Compare commits
4 Commits
d574159f82
...
f78fd7442c
| Author | SHA1 | Date | |
|---|---|---|---|
| f78fd7442c | |||
| a997c04cda | |||
| 70e16fccfb | |||
| f6a46b5b0a |
26
AGENTS.md
26
AGENTS.md
@ -593,3 +593,29 @@ AI 执行以下流程时,每一步都要向用户说明在做什么:
|
|||||||
1. **新增任何主任务入口必须走门闸**:不要绕过 `process_message_task` 直接驱动一轮模型对话;多智能体 idle 派发(task_type="notice")目前依赖 `_multi_agent_main_task_active` 标志,后续应统一纳管。
|
1. **新增任何主任务入口必须走门闸**:不要绕过 `process_message_task` 直接驱动一轮模型对话;多智能体 idle 派发(task_type="notice")目前依赖 `_multi_agent_main_task_active` 标志,后续应统一纳管。
|
||||||
2. **不要在 return 分支手写 `_tool_loop_active` 恢复**:`execute_tool_calls`(`server/chat_flow_tool_loop.py`)已改为守护包装(try/finally 复位,内层 `_execute_tool_calls_impl`),新增提前返回路径无需也不应手动操作该标志——并发交错「存旧值→置True→恢复旧值」正是此前标志卡死的原因。
|
2. **不要在 return 分支手写 `_tool_loop_active` 恢复**:`execute_tool_calls`(`server/chat_flow_tool_loop.py`)已改为守护包装(try/finally 复位,内层 `_execute_tool_calls_impl`),新增提前返回路径无需也不应手动操作该标志——并发交错「存旧值→置True→恢复旧值」正是此前标志卡死的原因。
|
||||||
3. **不要依赖 build_messages 防御层掩盖并发问题**:`core/main_terminal_parts/context/messages.py` 的孤儿 tool 消息剥离只是「坏数据不再 400」的止血层,乱序段本身意味着历史已被污染;发现剥离 warning 日志应按事故排查,而不是视为正常。
|
3. **不要依赖 build_messages 防御层掩盖并发问题**:`core/main_terminal_parts/context/messages.py` 的孤儿 tool 消息剥离只是「坏数据不再 400」的止血层,乱序段本身意味着历史已被污染;发现剥离 warning 日志应按事故排查,而不是视为正常。
|
||||||
|
|
||||||
|
## 13) 工具动态加载(tool_loading,2026-09 新增)
|
||||||
|
|
||||||
|
> 设计文档:`docs/dynamic_tool_loading_plan.md`。注册表与状态辅助唯一权威:`core/tool_loading.py`。
|
||||||
|
|
||||||
|
### 13.1 机制一句话
|
||||||
|
|
||||||
|
低频工具默认不进顶层 `tools` 数组,只在 system prompt 冻结段保留「类目+什么时候用+工具名」目录;模型需要时调常驻工具 `load_tools(tool_names=[...])`,在 tool result 里拿回完整 JSON 定义后直接调用;执行守门按对话 metadata 放行已加载工具。
|
||||||
|
|
||||||
|
### 13.2 硬约束(改代码必须知道)
|
||||||
|
|
||||||
|
1. **对话文件是唯一权威**:`metadata.tool_loading = {enabled, deferred_set, initial_exposed, loaded, pending}` 在**创建对话时快照一次**(注入点:`conversation_mixin.start_new_conversation`、`server/conversation.py` 活跃任务分支、`workflow_runtime_api.py`、`runtime/service.py`×2;多智能体对话不写入=不启用)。之后个人空间改动只影响新建对话;执行链路**禁止**为该功能读 personalization / Flask session。
|
||||||
|
2. **老对话无字段 = 未启用**:读侧一律走 `get_tool_loading_state()` 防御性解析(None 即未启用,全量工具),不做迁移。字段损坏同样按未启用处理。
|
||||||
|
3. **tools 数组对话级稳定**:过滤语义恒为「构建集 − deferred_set」,loaded 工具**不**回到数组(否则破坏前缀缓存);`initial_exposed` 只是首个请求懒记录的审计快照,不作为过滤依据。
|
||||||
|
4. **prompt 目录走现有冻结机制**:key=`frozen_tool_loading_prompt`(`_get_or_init_frozen_prompt`,拼接在 skills 段之后);模板 `prompts/tool_loading.txt` 只在冻结那一刻读一次。目录**不**随 loaded 状态变化(system prompt 是缓存首段,动了全破)。
|
||||||
|
5. **压缩必须重置**:深度压缩(`server/deep_compression.py`,in-place)、浅度压缩(`compression_mixin._run_auto_shallow_compression`)、手动压缩新建对话(同文件 `compress_conversation`)三处钩子统一做 `reset_state_after_compression`(loaded 清空、pending 回满 deferred_set)——load_tools 返回的定义活在 tool result 里,压缩后模型上下文已没有它们,不回滚守门会放行无定义调用。
|
||||||
|
6. **守门**:`tools_execution.py` 分发链前置——`deferred_set − loaded` 中的工具被调用时返回引导错误(`tools_exec.tool_not_loaded`),不执行;`load_tools` 本身在 `_READONLY_ALLOWED_TOOLS` 中(只读发现性质)。
|
||||||
|
7. **load_tools 的上下文 formatter 必须保留完整 JSON 定义**(`agent_context.py::_format_load_tools`)——定义靠 tool result 在上下文存续,只做摘要会导致历史重建后模型失去参数结构。
|
||||||
|
8. **close_sub_agent 已彻底删除**(2026-09,commit f6a46b5b):它自出生就是 terminate_sub_agent 的别名,拆分重构时执行分支丢失成僵尸。勿恢复;终止子智能体统一用 terminate_sub_agent。
|
||||||
|
|
||||||
|
### 13.3 新增可延迟工具 checklist
|
||||||
|
|
||||||
|
1. `core/tool_loading.py` 的 `DEFERRABLE_REGISTRY` 注册(类目 label + when_to_use 文案 + 工具名);默认延迟集=注册表全集,无需另配。
|
||||||
|
2. 确认后端 formatter(`tool_result_formatter`)与前端 renderer(`toolRenderers.ts`)已覆盖该工具。
|
||||||
|
3. 个人空间勾选 UI 自动出现(注册表经 `/api/personalization` 的 `tool_loading_registry` 下发,类目标签 i18n key `personalization.toolLoadingCat.<key>` 需双语补齐)。
|
||||||
|
4. 跑 `test/test_tool_loading.py`(注册表完整性断言会校验数量与结构)。
|
||||||
|
|||||||
@ -359,6 +359,43 @@ class MessagesMixin:
|
|||||||
if skills_prompt:
|
if skills_prompt:
|
||||||
messages.append({"role": "system", "content": skills_prompt})
|
messages.append({"role": "system", "content": skills_prompt})
|
||||||
|
|
||||||
|
# 工具动态加载目录(冻结段):仅对话启用且延迟集非空时注入。
|
||||||
|
# 老对话/关闭对话无字段,不调用冻结函数,避免把空串冻住
|
||||||
|
# (与 AGENTS.md 段同一模式)。目录创建即钉死、终身不变——
|
||||||
|
# system prompt 是前缀缓存首段,loaded/pending 变化不进 prompt。
|
||||||
|
_tl_state_guard = None
|
||||||
|
try:
|
||||||
|
from core.tool_loading import get_tool_loading_state as _get_tl_state
|
||||||
|
_tl_cm = getattr(self, "context_manager", None)
|
||||||
|
_tl_meta = getattr(_tl_cm, "conversation_metadata", None) if _tl_cm else None
|
||||||
|
_tl_state_guard = _get_tl_state(_tl_meta)
|
||||||
|
except Exception:
|
||||||
|
_tl_state_guard = None
|
||||||
|
|
||||||
|
def _build_tool_loading_prompt() -> str:
|
||||||
|
try:
|
||||||
|
from core.tool_loading import render_catalog
|
||||||
|
template = self.load_prompt("tool_loading").strip()
|
||||||
|
if not template or not _tl_state_guard:
|
||||||
|
return ""
|
||||||
|
unavailable = set(getattr(self, "disabled_tools", None) or set())
|
||||||
|
catalog = render_catalog(_tl_state_guard["deferred_set"], unavailable=unavailable)
|
||||||
|
if not catalog:
|
||||||
|
return ""
|
||||||
|
if "{tool_catalog}" in template:
|
||||||
|
return template.replace("{tool_catalog}", catalog)
|
||||||
|
return f"{template}\n{catalog}"
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
if _tl_state_guard and _tl_state_guard["deferred_set"]:
|
||||||
|
tool_loading_prompt = self._get_or_init_frozen_prompt(
|
||||||
|
"frozen_tool_loading_prompt",
|
||||||
|
_build_tool_loading_prompt,
|
||||||
|
)
|
||||||
|
if tool_loading_prompt:
|
||||||
|
messages.append({"role": "system", "content": tool_loading_prompt})
|
||||||
|
|
||||||
# 工作流(Workflow)上下文:不冻结,每次按当前状态现生成。
|
# 工作流(Workflow)上下文:不冻结,每次按当前状态现生成。
|
||||||
# 阶段推进时由 workflow_flow.refresh_workflow_system_segment 同步刷新,
|
# 阶段推进时由 workflow_flow.refresh_workflow_system_segment 同步刷新,
|
||||||
# 压缩不影响(system 段不在压缩范围),天然免疫压缩丢失。
|
# 压缩不影响(system 段不在压缩范围),天然免疫压缩丢失。
|
||||||
|
|||||||
@ -96,21 +96,12 @@ class ToolsDefinitionAgentToolsMixin:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning(f"[tools] 加载多智能体工具失败,回退旧版: {exc}")
|
logger.warning(f"[tools] 加载多智能体工具失败,回退旧版: {exc}")
|
||||||
return [
|
return [
|
||||||
{
|
# 【已删除】close_sub_agent(2026-09 彻底移除,勿恢复):
|
||||||
"type": "function",
|
# 该工具自 2025-11-15 引入起就是 terminate_sub_agent 的别名
|
||||||
"function": {
|
# (两个提交相隔 19 分钟,handler 直接调 terminate_sub_agent)。
|
||||||
"name": "close_sub_agent",
|
# main_terminal 拆分为 main_terminal_parts 时其执行分支丢失,
|
||||||
"description": "强制关闭指定子智能体,适用于长时间无响应、超时或卡死的任务。使用前请确认必要的日志/文件已保留,操作会立即终止该任务。",
|
# 仅剩定义残留——模型调用必落 unknown_tool 报错,纯僵尸工具。
|
||||||
"parameters": {
|
# 终止子智能体统一使用 terminate_sub_agent。
|
||||||
"type": "object",
|
|
||||||
"properties": self._inject_intent({
|
|
||||||
"task_id": {"type": "string", "description": "子智能体任务ID"},
|
|
||||||
"agent_id": {"type": "integer", "description": "子智能体编号(1~5),若缺少 task_id 可用"}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
{
|
||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
|
|||||||
@ -247,7 +247,7 @@ class ToolsDefinitionFileToolsMixin:
|
|||||||
"properties": {
|
"properties": {
|
||||||
"old_string": {
|
"old_string": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "要替换的文本(需与文件内容精确匹配,保留缩进;建议提供至少3行提升定位稳定性。需要批量替换的场景可以单行或不足一行)"
|
"description": "要替换的文本(需与文件内容精确匹配,保留缩进;必须提供至少3行内容,包含目标行及其前后上下文,不足3行将被拒绝执行)"
|
||||||
},
|
},
|
||||||
"new_string": {
|
"new_string": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
@ -68,6 +68,10 @@ from modules.mcp_server_registry import MCPServerRegistry, build_default_mcp_cat
|
|||||||
|
|
||||||
from modules.container_monitor import collect_stats, inspect_state
|
from modules.container_monitor import collect_stats, inspect_state
|
||||||
from core.tool_config import TOOL_CATEGORIES
|
from core.tool_config import TOOL_CATEGORIES
|
||||||
|
from core.tool_loading import (
|
||||||
|
build_load_tools_definition,
|
||||||
|
get_tool_loading_state,
|
||||||
|
)
|
||||||
from utils.api_client import APIClient
|
from utils.api_client import APIClient
|
||||||
from utils.context_manager import ContextManager
|
from utils.context_manager import ContextManager
|
||||||
from utils.tool_result_formatter import format_tool_result_for_context
|
from utils.tool_result_formatter import format_tool_result_for_context
|
||||||
@ -86,8 +90,12 @@ DISABLE_LENGTH_CHECK = True
|
|||||||
|
|
||||||
|
|
||||||
class ToolsDefinitionMainMixin:
|
class ToolsDefinitionMainMixin:
|
||||||
def define_tools(self) -> List[Dict]:
|
def define_tools(self, include_deferred: bool = False) -> List[Dict]:
|
||||||
"""定义可用工具(添加确认工具)"""
|
"""定义可用工具(添加确认工具)
|
||||||
|
|
||||||
|
include_deferred=True 时跳过延迟工具过滤(供 load_tools 取
|
||||||
|
完整定义用),正常请求走默认 False。
|
||||||
|
"""
|
||||||
tools: List[Dict] = []
|
tools: List[Dict] = []
|
||||||
tools.extend(self._build_core_tools())
|
tools.extend(self._build_core_tools())
|
||||||
tools.extend(self._build_file_tools())
|
tools.extend(self._build_file_tools())
|
||||||
@ -158,6 +166,48 @@ class ToolsDefinitionMainMixin:
|
|||||||
if tool.get("function", {}).get("name") not in self.disabled_tools
|
if tool.get("function", {}).get("name") not in self.disabled_tools
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# 工具动态加载:按对话快照过滤延迟工具。快照创建即钉死,
|
||||||
|
# tools 数组对话级稳定(不破坏前缀缓存);老对话无字段=全量。
|
||||||
|
# load_tools 取完整定义走 include_deferred=True,跳过本段。
|
||||||
|
tl_state = None
|
||||||
|
if not include_deferred:
|
||||||
|
try:
|
||||||
|
_tl_cm = getattr(self, "context_manager", None)
|
||||||
|
_tl_meta = getattr(_tl_cm, "conversation_metadata", None) if _tl_cm else None
|
||||||
|
tl_state = get_tool_loading_state(_tl_meta)
|
||||||
|
except Exception:
|
||||||
|
tl_state = None
|
||||||
|
if tl_state:
|
||||||
|
_deferred = set(tl_state["deferred_set"])
|
||||||
|
tools = [
|
||||||
|
tool for tool in tools
|
||||||
|
if (tool.get("function") or {}).get("name") not in _deferred
|
||||||
|
]
|
||||||
|
if _deferred:
|
||||||
|
tools.append(build_load_tools_definition())
|
||||||
|
# 懒记录 initial_exposed:首个请求写入一次。该字段仅作审计
|
||||||
|
# 快照,过滤语义恒为「构建集 − deferred_set」,不依赖它。
|
||||||
|
if not tl_state["initial_exposed"]:
|
||||||
|
try:
|
||||||
|
_tl_conv_id = getattr(_tl_cm, "current_conversation_id", None) if _tl_cm else None
|
||||||
|
if _tl_conv_id:
|
||||||
|
_exposed = [
|
||||||
|
name for name in (
|
||||||
|
(t.get("function") or {}).get("name") for t in tools
|
||||||
|
) if name
|
||||||
|
]
|
||||||
|
_new_state = {**tl_state, "initial_exposed": _exposed}
|
||||||
|
_mgr = (
|
||||||
|
_tl_cm._get_conversation_manager_for_id(_tl_conv_id)
|
||||||
|
if hasattr(_tl_cm, "_get_conversation_manager_for_id")
|
||||||
|
else _tl_cm.conversation_manager
|
||||||
|
)
|
||||||
|
_mgr.update_conversation_metadata(_tl_conv_id, {"tool_loading": _new_state})
|
||||||
|
if isinstance(_tl_cm.conversation_metadata, dict):
|
||||||
|
_tl_cm.conversation_metadata["tool_loading"] = _new_state
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# 调试日志:记录工具列表(DEBUG 级,不上终端)
|
# 调试日志:记录工具列表(DEBUG 级,不上终端)
|
||||||
tool_names = [t.get("function", {}).get("name") for t in tools]
|
tool_names = [t.get("function", {}).get("name") for t in tools]
|
||||||
logger.debug("[define_tools] 可用工具列表: %s", tool_names)
|
logger.debug("[define_tools] 可用工具列表: %s", tool_names)
|
||||||
|
|||||||
@ -184,7 +184,6 @@ class MainTerminalToolsExecutionMixin:
|
|||||||
}
|
}
|
||||||
_SUB_AGENT_SERIES_TOOLS = {
|
_SUB_AGENT_SERIES_TOOLS = {
|
||||||
"create_sub_agent",
|
"create_sub_agent",
|
||||||
"close_sub_agent",
|
|
||||||
"terminate_sub_agent",
|
"terminate_sub_agent",
|
||||||
"get_sub_agent_status",
|
"get_sub_agent_status",
|
||||||
}
|
}
|
||||||
@ -217,7 +216,6 @@ class MainTerminalToolsExecutionMixin:
|
|||||||
"terminal_snapshot",
|
"terminal_snapshot",
|
||||||
# 子智能体全生命周期(只读权限会传播给子智能体,计划模式调研主力)
|
# 子智能体全生命周期(只读权限会传播给子智能体,计划模式调研主力)
|
||||||
"create_sub_agent",
|
"create_sub_agent",
|
||||||
"close_sub_agent",
|
|
||||||
"terminate_sub_agent",
|
"terminate_sub_agent",
|
||||||
"get_sub_agent_status",
|
"get_sub_agent_status",
|
||||||
# MCP 服务列举(mcp__* 工具在 evaluate_tool_permission 中按前缀放行)
|
# MCP 服务列举(mcp__* 工具在 evaluate_tool_permission 中按前缀放行)
|
||||||
@ -225,6 +223,8 @@ class MainTerminalToolsExecutionMixin:
|
|||||||
# 工作流只读查询(激活/推进/停用/保存等状态写操作仍禁止)
|
# 工作流只读查询(激活/推进/停用/保存等状态写操作仍禁止)
|
||||||
"list_workflows",
|
"list_workflows",
|
||||||
"get_workflow_status",
|
"get_workflow_status",
|
||||||
|
# 工具动态加载入口(只读发现性质,各权限模式放行)
|
||||||
|
"load_tools",
|
||||||
}
|
}
|
||||||
_APPROVAL_REQUIRED_TOOLS = {
|
_APPROVAL_REQUIRED_TOOLS = {
|
||||||
"run_command",
|
"run_command",
|
||||||
@ -1074,6 +1074,21 @@ class MainTerminalToolsExecutionMixin:
|
|||||||
if not await self.confirm_action(tool_name, arguments):
|
if not await self.confirm_action(tool_name, arguments):
|
||||||
return json.dumps({"success": False, "error": tr("tools_exec.action_cancelled")})
|
return json.dumps({"success": False, "error": tr("tools_exec.action_cancelled")})
|
||||||
|
|
||||||
|
# 工具动态加载守门:延迟工具未加载时拦截并引导先 load_tools,
|
||||||
|
# 防止模型凭目录里的工具名猜参数盲调(读侧异常按未启用放行)。
|
||||||
|
try:
|
||||||
|
from core.tool_loading import get_tool_loading_state, is_deferred_not_loaded
|
||||||
|
_tl_cm_guard = getattr(self, "context_manager", None)
|
||||||
|
_tl_meta_guard = getattr(_tl_cm_guard, "conversation_metadata", None) if _tl_cm_guard else None
|
||||||
|
if is_deferred_not_loaded(get_tool_loading_state(_tl_meta_guard), tool_name):
|
||||||
|
return json.dumps({
|
||||||
|
"success": False,
|
||||||
|
"tool_not_loaded": True,
|
||||||
|
"error": tr("tools_exec.tool_not_loaded", tool_name=tool_name),
|
||||||
|
}, ensure_ascii=False)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# === 新增:预检查参数大小和格式 ===
|
# === 新增:预检查参数大小和格式 ===
|
||||||
try:
|
try:
|
||||||
# 检查参数总大小
|
# 检查参数总大小
|
||||||
@ -2532,6 +2547,81 @@ class MainTerminalToolsExecutionMixin:
|
|||||||
elif tool_name == "trigger_easter_egg":
|
elif tool_name == "trigger_easter_egg":
|
||||||
result = self.easter_egg_manager.trigger_effect(arguments.get("effect"))
|
result = self.easter_egg_manager.trigger_effect(arguments.get("effect"))
|
||||||
|
|
||||||
|
elif tool_name == "load_tools":
|
||||||
|
# 工具动态加载:按名返回完整定义,并把 loaded/pending 落盘
|
||||||
|
from core.tool_loading import (
|
||||||
|
get_tool_loading_state as _tl_get_state,
|
||||||
|
mark_tools_loaded as _tl_mark_loaded,
|
||||||
|
)
|
||||||
|
_tl_cm = getattr(self, "context_manager", None)
|
||||||
|
_tl_meta = getattr(_tl_cm, "conversation_metadata", None) if _tl_cm else None
|
||||||
|
_tl_state = _tl_get_state(_tl_meta)
|
||||||
|
if not _tl_state:
|
||||||
|
result = {"success": False, "error": tr("tools_exec.load_tools_not_enabled")}
|
||||||
|
else:
|
||||||
|
_names = arguments.get("tool_names")
|
||||||
|
if (
|
||||||
|
not isinstance(_names, list) or not _names
|
||||||
|
or not all(isinstance(n, str) and n.strip() for n in _names)
|
||||||
|
):
|
||||||
|
result = {"success": False, "error": tr("tools_exec.load_tools_invalid_names")}
|
||||||
|
else:
|
||||||
|
_names = list(dict.fromkeys(n.strip() for n in _names))
|
||||||
|
_available = set(_tl_state["loaded"]) | set(_tl_state["pending"])
|
||||||
|
_unknown = [n for n in _names if n not in _available]
|
||||||
|
if _unknown:
|
||||||
|
result = {
|
||||||
|
"success": False,
|
||||||
|
"error": tr(
|
||||||
|
"tools_exec.load_tools_unknown",
|
||||||
|
names=", ".join(_unknown),
|
||||||
|
available=", ".join(_tl_state["pending"]) or tr("tools_exec.none_placeholder"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# include_deferred=True 取完整定义(disabled_tools 过滤仍生效)
|
||||||
|
_all_defs = self.define_tools(include_deferred=True) or []
|
||||||
|
_def_map = {
|
||||||
|
(d.get("function") or {}).get("name"): d for d in _all_defs
|
||||||
|
}
|
||||||
|
_defs = [_def_map[n] for n in _names if n in _def_map]
|
||||||
|
_missing = [n for n in _names if n not in _def_map]
|
||||||
|
if _missing and not _defs:
|
||||||
|
result = {
|
||||||
|
"success": False,
|
||||||
|
"error": tr("tools_exec.load_tools_unavailable", names=", ".join(_missing)),
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
_already = [n for n in _names if n in _tl_state["loaded"]]
|
||||||
|
_new = [n for n in _names if n not in _tl_state["loaded"] and n in _def_map]
|
||||||
|
_new_state = _tl_mark_loaded(_tl_state, [n for n in _names if n in _def_map])
|
||||||
|
# 持久化 loaded/pending(与冻结 prompt 同一 metadata 写入通道)
|
||||||
|
try:
|
||||||
|
_conv_id = getattr(_tl_cm, "current_conversation_id", None) if _tl_cm else None
|
||||||
|
if _conv_id:
|
||||||
|
_mgr = (
|
||||||
|
_tl_cm._get_conversation_manager_for_id(_conv_id)
|
||||||
|
if hasattr(_tl_cm, "_get_conversation_manager_for_id")
|
||||||
|
else _tl_cm.conversation_manager
|
||||||
|
)
|
||||||
|
_mgr.update_conversation_metadata(_conv_id, {"tool_loading": _new_state})
|
||||||
|
if isinstance(_tl_cm.conversation_metadata, dict):
|
||||||
|
_tl_cm.conversation_metadata["tool_loading"] = _new_state
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
result = {
|
||||||
|
"success": True,
|
||||||
|
"tools": _defs,
|
||||||
|
"loaded_now": _new,
|
||||||
|
"already_loaded": _already,
|
||||||
|
**({"unavailable": _missing} if _missing else {}),
|
||||||
|
"message": tr(
|
||||||
|
"tools_exec.load_tools_loaded",
|
||||||
|
n=len(_defs),
|
||||||
|
names=", ".join(n for n in _names if n in _def_map),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
elif tool_name == "manage_personalization":
|
elif tool_name == "manage_personalization":
|
||||||
logger.info("[handle_tool_call] 进入manage_personalization分支")
|
logger.info("[handle_tool_call] 进入manage_personalization分支")
|
||||||
result = await self._execute_manage_personalization(arguments)
|
result = await self._execute_manage_personalization(arguments)
|
||||||
|
|||||||
@ -78,7 +78,7 @@ TOOL_CATEGORIES: Dict[str, ToolCategory] = {
|
|||||||
),
|
),
|
||||||
"sub_agent": ToolCategory(
|
"sub_agent": ToolCategory(
|
||||||
label="子智能体",
|
label="子智能体",
|
||||||
tools=["create_sub_agent", "close_sub_agent"],
|
tools=["create_sub_agent", "terminate_sub_agent", "get_sub_agent_status"],
|
||||||
),
|
),
|
||||||
"easter_egg": ToolCategory(
|
"easter_egg": ToolCategory(
|
||||||
label="彩蛋实验",
|
label="彩蛋实验",
|
||||||
|
|||||||
322
core/tool_loading.py
Normal file
322
core/tool_loading.py
Normal file
@ -0,0 +1,322 @@
|
|||||||
|
"""工具动态加载(Deferred Tool Loading)注册表与状态辅助。
|
||||||
|
|
||||||
|
设计文档:docs/dynamic_tool_loading_plan.md
|
||||||
|
|
||||||
|
核心语义(与 runtime_contract 默认值解析优先级对齐:对话元数据绑定 > 用户偏好快照):
|
||||||
|
- 对话创建时把五元组快照写入 conversation metadata["tool_loading"]:
|
||||||
|
enabled / deferred_set / initial_exposed / loaded / pending
|
||||||
|
- 快照创建即钉死,个人空间设置之后只影响新建对话;
|
||||||
|
- load_tools 成功后 loaded/pending 更新(同一写入点);
|
||||||
|
- 对话压缩(深度+浅度)后 reset_state_after_compression 重置 loaded/pending;
|
||||||
|
- 读侧全部防御性解析:字段缺失/损坏一律视为未启用(回退全量工具,安全方向)。
|
||||||
|
|
||||||
|
注意:本模块不读 personalization、不读 Flask session。快照计算所需的用户配置
|
||||||
|
由调用方(适配层)读好后传入。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any, Dict, Iterable, List, Optional, Set
|
||||||
|
|
||||||
|
METADATA_KEY = "tool_loading"
|
||||||
|
FROZEN_PROMPT_KEY = "frozen_tool_loading_prompt"
|
||||||
|
LOAD_TOOLS_NAME = "load_tools"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 可延迟工具注册表
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 类目 key -> {"label": 展示名, "when_to_use": prompt 目录里的「什么时候用」文案,
|
||||||
|
# "tools": [工具名...]}
|
||||||
|
# 新增可延迟工具 checklist(详见 AGENTS.md 工具动态加载一节):
|
||||||
|
# 1. 在此注册类目/工具;2. 评估是否进默认延迟集(DEFAULT_DEFERRED 由注册表全集
|
||||||
|
# 构成,默认即全延迟,无需另配);3. 确认 formatter / 前端 renderer 已覆盖。
|
||||||
|
DEFERRABLE_REGISTRY: Dict[str, Dict[str, Any]] = {
|
||||||
|
"workflow": {
|
||||||
|
"label": "工作流",
|
||||||
|
"when_to_use": "按既定流程执行任务、创建/调整工作流、推进或查询已激活的工作流时",
|
||||||
|
"tools": [
|
||||||
|
"activate_workflow",
|
||||||
|
"report_workflow_stage",
|
||||||
|
"choose_workflow_branch",
|
||||||
|
"get_workflow_status",
|
||||||
|
"deactivate_workflow",
|
||||||
|
"list_workflows",
|
||||||
|
"save_workflow",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"sub_agent": {
|
||||||
|
"label": "子智能体",
|
||||||
|
"when_to_use": "并行处理独立任务、批量或后台执行、查询/终止子智能体时",
|
||||||
|
"tools": [
|
||||||
|
"create_sub_agent",
|
||||||
|
"get_sub_agent_status",
|
||||||
|
"terminate_sub_agent",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"conversation": {
|
||||||
|
"label": "对话回顾",
|
||||||
|
"when_to_use": "查找或回顾本工作区的历史对话时",
|
||||||
|
"tools": [
|
||||||
|
"conversation_search",
|
||||||
|
"conversation_review",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"memory_write": {
|
||||||
|
"label": "记忆写入",
|
||||||
|
"when_to_use": "记录用户偏好、项目约定或重要决策时",
|
||||||
|
"tools": [
|
||||||
|
"update_memory",
|
||||||
|
"update_project_memory",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"personalization": {
|
||||||
|
"label": "个性化",
|
||||||
|
"when_to_use": "修改称呼、语气、主题等个性化配置时",
|
||||||
|
"tools": [
|
||||||
|
"manage_personalization",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"skill_create": {
|
||||||
|
"label": "技能",
|
||||||
|
"when_to_use": "把经验沉淀为可复用 skill 时",
|
||||||
|
"tools": [
|
||||||
|
"create_skill",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"mcp": {
|
||||||
|
"label": "MCP",
|
||||||
|
"when_to_use": "查看或刷新 MCP 服务与工具映射时",
|
||||||
|
"tools": [
|
||||||
|
"list_mcp_servers",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"misc": {
|
||||||
|
"label": "彩蛋",
|
||||||
|
"when_to_use": "触发隐藏彩蛋时",
|
||||||
|
"tools": [
|
||||||
|
"trigger_easter_egg",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def deferrable_tool_names() -> List[str]:
|
||||||
|
"""注册表内全部可延迟工具名(保序)。"""
|
||||||
|
names: List[str] = []
|
||||||
|
for cat in DEFERRABLE_REGISTRY.values():
|
||||||
|
names.extend(cat["tools"])
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
def category_of_tool(tool_name: str) -> Optional[str]:
|
||||||
|
for cat_key, cat in DEFERRABLE_REGISTRY.items():
|
||||||
|
if tool_name in cat["tools"]:
|
||||||
|
return cat_key
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def build_registry_payload() -> List[Dict[str, Any]]:
|
||||||
|
"""供个人空间 UI 使用的注册表载荷(类目标签由前端 i18n 解析)。"""
|
||||||
|
return [
|
||||||
|
{"key": cat_key, "tools": list(cat["tools"])}
|
||||||
|
for cat_key, cat in DEFERRABLE_REGISTRY.items()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 状态读写(全部防御性解析)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _normalize_name_list(value: Any) -> List[str]:
|
||||||
|
if not isinstance(value, list):
|
||||||
|
return []
|
||||||
|
result: List[str] = []
|
||||||
|
for item in value:
|
||||||
|
if isinstance(item, str) and item and item not in result:
|
||||||
|
result.append(item)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def get_tool_loading_state(metadata: Any) -> Optional[Dict[str, Any]]:
|
||||||
|
"""从对话 metadata 读取并归一化 tool_loading 状态。
|
||||||
|
|
||||||
|
返回 None 表示未启用(老对话无字段 / enabled 非真 / 结构损坏)。
|
||||||
|
返回的 dict 保证含 enabled/deferred_set/initial_exposed/loaded/pending 五键,
|
||||||
|
且 loaded/pending 相互一致(pending = deferred_set − loaded 重算为准)。
|
||||||
|
"""
|
||||||
|
if not isinstance(metadata, dict):
|
||||||
|
return None
|
||||||
|
raw = metadata.get(METADATA_KEY)
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return None
|
||||||
|
if raw.get("enabled") is not True:
|
||||||
|
return None
|
||||||
|
deferred_set = [n for n in _normalize_name_list(raw.get("deferred_set"))
|
||||||
|
if category_of_tool(n) is not None]
|
||||||
|
loaded = [n for n in _normalize_name_list(raw.get("loaded")) if n in deferred_set]
|
||||||
|
initial_exposed = _normalize_name_list(raw.get("initial_exposed"))
|
||||||
|
state = {
|
||||||
|
"enabled": True,
|
||||||
|
"deferred_set": deferred_set,
|
||||||
|
"initial_exposed": initial_exposed,
|
||||||
|
"loaded": loaded,
|
||||||
|
"pending": [n for n in deferred_set if n not in loaded],
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def is_tool_loading_enabled(metadata: Any) -> bool:
|
||||||
|
return get_tool_loading_state(metadata) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def is_deferred_not_loaded(state: Optional[Dict[str, Any]], tool_name: str) -> bool:
|
||||||
|
"""守门判定:该工具处于「已延迟且尚未加载」状态(应拦截并引导 load_tools)。"""
|
||||||
|
if not state:
|
||||||
|
return False
|
||||||
|
return tool_name in state["pending"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 快照与状态迁移
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def build_snapshot(
|
||||||
|
deferred_config: Optional[Iterable[str]],
|
||||||
|
built_tool_names: Optional[Iterable[str]] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""计算创建对话时的 tool_loading 快照(五元组)。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
deferred_config: 用户配置要延迟的工具名(None/非法 => 默认全集)。
|
||||||
|
built_tool_names: 创建时实际暴露的工具名全集(用于记录 initial_exposed;
|
||||||
|
不传则记录为空列表,由 define_tools 以「构建集 − deferred_set」语义
|
||||||
|
动态过滤,不影响行为)。
|
||||||
|
"""
|
||||||
|
registry_names = deferrable_tool_names()
|
||||||
|
if deferred_config is None:
|
||||||
|
deferred_set = list(registry_names)
|
||||||
|
else:
|
||||||
|
requested = set(_normalize_name_list(list(deferred_config)))
|
||||||
|
deferred_set = [n for n in registry_names if n in requested]
|
||||||
|
|
||||||
|
initial_exposed: List[str] = []
|
||||||
|
if built_tool_names is not None:
|
||||||
|
deferred_lookup = set(deferred_set)
|
||||||
|
initial_exposed = [n for n in built_tool_names if n not in deferred_lookup]
|
||||||
|
if deferred_set and LOAD_TOOLS_NAME not in initial_exposed:
|
||||||
|
initial_exposed.append(LOAD_TOOLS_NAME)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"enabled": True,
|
||||||
|
"deferred_set": deferred_set,
|
||||||
|
"initial_exposed": initial_exposed,
|
||||||
|
"loaded": [],
|
||||||
|
"pending": list(deferred_set),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot_overrides_from_prefs(
|
||||||
|
personalization_config: Any,
|
||||||
|
multi_agent_mode: bool = False,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""创建对话时合并进 metadata_overrides 的 tool_loading 部分(适配层调用)。
|
||||||
|
|
||||||
|
返回 {} 表示本对话不启用(多智能体对话 v1 不启用 / 个人空间总开关关闭)。
|
||||||
|
老对话(功能上线前创建)无此字段,读侧一律按未启用处理,不做迁移。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
personalization_config: 调用方已加载的个人空间配置(本函数不做 I/O)。
|
||||||
|
multi_agent_mode: 是否为多智能体对话。
|
||||||
|
"""
|
||||||
|
if multi_agent_mode:
|
||||||
|
return {}
|
||||||
|
prefs = personalization_config if isinstance(personalization_config, dict) else {}
|
||||||
|
if prefs.get("tool_loading_enabled", True) is not True:
|
||||||
|
return {}
|
||||||
|
return {METADATA_KEY: build_snapshot(prefs.get("tool_loading_deferred"))}
|
||||||
|
|
||||||
|
|
||||||
|
def mark_tools_loaded(state: Dict[str, Any], names: Iterable[str]) -> Dict[str, Any]:
|
||||||
|
"""返回 loaded 追加指定工具后的新 state(pending 同步重算)。"""
|
||||||
|
deferred_set = list(state.get("deferred_set") or [])
|
||||||
|
loaded = list(state.get("loaded") or [])
|
||||||
|
for name in names:
|
||||||
|
if name in deferred_set and name not in loaded:
|
||||||
|
loaded.append(name)
|
||||||
|
return {
|
||||||
|
**state,
|
||||||
|
"loaded": loaded,
|
||||||
|
"pending": [n for n in deferred_set if n not in loaded],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def reset_state_after_compression(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""对话压缩后的重置:loaded 清空、pending 回满 deferred_set。
|
||||||
|
|
||||||
|
压缩会丢弃/摘要历史中的 load_tools 工具结果,模型上下文里已没有这些
|
||||||
|
定义,状态必须随之前滚到「创建时」语义;initial_exposed 与 deferred_set
|
||||||
|
不变,冻结 prompt 目录依然准确。
|
||||||
|
"""
|
||||||
|
deferred_set = list(state.get("deferred_set") or [])
|
||||||
|
return {
|
||||||
|
**state,
|
||||||
|
"loaded": [],
|
||||||
|
"pending": list(deferred_set),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# load_tools 工具定义
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def build_load_tools_definition() -> Dict[str, Any]:
|
||||||
|
"""构建 load_tools 的工具定义(intent 由 define_tools 末尾统一注入)。"""
|
||||||
|
return {
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": LOAD_TOOLS_NAME,
|
||||||
|
"description": (
|
||||||
|
"按名加载「按需加载的工具」目录中列出的未加载工具:在工具结果中返回这些工具的"
|
||||||
|
"完整 JSON 定义,之后即可像普通工具一样直接调用。一次可传多个工具名;"
|
||||||
|
"用户意图明确属于某个类目时,建议一次性加载该类目所需的全部工具。"
|
||||||
|
"已加载的工具在本对话中持续可用,重复传入会再次返回定义。"
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"tool_names": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "string"},
|
||||||
|
"description": (
|
||||||
|
"要加载的工具名列表,必须来自 system prompt 中「按需加载的工具」"
|
||||||
|
"目录里列出的工具名,例如 [\"activate_workflow\", \"list_workflows\"]"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["tool_names"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# prompt 目录渲染
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def render_catalog(
|
||||||
|
deferred_set: Iterable[str],
|
||||||
|
unavailable: Optional[Set[str]] = None,
|
||||||
|
) -> str:
|
||||||
|
"""按类目渲染 prompt 目录(仅含该对话实际延迟的工具;空类目整行省略)。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
deferred_set: 该对话延迟集。
|
||||||
|
unavailable: 当前不可用的工具(如分类被禁用),从目录中剔除。
|
||||||
|
"""
|
||||||
|
deferred: Set[str] = set(deferred_set or [])
|
||||||
|
skip: Set[str] = set(unavailable or set())
|
||||||
|
lines: List[str] = []
|
||||||
|
for cat in DEFERRABLE_REGISTRY.values():
|
||||||
|
tools = [n for n in cat["tools"] if n in deferred and n not in skip]
|
||||||
|
if not tools:
|
||||||
|
continue
|
||||||
|
lines.append(f"- {cat['label']}({', '.join(tools)}):{cat['when_to_use']}")
|
||||||
|
return "\n".join(lines)
|
||||||
@ -73,7 +73,12 @@ class ReplaceMixin:
|
|||||||
"error": tr("file_manager.replace_new_text_too_long"),
|
"error": tr("file_manager.replace_new_text_too_long"),
|
||||||
"suggestion": "请将大内容分成多个小的替换操作"
|
"suggestion": "请将大内容分成多个小的替换操作"
|
||||||
}
|
}
|
||||||
short_old_text_notice = bool(old_text and len(old_text.splitlines()) < 3)
|
if old_text and len(old_text.splitlines()) < 3:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": tr("file_manager.replace_old_text_too_short"),
|
||||||
|
"suggestion": "请在 old_string 中包含目标行及其前后上下文(至少3行)后重试"
|
||||||
|
}
|
||||||
|
|
||||||
# 检查是否包含要替换的内容
|
# 检查是否包含要替换的内容
|
||||||
if old_text and old_text not in content:
|
if old_text and old_text not in content:
|
||||||
@ -111,11 +116,6 @@ class ReplaceMixin:
|
|||||||
lines=line_text,
|
lines=line_text,
|
||||||
count=count,
|
count=count,
|
||||||
))
|
))
|
||||||
if short_old_text_notice:
|
|
||||||
message_parts.insert(
|
|
||||||
0,
|
|
||||||
tr("file_manager.replace_short_old_notice")
|
|
||||||
)
|
|
||||||
if message_parts:
|
if message_parts:
|
||||||
result["message"] = ";".join(message_parts)
|
result["message"] = ";".join(message_parts)
|
||||||
print(f"{OUTPUT_FORMATS['file']} 替换了 {count} 处内容")
|
print(f"{OUTPUT_FORMATS['file']} 替换了 {count} 处内容")
|
||||||
@ -144,7 +144,6 @@ class ReplaceMixin:
|
|||||||
failed_details: List[Dict[str, Any]] = []
|
failed_details: List[Dict[str, Any]] = []
|
||||||
total_replacements = 0
|
total_replacements = 0
|
||||||
total_found_matches = 0
|
total_found_matches = 0
|
||||||
short_old_text_indices: List[int] = []
|
|
||||||
|
|
||||||
for zero_based_index, item in enumerate(replacements):
|
for zero_based_index, item in enumerate(replacements):
|
||||||
index = zero_based_index + 1
|
index = zero_based_index + 1
|
||||||
@ -230,7 +229,13 @@ class ReplaceMixin:
|
|||||||
break
|
break
|
||||||
|
|
||||||
if len(old_text.splitlines()) < 3:
|
if len(old_text.splitlines()) < 3:
|
||||||
short_old_text_indices.append(index)
|
detail.update({
|
||||||
|
"status": "error",
|
||||||
|
"reason": "old_string 少于3行,请包含目标行及其前后上下文(至少3行)后重试"
|
||||||
|
})
|
||||||
|
failed_details.append(detail.copy())
|
||||||
|
details.append(detail)
|
||||||
|
break
|
||||||
|
|
||||||
matched_lines = self._find_match_line_numbers(current_content, old_text)
|
matched_lines = self._find_match_line_numbers(current_content, old_text)
|
||||||
found_count = len(matched_lines)
|
found_count = len(matched_lines)
|
||||||
@ -300,9 +305,6 @@ class ReplaceMixin:
|
|||||||
groups=len(replacements),
|
groups=len(replacements),
|
||||||
replacements=total_replacements,
|
replacements=total_replacements,
|
||||||
)]
|
)]
|
||||||
if short_old_text_indices:
|
|
||||||
indices_text = ",".join(str(item) for item in short_old_text_indices)
|
|
||||||
message_parts.append(tr("file_manager.replace_many_short_notice", indices=indices_text))
|
|
||||||
write_result["message"] = ";".join(message_parts)
|
write_result["message"] = ";".join(message_parts)
|
||||||
print(f"{OUTPUT_FORMATS['file']} 批量替换了 {total_replacements} 处内容")
|
print(f"{OUTPUT_FORMATS['file']} 批量替换了 {total_replacements} 处内容")
|
||||||
return write_result
|
return write_result
|
||||||
|
|||||||
@ -258,16 +258,12 @@ MESSAGES = {
|
|||||||
"zh-CN": "发现{found}处,于{lines}行共替换{count}处",
|
"zh-CN": "发现{found}处,于{lines}行共替换{count}处",
|
||||||
"en-US": "Found {found} matches; replaced {count} at lines {lines}",
|
"en-US": "Found {found} matches; replaced {count} at lines {lines}",
|
||||||
},
|
},
|
||||||
"file_manager.replace_short_old_notice": {
|
"file_manager.replace_old_text_too_short": {
|
||||||
"zh-CN": "提示:old_string 少于3行,已继续执行;需要批量替换的场景可以单行或不足一行",
|
"zh-CN": "old_string 少于3行:为确保精确匹配,请提供至少3行内容(包含目标行及其前后上下文)",
|
||||||
"en-US": "Note: old_string is shorter than 3 lines; execution continued. For batch replacement, use single-line or shorter strings",
|
"en-US": "old_string has fewer than 3 lines: to ensure precise matching, provide at least 3 lines including the target line and its surrounding context",
|
||||||
},
|
},
|
||||||
"file_manager.replace_many_summary": {
|
"file_manager.replace_many_summary": {
|
||||||
"zh-CN": "共 {groups} 组替换,替换 {replacements} 处",
|
"zh-CN": "共 {groups} 组替换,替换 {replacements} 处",
|
||||||
"en-US": "{groups} groups processed; {replacements} replacements made",
|
"en-US": "{groups} groups processed; {replacements} replacements made",
|
||||||
},
|
},
|
||||||
"file_manager.replace_many_short_notice": {
|
|
||||||
"zh-CN": "提示:第 {indices} 组 old_string 少于3行,已继续执行",
|
|
||||||
"en-US": "Note: old_string in groups {indices} is shorter than 3 lines; execution continued",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
@ -164,14 +164,22 @@ MESSAGES = {
|
|||||||
"zh-CN": "子智能体任务失败",
|
"zh-CN": "子智能体任务失败",
|
||||||
"en-US": "Sub-agent task failed",
|
"en-US": "Sub-agent task failed",
|
||||||
},
|
},
|
||||||
"fmt_agent2.closed": {
|
|
||||||
"zh-CN": "子智能体已关闭。",
|
|
||||||
"en-US": "Sub-agent closed.",
|
|
||||||
},
|
|
||||||
"fmt_agent2.force_closed": {
|
"fmt_agent2.force_closed": {
|
||||||
"zh-CN": "子智能体已被强制关闭。",
|
"zh-CN": "子智能体已被强制关闭。",
|
||||||
"en-US": "Sub-agent was forcibly closed.",
|
"en-US": "Sub-agent was forcibly closed.",
|
||||||
},
|
},
|
||||||
|
"fmt_tool_loading.loaded": {
|
||||||
|
"zh-CN": "已加载 {n} 个工具:{names}",
|
||||||
|
"en-US": "Loaded {n} tool(s): {names}",
|
||||||
|
},
|
||||||
|
"fmt_tool_loading.already": {
|
||||||
|
"zh-CN": "其中此前已加载:{names}",
|
||||||
|
"en-US": "Already loaded before: {names}",
|
||||||
|
},
|
||||||
|
"fmt_tool_loading.defs_header": {
|
||||||
|
"zh-CN": "工具完整定义(之后可直接调用,无需重复加载):",
|
||||||
|
"en-US": "Full tool definitions (call them directly afterwards; no need to reload):",
|
||||||
|
},
|
||||||
"fmt_agent2.paused": {
|
"fmt_agent2.paused": {
|
||||||
"zh-CN": "子智能体已暂停。",
|
"zh-CN": "子智能体已暂停。",
|
||||||
"en-US": "Sub-agent paused.",
|
"en-US": "Sub-agent paused.",
|
||||||
|
|||||||
@ -522,6 +522,32 @@ MESSAGES = {
|
|||||||
"en-US": "Tool execution error: {error}",
|
"en-US": "Tool execution error: {error}",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
# ── 工具动态加载(load_tools) ──
|
||||||
|
"tools_exec.tool_not_loaded": {
|
||||||
|
"zh-CN": "工具 {tool_name} 未加载。请先调用 load_tools(tool_names=[\"{tool_name}\"]) 获取完整定义后再调用。",
|
||||||
|
"en-US": "Tool {tool_name} is not loaded. Call load_tools(tool_names=[\"{tool_name}\"]) first to get its full definition, then invoke it.",
|
||||||
|
},
|
||||||
|
"tools_exec.load_tools_not_enabled": {
|
||||||
|
"zh-CN": "本对话未启用工具动态加载,所有工具均已直接可用,无需加载。",
|
||||||
|
"en-US": "Dynamic tool loading is not enabled for this conversation; all tools are already available.",
|
||||||
|
},
|
||||||
|
"tools_exec.load_tools_invalid_names": {
|
||||||
|
"zh-CN": "参数 tool_names 必须是非空的工具名数组。",
|
||||||
|
"en-US": "Argument tool_names must be a non-empty array of tool names.",
|
||||||
|
},
|
||||||
|
"tools_exec.load_tools_unknown": {
|
||||||
|
"zh-CN": "以下工具不在可加载列表中:{names}。当前可加载的工具:{available}",
|
||||||
|
"en-US": "These tools cannot be loaded: {names}. Currently loadable tools: {available}",
|
||||||
|
},
|
||||||
|
"tools_exec.load_tools_unavailable": {
|
||||||
|
"zh-CN": "以下工具当前不可用(可能已被禁用):{names}",
|
||||||
|
"en-US": "These tools are currently unavailable (possibly disabled): {names}",
|
||||||
|
},
|
||||||
|
"tools_exec.load_tools_loaded": {
|
||||||
|
"zh-CN": "已加载 {n} 个工具:{names}。完整定义见下方,之后可直接调用,无需重复加载。",
|
||||||
|
"en-US": "Loaded {n} tool(s): {names}. Full definitions below; you can now call them directly.",
|
||||||
|
},
|
||||||
|
|
||||||
# ── 个性化管理 ──
|
# ── 个性化管理 ──
|
||||||
"tools_exec.pref_enabled_label": {
|
"tools_exec.pref_enabled_label": {
|
||||||
"zh-CN": "个性化功能总开关",
|
"zh-CN": "个性化功能总开关",
|
||||||
|
|||||||
@ -94,6 +94,10 @@ DEFAULT_PERSONALIZATION_CONFIG: Dict[str, Any] = {
|
|||||||
"skill_strict_sub_agent_enabled": False, # 强约束:子智能体系列工具需先阅读 sub-agent-guide
|
"skill_strict_sub_agent_enabled": False, # 强约束:子智能体系列工具需先阅读 sub-agent-guide
|
||||||
"skill_strict_run_command_foreground_enabled": False, # 强约束:run_command 前台模式需先阅读 run-command-guide
|
"skill_strict_run_command_foreground_enabled": False, # 强约束:run_command 前台模式需先阅读 run-command-guide
|
||||||
"skill_strict_run_command_background_enabled": False, # 强约束:run_command 后台模式需先阅读 run-command-guide
|
"skill_strict_run_command_background_enabled": False, # 强约束:run_command 后台模式需先阅读 run-command-guide
|
||||||
|
# 工具动态加载:总开关(默认开启)+ 默认延迟工具集(None=注册表全集)。
|
||||||
|
# 仅在创建对话时快照一次;已有对话以其对话文件中的快照为准,改这里不影响。
|
||||||
|
"tool_loading_enabled": True,
|
||||||
|
"tool_loading_deferred": None,
|
||||||
"default_model": None,
|
"default_model": None,
|
||||||
"image_compression": "original", # original / 1080p / 720p / 540p
|
"image_compression": "original", # original / 1080p / 720p / 540p
|
||||||
"auto_shallow_compress_enabled": False,
|
"auto_shallow_compress_enabled": False,
|
||||||
@ -311,6 +315,16 @@ def sanitize_personalization_payload(
|
|||||||
else:
|
else:
|
||||||
base["tool_intent_enabled"] = bool(base.get("tool_intent_enabled"))
|
base["tool_intent_enabled"] = bool(base.get("tool_intent_enabled"))
|
||||||
|
|
||||||
|
# 工具动态加载(总开关 + 默认延迟集)
|
||||||
|
if "tool_loading_enabled" in data:
|
||||||
|
base["tool_loading_enabled"] = bool(data.get("tool_loading_enabled"))
|
||||||
|
else:
|
||||||
|
base["tool_loading_enabled"] = bool(base.get("tool_loading_enabled", True))
|
||||||
|
if "tool_loading_deferred" in data:
|
||||||
|
base["tool_loading_deferred"] = _sanitize_tool_loading_deferred(data.get("tool_loading_deferred"))
|
||||||
|
else:
|
||||||
|
base["tool_loading_deferred"] = _sanitize_tool_loading_deferred(base.get("tool_loading_deferred"))
|
||||||
|
|
||||||
# Skill 提示系统开关
|
# Skill 提示系统开关
|
||||||
if "skill_hints_enabled" in data:
|
if "skill_hints_enabled" in data:
|
||||||
base["skill_hints_enabled"] = bool(data.get("skill_hints_enabled"))
|
base["skill_hints_enabled"] = bool(data.get("skill_hints_enabled"))
|
||||||
@ -1061,6 +1075,22 @@ def _sanitize_considerations(value: Any) -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_tool_loading_deferred(value: Any) -> list:
|
||||||
|
"""规范化默认延迟工具集:仅保留注册表内工具名,按注册表顺序去重。
|
||||||
|
|
||||||
|
None/非列表 => 注册表全集(默认全延迟);显式空列表 => 不延迟任何工具。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from core.tool_loading import deferrable_tool_names
|
||||||
|
allowed = deferrable_tool_names()
|
||||||
|
except Exception:
|
||||||
|
allowed = []
|
||||||
|
if not isinstance(value, list):
|
||||||
|
return list(allowed)
|
||||||
|
requested = {item for item in value if isinstance(item, str)}
|
||||||
|
return [name for name in allowed if name in requested]
|
||||||
|
|
||||||
|
|
||||||
def _sanitize_tool_categories(value: Any, allowed: set) -> list:
|
def _sanitize_tool_categories(value: Any, allowed: set) -> list:
|
||||||
if not isinstance(value, list):
|
if not isinstance(value, list):
|
||||||
return []
|
return []
|
||||||
|
|||||||
9
prompts/tool_loading.txt
Normal file
9
prompts/tool_loading.txt
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
## 按需加载的工具
|
||||||
|
|
||||||
|
本对话启用了工具动态加载:下列工具默认未加载。需要使用某类能力时,先调用 load_tools(tool_names 参数,一次可传多个工具名)获取完整工具定义,之后即可像普通工具一样直接调用。已加载的工具在本对话中持续可用,无需重复加载;未加载就直接调用会被拒绝并返回引导提示。
|
||||||
|
|
||||||
|
{tool_catalog}
|
||||||
|
|
||||||
|
使用建议:
|
||||||
|
- 用户意图明确属于某个类目时,先一次性加载该类目所需的全部工具再行动,避免边做边停
|
||||||
|
- load_tools 只接受上方列出的工具名;上方未列出的工具均已默认加载,无需处理
|
||||||
@ -34,6 +34,7 @@ from modules.upload_security import UploadSecurityError
|
|||||||
from modules.host_sandbox_policy import load_policy, save_policy
|
from modules.host_sandbox_policy import load_policy, save_policy
|
||||||
from modules.user_manager import UserWorkspace
|
from modules.user_manager import UserWorkspace
|
||||||
from core.web_terminal import WebTerminal
|
from core.web_terminal import WebTerminal
|
||||||
|
from core.tool_loading import build_registry_payload
|
||||||
from config.model_profiles import get_model_context_window
|
from config.model_profiles import get_model_context_window
|
||||||
|
|
||||||
from server.auth_helpers import api_login_required, resolve_admin_policy, get_current_user_record, get_current_username
|
from server.auth_helpers import api_login_required, resolve_admin_policy, get_current_user_record, get_current_username
|
||||||
@ -305,6 +306,7 @@ def get_personalization_settings(terminal: WebTerminal, workspace: UserWorkspace
|
|||||||
"data": data_out,
|
"data": data_out,
|
||||||
"tool_categories": terminal.get_tool_settings_snapshot(),
|
"tool_categories": terminal.get_tool_settings_snapshot(),
|
||||||
"skills_catalog": skills_catalog,
|
"skills_catalog": skills_catalog,
|
||||||
|
"tool_loading_registry": build_registry_payload(),
|
||||||
"context_compression_settings": {
|
"context_compression_settings": {
|
||||||
**compression_settings,
|
**compression_settings,
|
||||||
"context_window_tokens": min(
|
"context_window_tokens": min(
|
||||||
|
|||||||
@ -734,6 +734,12 @@ def create_conversation(terminal: WebTerminal, workspace: UserWorkspace, usernam
|
|||||||
default_effort = None
|
default_effort = None
|
||||||
else:
|
else:
|
||||||
default_effort = None
|
default_effort = None
|
||||||
|
# 工具动态加载快照(多智能体对话不启用;老对话无字段=不启用)
|
||||||
|
try:
|
||||||
|
from core.tool_loading import snapshot_overrides_from_prefs
|
||||||
|
_tl_overrides = snapshot_overrides_from_prefs(prefs, multi_agent_mode=bool(multi_agent_mode))
|
||||||
|
except Exception:
|
||||||
|
_tl_overrides = {}
|
||||||
conversation_id = cm.create_conversation(
|
conversation_id = cm.create_conversation(
|
||||||
project_path=str(workspace.project_path),
|
project_path=str(workspace.project_path),
|
||||||
thinking_mode=safe_thinking,
|
thinking_mode=safe_thinking,
|
||||||
@ -747,6 +753,7 @@ def create_conversation(terminal: WebTerminal, workspace: UserWorkspace, usernam
|
|||||||
"pre_plan_permission_mode": safe_pre_plan_permission,
|
"pre_plan_permission_mode": safe_pre_plan_permission,
|
||||||
"multi_agent_mode": bool(multi_agent_mode),
|
"multi_agent_mode": bool(multi_agent_mode),
|
||||||
"reasoning_effort": default_effort,
|
"reasoning_effort": default_effort,
|
||||||
|
**_tl_overrides,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -636,6 +636,7 @@ async def run_deep_compression(
|
|||||||
"frozen_workspace_prompt",
|
"frozen_workspace_prompt",
|
||||||
"frozen_agents_md_prompt",
|
"frozen_agents_md_prompt",
|
||||||
"frozen_skills_prompt",
|
"frozen_skills_prompt",
|
||||||
|
"frozen_tool_loading_prompt",
|
||||||
"frozen_memory_prompt",
|
"frozen_memory_prompt",
|
||||||
"frozen_custom_system_prompt",
|
"frozen_custom_system_prompt",
|
||||||
"frozen_disabled_tools_prompt",
|
"frozen_disabled_tools_prompt",
|
||||||
@ -657,6 +658,19 @@ async def run_deep_compression(
|
|||||||
}
|
}
|
||||||
for frozen_key in REBUILD_FROZEN_KEYS:
|
for frozen_key in REBUILD_FROZEN_KEYS:
|
||||||
meta_updates[frozen_key] = None
|
meta_updates[frozen_key] = None
|
||||||
|
# 工具动态加载:压缩丢弃了历史中的 load_tools 定义(tool result 被标记重写),
|
||||||
|
# loaded 状态必须随之前滚回滚,否则守门会放行「上下文里已无定义」的工具调用。
|
||||||
|
try:
|
||||||
|
from core.tool_loading import (
|
||||||
|
METADATA_KEY as _TL_META_KEY,
|
||||||
|
get_tool_loading_state as _tl_get_state,
|
||||||
|
reset_state_after_compression as _tl_reset,
|
||||||
|
)
|
||||||
|
_tl_state = _tl_get_state(metadata)
|
||||||
|
if _tl_state:
|
||||||
|
meta_updates[_TL_META_KEY] = _tl_reset(_tl_state)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
target_manager.update_conversation_metadata(conversation_id, meta_updates)
|
target_manager.update_conversation_metadata(conversation_id, meta_updates)
|
||||||
# 同步内存中的 metadata,清除 frozen 缓存
|
# 同步内存中的 metadata,清除 frozen 缓存
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -81,11 +81,22 @@ class RuntimeService:
|
|||||||
if thinking_mode is None:
|
if thinking_mode is None:
|
||||||
thinking_mode = p.preferred_thinking_mode
|
thinking_mode = p.preferred_thinking_mode
|
||||||
thinking_mode = bool(thinking_mode) if thinking_mode is not None else (run_mode != "fast")
|
thinking_mode = bool(thinking_mode) if thinking_mode is not None else (run_mode != "fast")
|
||||||
|
# 工具动态加载快照(老对话无字段=不启用)
|
||||||
|
_tl_overrides: Dict[str, Any] = {}
|
||||||
|
try:
|
||||||
|
from core.tool_loading import snapshot_overrides_from_prefs
|
||||||
|
from modules.personalization_manager import load_personalization_config as _load_tl_prefs
|
||||||
|
_tl_overrides = snapshot_overrides_from_prefs(
|
||||||
|
_load_tl_prefs(getattr(workspace, "data_dir", None))
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
_tl_overrides = {}
|
||||||
conversation_id = cm.create_conversation(
|
conversation_id = cm.create_conversation(
|
||||||
project_path=str(getattr(workspace, "project_path", "") or "."),
|
project_path=str(getattr(workspace, "project_path", "") or "."),
|
||||||
run_mode=run_mode,
|
run_mode=run_mode,
|
||||||
thinking_mode=thinking_mode,
|
thinking_mode=thinking_mode,
|
||||||
model_key=params.model_key or p.preferred_model_key,
|
model_key=params.model_key or p.preferred_model_key,
|
||||||
|
metadata_overrides=_tl_overrides or None,
|
||||||
)
|
)
|
||||||
debug_log(f"[RuntimeService] 未携带 conversation_id,已补建对话: {conversation_id}")
|
debug_log(f"[RuntimeService] 未携带 conversation_id,已补建对话: {conversation_id}")
|
||||||
return conversation_id
|
return conversation_id
|
||||||
@ -260,12 +271,23 @@ class RuntimeService:
|
|||||||
resolved_thinking = (
|
resolved_thinking = (
|
||||||
bool(resolved_thinking) if resolved_thinking is not None else (resolved_run_mode != "fast")
|
bool(resolved_thinking) if resolved_thinking is not None else (resolved_run_mode != "fast")
|
||||||
)
|
)
|
||||||
|
# 工具动态加载快照(多智能体对话 v1 不启用;老对话无字段=不启用)
|
||||||
|
_meta_overrides: Dict[str, Any] = {"multi_agent_mode": True} if multi_agent_mode else {}
|
||||||
|
try:
|
||||||
|
from core.tool_loading import snapshot_overrides_from_prefs
|
||||||
|
from modules.personalization_manager import load_personalization_config as _load_tl_prefs
|
||||||
|
_meta_overrides.update(snapshot_overrides_from_prefs(
|
||||||
|
_load_tl_prefs(getattr(workspace, "data_dir", None)),
|
||||||
|
multi_agent_mode=multi_agent_mode,
|
||||||
|
))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
conversation_id = cm.create_conversation(
|
conversation_id = cm.create_conversation(
|
||||||
project_path=str(getattr(workspace, "project_path", "") or "."),
|
project_path=str(getattr(workspace, "project_path", "") or "."),
|
||||||
run_mode=resolved_run_mode,
|
run_mode=resolved_run_mode,
|
||||||
thinking_mode=resolved_thinking,
|
thinking_mode=resolved_thinking,
|
||||||
model_key=model_key or (principal.preferred_model_key if principal else None),
|
model_key=model_key or (principal.preferred_model_key if principal else None),
|
||||||
metadata_overrides={"multi_agent_mode": True} if multi_agent_mode else None,
|
metadata_overrides=_meta_overrides or None,
|
||||||
)
|
)
|
||||||
return {"conversation_id": conversation_id}
|
return {"conversation_id": conversation_id}
|
||||||
|
|
||||||
|
|||||||
@ -96,6 +96,12 @@ def api_activate_workflow(terminal, workspace, username):
|
|||||||
"pre_plan_permission_mode": _pre_plan_permission,
|
"pre_plan_permission_mode": _pre_plan_permission,
|
||||||
"reasoning_effort": _reasoning_effort,
|
"reasoning_effort": _reasoning_effort,
|
||||||
}
|
}
|
||||||
|
# 工具动态加载快照(老对话无字段=不启用)
|
||||||
|
try:
|
||||||
|
from core.tool_loading import snapshot_overrides_from_prefs
|
||||||
|
_meta_overrides.update(snapshot_overrides_from_prefs(_prefs))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
try:
|
try:
|
||||||
conversation_id = cm.create_conversation(
|
conversation_id = cm.create_conversation(
|
||||||
project_path=str(getattr(workspace, "project_path", "") or "."),
|
project_path=str(getattr(workspace, "project_path", "") or "."),
|
||||||
|
|||||||
@ -682,7 +682,6 @@ const TOOL_CATEGORY_MAP: Record<string, ToolCategory> = {
|
|||||||
|
|
||||||
// 子智能体:创建 / 管理
|
// 子智能体:创建 / 管理
|
||||||
create_sub_agent: 'sub_agent',
|
create_sub_agent: 'sub_agent',
|
||||||
close_sub_agent: 'sub_agent_manage',
|
|
||||||
terminate_sub_agent: 'sub_agent_manage',
|
terminate_sub_agent: 'sub_agent_manage',
|
||||||
get_sub_agent_status: 'sub_agent_manage',
|
get_sub_agent_status: 'sub_agent_manage',
|
||||||
|
|
||||||
|
|||||||
@ -189,6 +189,11 @@ export function renderEnhancedToolResult(
|
|||||||
return renderGetSubAgentStatus(result);
|
return renderGetSubAgentStatus(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 工具动态加载
|
||||||
|
else if (name === 'load_tools') {
|
||||||
|
return renderLoadTools(result, args);
|
||||||
|
}
|
||||||
|
|
||||||
// 默认回退:显示重要参数 + 结果摘要,不再直接返回空字符串
|
// 默认回退:显示重要参数 + 结果摘要,不再直接返回空字符串
|
||||||
return renderDefaultResult(result, args, name);
|
return renderDefaultResult(result, args, name);
|
||||||
}
|
}
|
||||||
@ -1487,6 +1492,48 @@ function renderEasterEgg(result: any, args: any): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 子智能体类渲染函数
|
// 子智能体类渲染函数
|
||||||
|
function renderLoadTools(result: any, args: any): string {
|
||||||
|
const status = formatToolStatusLabel(result, t('toolResults.status.completed'), t('toolResults.status.failedMark'));
|
||||||
|
const requested: string[] = Array.isArray(args?.tool_names) ? args.tool_names : [];
|
||||||
|
const loadedNow: string[] = Array.isArray(result?.loaded_now) ? result.loaded_now : [];
|
||||||
|
const already: string[] = Array.isArray(result?.already_loaded) ? result.already_loaded : [];
|
||||||
|
const unavailable: string[] = Array.isArray(result?.unavailable) ? result.unavailable : [];
|
||||||
|
const toolDefs: any[] = Array.isArray(result?.tools) ? result.tools : [];
|
||||||
|
|
||||||
|
// meta 只放调用参数与状态(§5.6:meta=参数,content=结果)
|
||||||
|
let html = '<div class="tool-result-meta">';
|
||||||
|
html += `<div><strong>${escapeHtml(t('toolResults.labels.status'))}</strong>${status}</div>`;
|
||||||
|
if (requested.length) {
|
||||||
|
html += `<div><strong>${escapeHtml(t('toolResults.labels.requestedTools'))}</strong>${escapeHtml(requested.join(', '))}</div>`;
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
if (result?.success && toolDefs.length) {
|
||||||
|
html += '<div class="tool-result-content scrollable">';
|
||||||
|
if (loadedNow.length) {
|
||||||
|
html += `<div><strong>${escapeHtml(t('toolResults.labels.loadedNow'))}</strong>${escapeHtml(loadedNow.join(', '))}</div>`;
|
||||||
|
}
|
||||||
|
if (already.length) {
|
||||||
|
html += `<div><strong>${escapeHtml(t('toolResults.labels.alreadyLoaded'))}</strong>${escapeHtml(already.join(', '))}</div>`;
|
||||||
|
}
|
||||||
|
if (unavailable.length) {
|
||||||
|
html += `<div><strong>${escapeHtml(t('toolResults.labels.unavailableTools'))}</strong>${escapeHtml(unavailable.join(', '))}</div>`;
|
||||||
|
}
|
||||||
|
for (const def of toolDefs) {
|
||||||
|
const fn = def?.function || {};
|
||||||
|
const toolName = String(fn.name || '');
|
||||||
|
// 只展示首行描述,完整 JSON 定义留给模型上下文,不上界面
|
||||||
|
const desc = String(fn.description || '').split('\n')[0];
|
||||||
|
html += `<div><strong>${escapeHtml(toolName)}</strong>${desc ? ` — ${escapeHtml(desc)}` : ''}</div>`;
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
} else if (!result?.success && result?.error) {
|
||||||
|
html += `<div class="tool-result-content scrollable"><div>${escapeHtml(String(result.error))}</div></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
function renderCreateSubAgent(result: any, args: any): string {
|
function renderCreateSubAgent(result: any, args: any): string {
|
||||||
const status = formatToolStatusLabel(result, t('toolResults.status.created'), t('toolResults.status.createFailed'));
|
const status = formatToolStatusLabel(result, t('toolResults.status.created'), t('toolResults.status.createFailed'));
|
||||||
const agentId = result.agent_id ?? args.agent_id ?? '';
|
const agentId = result.agent_id ?? args.agent_id ?? '';
|
||||||
|
|||||||
@ -198,6 +198,7 @@ const {
|
|||||||
saving,
|
saving,
|
||||||
toggleUpdating,
|
toggleUpdating,
|
||||||
toolCategories,
|
toolCategories,
|
||||||
|
toolLoadingRegistry,
|
||||||
skillsCatalog,
|
skillsCatalog,
|
||||||
recentConversationsPromptLimitRange,
|
recentConversationsPromptLimitRange,
|
||||||
projectMemoryInjectLimitMin,
|
projectMemoryInjectLimitMin,
|
||||||
@ -1489,7 +1490,7 @@ const drawerContext = {
|
|||||||
showMcpConfigEntry, skillsCatalog, stackedHideBorders, startTutorial, subAgentCompressThreshold,
|
showMcpConfigEntry, skillsCatalog, stackedHideBorders, startTutorial, subAgentCompressThreshold,
|
||||||
subAgentMaxTurns, subAgentModels, subAgentRoles, subAgentRolesLoading, subAgentSettingsSaving,
|
subAgentMaxTurns, subAgentModels, subAgentRoles, subAgentRolesLoading, subAgentSettingsSaving,
|
||||||
themeLabel, themeOptions, toggleCategory, toggleDropdown, toggleGoalTokenLimit, toggleUpdating,
|
themeLabel, themeOptions, toggleCategory, toggleDropdown, toggleGoalTokenLimit, toggleUpdating,
|
||||||
tonePresets, toolCategories, updateReviewAgent, updateReviewAgentInt, usageError, usageLoading,
|
tonePresets, toolCategories, toolLoadingRegistry, updateReviewAgent, updateReviewAgentInt, usageError, usageLoading,
|
||||||
usageSummary, usageUpdatedText, versioningBackupModeLabel, voiceDownloadMsg, voiceDownloadPercent,
|
usageSummary, usageUpdatedText, versioningBackupModeLabel, voiceDownloadMsg, voiceDownloadPercent,
|
||||||
voiceDownloading, voiceModelPartial, voiceModelReady, workModeLabel,
|
voiceDownloading, voiceModelPartial, voiceModelReady, workModeLabel,
|
||||||
runModeOptions, reasoningEffortOptions, permissionModeOptions, workModeOptions,
|
runModeOptions, reasoningEffortOptions, permissionModeOptions, workModeOptions,
|
||||||
|
|||||||
@ -14,8 +14,44 @@ const {
|
|||||||
personalization,
|
personalization,
|
||||||
skillsCatalog,
|
skillsCatalog,
|
||||||
toggleCategory,
|
toggleCategory,
|
||||||
toolCategories
|
toolCategories,
|
||||||
|
toolLoadingRegistry
|
||||||
} = ctx;
|
} = ctx;
|
||||||
|
|
||||||
|
/** 类目内工具是否全部被勾选为延迟加载 */
|
||||||
|
const isCategoryFullyDeferred = (cat: { tools: string[] }) =>
|
||||||
|
Array.isArray(cat.tools) &&
|
||||||
|
cat.tools.length > 0 &&
|
||||||
|
cat.tools.every((name: string) => form.tool_loading_deferred.includes(name));
|
||||||
|
|
||||||
|
/** 勾选/取消单个延迟工具 */
|
||||||
|
const toggleDeferredTool = (name: string, checked: boolean) => {
|
||||||
|
const current = Array.isArray(form.tool_loading_deferred)
|
||||||
|
? [...form.tool_loading_deferred]
|
||||||
|
: [];
|
||||||
|
const idx = current.indexOf(name);
|
||||||
|
if (checked && idx === -1) {
|
||||||
|
current.push(name);
|
||||||
|
} else if (!checked && idx !== -1) {
|
||||||
|
current.splice(idx, 1);
|
||||||
|
}
|
||||||
|
personalization.updateField({ key: 'tool_loading_deferred', value: current });
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 勾选/取消整个类目(幂等,不动其他类目) */
|
||||||
|
const toggleDeferredCategory = (cat: { tools: string[] }, checked: boolean) => {
|
||||||
|
const current = new Set<string>(
|
||||||
|
Array.isArray(form.tool_loading_deferred) ? form.tool_loading_deferred : []
|
||||||
|
);
|
||||||
|
for (const name of cat.tools || []) {
|
||||||
|
if (checked) {
|
||||||
|
current.add(name);
|
||||||
|
} else {
|
||||||
|
current.delete(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
personalization.updateField({ key: 'tool_loading_deferred', value: Array.from(current) });
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -166,5 +202,43 @@ const {
|
|||||||
@change="toggleCategory(category.id)" /><FancyCheck :checked="form.disabled_tool_categories.includes(category.id)" /></label>
|
@change="toggleCategory(category.id)" /><FancyCheck :checked="form.disabled_tool_categories.includes(category.id)" /></label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="settings-group-block" v-if="toolLoadingRegistry.length">
|
||||||
|
<div class="settings-group-title">
|
||||||
|
<span class="settings-row-title">{{ $t('personalization.toolLoadingTitle') }}</span
|
||||||
|
><span class="settings-row-desc">{{ $t('personalization.toolLoadingDesc') }}</span>
|
||||||
|
</div>
|
||||||
|
<label class="settings-toggle-row inner"
|
||||||
|
><span class="settings-row-title">{{ $t('personalization.toolLoadingEnabledTitle') }}</span
|
||||||
|
><input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="form.tool_loading_enabled"
|
||||||
|
@change="
|
||||||
|
personalization.updateField({
|
||||||
|
key: 'tool_loading_enabled',
|
||||||
|
value: $event.target.checked
|
||||||
|
})
|
||||||
|
" /><FancyCheck :checked="form.tool_loading_enabled" /></label>
|
||||||
|
<template v-if="form.tool_loading_enabled">
|
||||||
|
<div v-for="cat in toolLoadingRegistry" :key="cat.key">
|
||||||
|
<label class="settings-toggle-row inner"
|
||||||
|
><span class="settings-row-title">{{ $t(`personalization.toolLoadingCat.${cat.key}`) }}</span
|
||||||
|
><input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="isCategoryFullyDeferred(cat)"
|
||||||
|
@change="toggleDeferredCategory(cat, $event.target.checked)" /><FancyCheck :checked="isCategoryFullyDeferred(cat)" /></label>
|
||||||
|
<div class="settings-check-grid">
|
||||||
|
<label
|
||||||
|
v-for="name in cat.tools"
|
||||||
|
:key="name"
|
||||||
|
class="settings-toggle-row inner"
|
||||||
|
><span class="settings-row-title">{{ name }}</span
|
||||||
|
><input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="form.tool_loading_deferred.includes(name)"
|
||||||
|
@change="toggleDeferredTool(name, $event.target.checked)" /><FancyCheck :checked="form.tool_loading_deferred.includes(name)" /></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -240,6 +240,19 @@ export default {
|
|||||||
availableSkillsDesc: 'Checked skills are injected into the system prompt and synced to .astrion/skills/ in the workspace',
|
availableSkillsDesc: 'Checked skills are injected into the system prompt and synced to .astrion/skills/ in the workspace',
|
||||||
disabledToolCategoriesTitle: 'Default disabled tool categories',
|
disabledToolCategoriesTitle: 'Default disabled tool categories',
|
||||||
disabledToolCategoriesDesc: 'These categories stay off in new tasks once selected',
|
disabledToolCategoriesDesc: 'These categories stay off in new tasks once selected',
|
||||||
|
toolLoadingTitle: 'On-demand tools (dynamic loading)',
|
||||||
|
toolLoadingDesc: 'Checked tools are not injected into context by default; the model fetches their full definitions on demand via load_tools, saving tokens and improving tool selection accuracy. Only affects new conversations; existing conversations keep the snapshot taken at creation',
|
||||||
|
toolLoadingEnabledTitle: 'Enable dynamic tool loading',
|
||||||
|
toolLoadingCat: {
|
||||||
|
workflow: 'Workflows',
|
||||||
|
sub_agent: 'Sub-agents',
|
||||||
|
conversation: 'Conversation review',
|
||||||
|
memory_write: 'Memory write',
|
||||||
|
personalization: 'Personalization',
|
||||||
|
skill_create: 'Skills',
|
||||||
|
mcp: 'MCP',
|
||||||
|
misc: 'Easter egg'
|
||||||
|
},
|
||||||
|
|
||||||
// ── Files & Images ──
|
// ── Files & Images ──
|
||||||
imageCompressionTitle: 'Image compression',
|
imageCompressionTitle: 'Image compression',
|
||||||
|
|||||||
@ -98,6 +98,10 @@ export default {
|
|||||||
userAnswer: 'User answer:',
|
userAnswer: 'User answer:',
|
||||||
subAgent: 'Sub-agent:',
|
subAgent: 'Sub-agent:',
|
||||||
subAgentId: 'Sub-agent ID:',
|
subAgentId: 'Sub-agent ID:',
|
||||||
|
requestedTools: 'Requested:',
|
||||||
|
loadedNow: 'Newly loaded:',
|
||||||
|
alreadyLoaded: 'Already loaded:',
|
||||||
|
unavailableTools: 'Unavailable:',
|
||||||
deliverablesDir: 'Deliverables dir:',
|
deliverablesDir: 'Deliverables dir:',
|
||||||
task: 'Task:',
|
task: 'Task:',
|
||||||
workTime: 'Work time:',
|
workTime: 'Work time:',
|
||||||
|
|||||||
@ -241,6 +241,19 @@ export default {
|
|||||||
availableSkillsDesc: '勾选后会注入 system prompt,并同步到工作区的 .astrion/skills/ 目录',
|
availableSkillsDesc: '勾选后会注入 system prompt,并同步到工作区的 .astrion/skills/ 目录',
|
||||||
disabledToolCategoriesTitle: '默认禁用工具类别',
|
disabledToolCategoriesTitle: '默认禁用工具类别',
|
||||||
disabledToolCategoriesDesc: '选择后,这些类别在新任务中保持关闭',
|
disabledToolCategoriesDesc: '选择后,这些类别在新任务中保持关闭',
|
||||||
|
toolLoadingTitle: '按需加载的工具(动态加载)',
|
||||||
|
toolLoadingDesc: '勾选的工具默认不注入上下文,模型需要时经 load_tools 按需获取完整定义,可省 token 并提高工具选择准确率;仅对新对话生效,已有对话以创建时的快照为准',
|
||||||
|
toolLoadingEnabledTitle: '启用工具动态加载',
|
||||||
|
toolLoadingCat: {
|
||||||
|
workflow: '工作流',
|
||||||
|
sub_agent: '子智能体',
|
||||||
|
conversation: '对话回顾',
|
||||||
|
memory_write: '记忆写入',
|
||||||
|
personalization: '个性化',
|
||||||
|
skill_create: '技能',
|
||||||
|
mcp: 'MCP',
|
||||||
|
misc: '彩蛋'
|
||||||
|
},
|
||||||
|
|
||||||
// ── 文件与图片 ──
|
// ── 文件与图片 ──
|
||||||
imageCompressionTitle: '图片压缩',
|
imageCompressionTitle: '图片压缩',
|
||||||
|
|||||||
@ -105,6 +105,10 @@ export default {
|
|||||||
userAnswer: '用户回答:',
|
userAnswer: '用户回答:',
|
||||||
subAgent: '子智能体:',
|
subAgent: '子智能体:',
|
||||||
subAgentId: '子智能体 ID:',
|
subAgentId: '子智能体 ID:',
|
||||||
|
requestedTools: '请求加载:',
|
||||||
|
loadedNow: '新加载:',
|
||||||
|
alreadyLoaded: '此前已加载:',
|
||||||
|
unavailableTools: '不可用:',
|
||||||
deliverablesDir: '交付目录:',
|
deliverablesDir: '交付目录:',
|
||||||
task: '任务:',
|
task: '任务:',
|
||||||
workTime: '工作时间:',
|
workTime: '工作时间:',
|
||||||
|
|||||||
@ -100,6 +100,9 @@ interface PersonalForm {
|
|||||||
recent_conversations_prompt_limit: number | string;
|
recent_conversations_prompt_limit: number | string;
|
||||||
project_memory_inject_limit: number | string | null;
|
project_memory_inject_limit: number | string | null;
|
||||||
tool_intent_enabled: boolean;
|
tool_intent_enabled: boolean;
|
||||||
|
tool_loading_enabled: boolean;
|
||||||
|
/** 默认延迟加载的工具名列表(仅影响新建对话) */
|
||||||
|
tool_loading_deferred: string[];
|
||||||
skill_hints_enabled: boolean;
|
skill_hints_enabled: boolean;
|
||||||
skill_strict_terminal_enabled: boolean;
|
skill_strict_terminal_enabled: boolean;
|
||||||
skill_strict_sub_agent_enabled: boolean;
|
skill_strict_sub_agent_enabled: boolean;
|
||||||
@ -179,6 +182,7 @@ interface PersonalizationState {
|
|||||||
overlayPressActive: boolean;
|
overlayPressActive: boolean;
|
||||||
form: PersonalForm;
|
form: PersonalForm;
|
||||||
toolCategories: Array<{ id: string; label: string }>;
|
toolCategories: Array<{ id: string; label: string }>;
|
||||||
|
toolLoadingRegistry: Array<{ key: string; tools: string[] }>;
|
||||||
skillsCatalog: Array<{ id: string; label: string; description?: string }>;
|
skillsCatalog: Array<{ id: string; label: string; description?: string }>;
|
||||||
recentConversationsPromptLimitRange: { min: number; max: number };
|
recentConversationsPromptLimitRange: { min: number; max: number };
|
||||||
projectMemoryInjectLimitMin: number;
|
projectMemoryInjectLimitMin: number;
|
||||||
@ -308,6 +312,8 @@ const defaultForm = (): PersonalForm => ({
|
|||||||
recent_conversations_prompt_limit: DEFAULT_RECENT_CONVERSATIONS_PROMPT_LIMIT,
|
recent_conversations_prompt_limit: DEFAULT_RECENT_CONVERSATIONS_PROMPT_LIMIT,
|
||||||
project_memory_inject_limit: DEFAULT_PROJECT_MEMORY_INJECT_LIMIT,
|
project_memory_inject_limit: DEFAULT_PROJECT_MEMORY_INJECT_LIMIT,
|
||||||
tool_intent_enabled: true,
|
tool_intent_enabled: true,
|
||||||
|
tool_loading_enabled: true,
|
||||||
|
tool_loading_deferred: [],
|
||||||
skill_hints_enabled: false,
|
skill_hints_enabled: false,
|
||||||
skill_strict_terminal_enabled: false,
|
skill_strict_terminal_enabled: false,
|
||||||
skill_strict_sub_agent_enabled: false,
|
skill_strict_sub_agent_enabled: false,
|
||||||
@ -433,6 +439,7 @@ export const usePersonalizationStore = defineStore('personalization', {
|
|||||||
overlayPressActive: false,
|
overlayPressActive: false,
|
||||||
form: defaultForm(),
|
form: defaultForm(),
|
||||||
toolCategories: [],
|
toolCategories: [],
|
||||||
|
toolLoadingRegistry: [],
|
||||||
skillsCatalog: [],
|
skillsCatalog: [],
|
||||||
recentConversationsPromptLimitRange: { ...DEFAULT_RECENT_CONVERSATIONS_PROMPT_LIMIT_RANGE },
|
recentConversationsPromptLimitRange: { ...DEFAULT_RECENT_CONVERSATIONS_PROMPT_LIMIT_RANGE },
|
||||||
projectMemoryInjectLimitMin: PROJECT_MEMORY_INJECT_LIMIT_MIN,
|
projectMemoryInjectLimitMin: PROJECT_MEMORY_INJECT_LIMIT_MIN,
|
||||||
@ -529,6 +536,10 @@ export const usePersonalizationStore = defineStore('personalization', {
|
|||||||
data.project_memory_inject_limit
|
data.project_memory_inject_limit
|
||||||
),
|
),
|
||||||
tool_intent_enabled: !!data.tool_intent_enabled,
|
tool_intent_enabled: !!data.tool_intent_enabled,
|
||||||
|
tool_loading_enabled: data.tool_loading_enabled !== false,
|
||||||
|
tool_loading_deferred: Array.isArray(data.tool_loading_deferred)
|
||||||
|
? data.tool_loading_deferred.filter((item: any) => typeof item === 'string')
|
||||||
|
: [],
|
||||||
skill_hints_enabled: !!data.skill_hints_enabled,
|
skill_hints_enabled: !!data.skill_hints_enabled,
|
||||||
skill_strict_terminal_enabled: !!data.skill_strict_terminal_enabled,
|
skill_strict_terminal_enabled: !!data.skill_strict_terminal_enabled,
|
||||||
skill_strict_sub_agent_enabled: !!data.skill_strict_sub_agent_enabled,
|
skill_strict_sub_agent_enabled: !!data.skill_strict_sub_agent_enabled,
|
||||||
@ -780,6 +791,18 @@ export const usePersonalizationStore = defineStore('personalization', {
|
|||||||
} else {
|
} else {
|
||||||
this.toolCategories = [];
|
this.toolCategories = [];
|
||||||
}
|
}
|
||||||
|
if (payload && Array.isArray(payload.tool_loading_registry)) {
|
||||||
|
this.toolLoadingRegistry = payload.tool_loading_registry
|
||||||
|
.map((item: { key?: string; tools?: any } = {}) => ({
|
||||||
|
key: typeof item.key === 'string' ? item.key : String(item.key ?? ''),
|
||||||
|
tools: Array.isArray(item.tools)
|
||||||
|
? item.tools.filter((n: any) => typeof n === 'string')
|
||||||
|
: []
|
||||||
|
}))
|
||||||
|
.filter((item: { key: string }) => !!item.key);
|
||||||
|
} else {
|
||||||
|
this.toolLoadingRegistry = [];
|
||||||
|
}
|
||||||
if (payload && Array.isArray(payload.skills_catalog)) {
|
if (payload && Array.isArray(payload.skills_catalog)) {
|
||||||
this.skillsCatalog = payload.skills_catalog
|
this.skillsCatalog = payload.skills_catalog
|
||||||
.map((item: { id?: string; label?: string; description?: string } = {}) => ({
|
.map((item: { id?: string; label?: string; description?: string } = {}) => ({
|
||||||
|
|||||||
@ -28,7 +28,7 @@ const TOOL_FACE_MAP: Record<string, string> = {
|
|||||||
todo_create: 'note',
|
todo_create: 'note',
|
||||||
todo_update_task: 'check',
|
todo_update_task: 'check',
|
||||||
create_sub_agent: 'subagent',
|
create_sub_agent: 'subagent',
|
||||||
close_sub_agent: 'subagent',
|
load_tools: 'search',
|
||||||
terminate_sub_agent: 'subagent',
|
terminate_sub_agent: 'subagent',
|
||||||
get_sub_agent_status: 'subagent',
|
get_sub_agent_status: 'subagent',
|
||||||
manage_personalization: 'persona',
|
manage_personalization: 'persona',
|
||||||
|
|||||||
@ -63,7 +63,7 @@ export const ICONS = Object.freeze({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const TOOL_ICON_MAP = Object.freeze({
|
export const TOOL_ICON_MAP = Object.freeze({
|
||||||
close_sub_agent: 'bot',
|
load_tools: 'wrench',
|
||||||
create_file: 'file',
|
create_file: 'file',
|
||||||
create_skill: 'sparkles',
|
create_skill: 'sparkles',
|
||||||
manage_personalization: 'userPen',
|
manage_personalization: 'userPen',
|
||||||
|
|||||||
167
test/test_tool_loading.py
Normal file
167
test/test_tool_loading.py
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
"""工具动态加载(core.tool_loading + personalization 字段)单元测试。
|
||||||
|
|
||||||
|
设计文档:docs/dynamic_tool_loading_plan.md
|
||||||
|
运行:.venv/bin/python -m pytest test/test_tool_loading.py -q
|
||||||
|
无 pytest 时:
|
||||||
|
.venv/bin/python -c "import sys; sys.path.insert(0, '.'); import unittest; \\
|
||||||
|
s = unittest.TestLoader().discover('test', pattern='test_tool_loading.py'); \\
|
||||||
|
r = unittest.TextTestRunner().run(s); sys.exit(0 if r.wasSuccessful() else 1)"
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from core import tool_loading as tl # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class RegistryIntegrityTest(unittest.TestCase):
|
||||||
|
def test_registry_has_expected_shape(self):
|
||||||
|
names = tl.deferrable_tool_names()
|
||||||
|
self.assertEqual(len(names), 18)
|
||||||
|
self.assertEqual(len(set(names)), len(names), "注册表内工具名重复")
|
||||||
|
for cat in tl.DEFERRABLE_REGISTRY.values():
|
||||||
|
self.assertTrue(cat.get("label"))
|
||||||
|
self.assertTrue(cat.get("when_to_use"))
|
||||||
|
self.assertTrue(cat.get("tools"))
|
||||||
|
|
||||||
|
def test_every_name_has_category(self):
|
||||||
|
for name in tl.deferrable_tool_names():
|
||||||
|
self.assertIsNotNone(tl.category_of_tool(name), name)
|
||||||
|
self.assertIsNone(tl.category_of_tool("read_file"))
|
||||||
|
self.assertIsNone(tl.category_of_tool("no_such_tool"))
|
||||||
|
|
||||||
|
def test_registry_payload(self):
|
||||||
|
payload = tl.build_registry_payload()
|
||||||
|
self.assertEqual(len(payload), len(tl.DEFERRABLE_REGISTRY))
|
||||||
|
total = sum(len(item["tools"]) for item in payload)
|
||||||
|
self.assertEqual(total, 18)
|
||||||
|
|
||||||
|
|
||||||
|
class SnapshotTest(unittest.TestCase):
|
||||||
|
def test_default_defers_everything(self):
|
||||||
|
snap = tl.build_snapshot(None)
|
||||||
|
self.assertTrue(snap["enabled"])
|
||||||
|
self.assertEqual(snap["deferred_set"], tl.deferrable_tool_names())
|
||||||
|
self.assertEqual(snap["loaded"], [])
|
||||||
|
self.assertEqual(snap["pending"], snap["deferred_set"])
|
||||||
|
|
||||||
|
def test_partial_and_invalid_names_clamped(self):
|
||||||
|
snap = tl.build_snapshot(["activate_workflow", "bogus_tool", "save_workflow"])
|
||||||
|
self.assertEqual(snap["deferred_set"], ["activate_workflow", "save_workflow"])
|
||||||
|
|
||||||
|
def test_explicit_empty_defers_nothing(self):
|
||||||
|
snap = tl.build_snapshot([])
|
||||||
|
self.assertEqual(snap["deferred_set"], [])
|
||||||
|
self.assertEqual(snap["pending"], [])
|
||||||
|
|
||||||
|
def test_initial_exposed_recorded_when_built_names_given(self):
|
||||||
|
snap = tl.build_snapshot(
|
||||||
|
["activate_workflow"],
|
||||||
|
built_tool_names=["read_file", "activate_workflow", "web_search"],
|
||||||
|
)
|
||||||
|
self.assertNotIn("activate_workflow", snap["initial_exposed"])
|
||||||
|
self.assertIn("read_file", snap["initial_exposed"])
|
||||||
|
self.assertIn(tl.LOAD_TOOLS_NAME, snap["initial_exposed"])
|
||||||
|
|
||||||
|
def test_overrides_from_prefs(self):
|
||||||
|
self.assertEqual(tl.snapshot_overrides_from_prefs({}, multi_agent_mode=True), {})
|
||||||
|
self.assertEqual(tl.snapshot_overrides_from_prefs({"tool_loading_enabled": False}), {})
|
||||||
|
overrides = tl.snapshot_overrides_from_prefs({})
|
||||||
|
self.assertIn(tl.METADATA_KEY, overrides)
|
||||||
|
self.assertTrue(overrides[tl.METADATA_KEY]["enabled"])
|
||||||
|
|
||||||
|
|
||||||
|
class StateParsingTest(unittest.TestCase):
|
||||||
|
def test_missing_or_disabled_returns_none(self):
|
||||||
|
self.assertIsNone(tl.get_tool_loading_state(None))
|
||||||
|
self.assertIsNone(tl.get_tool_loading_state({}))
|
||||||
|
self.assertIsNone(tl.get_tool_loading_state({"tool_loading": "garbage"}))
|
||||||
|
self.assertIsNone(tl.get_tool_loading_state({"tool_loading": {"enabled": False}}))
|
||||||
|
|
||||||
|
def test_normalization_recomputes_pending_and_drops_ghosts(self):
|
||||||
|
snap = tl.build_snapshot(None)
|
||||||
|
snap["loaded"] = ["activate_workflow", "ghost_tool"]
|
||||||
|
snap["pending"] = ["wrong"]
|
||||||
|
state = tl.get_tool_loading_state({tl.METADATA_KEY: snap})
|
||||||
|
self.assertIsNotNone(state)
|
||||||
|
self.assertEqual(state["loaded"], ["activate_workflow"])
|
||||||
|
self.assertNotIn("activate_workflow", state["pending"])
|
||||||
|
self.assertIn("save_workflow", state["pending"])
|
||||||
|
|
||||||
|
def test_guard_decision(self):
|
||||||
|
state = tl.get_tool_loading_state({tl.METADATA_KEY: tl.build_snapshot(None)})
|
||||||
|
self.assertTrue(tl.is_deferred_not_loaded(state, "save_workflow"))
|
||||||
|
self.assertFalse(tl.is_deferred_not_loaded(state, "read_file"))
|
||||||
|
self.assertFalse(tl.is_deferred_not_loaded(None, "save_workflow"))
|
||||||
|
loaded = tl.mark_tools_loaded(state, ["save_workflow"])
|
||||||
|
self.assertFalse(tl.is_deferred_not_loaded(loaded, "save_workflow"))
|
||||||
|
|
||||||
|
|
||||||
|
class StateTransitionTest(unittest.TestCase):
|
||||||
|
def test_mark_loaded_is_idempotent(self):
|
||||||
|
state = tl.get_tool_loading_state({tl.METADATA_KEY: tl.build_snapshot(None)})
|
||||||
|
once = tl.mark_tools_loaded(state, ["save_workflow"])
|
||||||
|
twice = tl.mark_tools_loaded(once, ["save_workflow", "list_workflows"])
|
||||||
|
self.assertEqual(twice["loaded"].count("save_workflow"), 1)
|
||||||
|
self.assertNotIn("list_workflows", twice["pending"])
|
||||||
|
|
||||||
|
def test_compression_reset(self):
|
||||||
|
state = tl.get_tool_loading_state({tl.METADATA_KEY: tl.build_snapshot(None)})
|
||||||
|
loaded = tl.mark_tools_loaded(state, ["save_workflow", "list_workflows"])
|
||||||
|
reset = tl.reset_state_after_compression(loaded)
|
||||||
|
self.assertEqual(reset["loaded"], [])
|
||||||
|
self.assertEqual(reset["pending"], reset["deferred_set"])
|
||||||
|
self.assertEqual(reset["initial_exposed"], loaded["initial_exposed"])
|
||||||
|
|
||||||
|
|
||||||
|
class CatalogRenderTest(unittest.TestCase):
|
||||||
|
def test_subset_and_empty_categories_omitted(self):
|
||||||
|
catalog = tl.render_catalog(["activate_workflow", "create_sub_agent"])
|
||||||
|
self.assertIn("工作流", catalog)
|
||||||
|
self.assertIn("子智能体", catalog)
|
||||||
|
self.assertNotIn("对话回顾", catalog)
|
||||||
|
self.assertNotIn("彩蛋", catalog)
|
||||||
|
|
||||||
|
def test_unavailable_excluded(self):
|
||||||
|
catalog = tl.render_catalog(["activate_workflow"], unavailable={"activate_workflow"})
|
||||||
|
self.assertEqual(catalog, "")
|
||||||
|
|
||||||
|
def test_load_tools_definition(self):
|
||||||
|
definition = tl.build_load_tools_definition()
|
||||||
|
fn = definition["function"]
|
||||||
|
self.assertEqual(fn["name"], "load_tools")
|
||||||
|
self.assertEqual(fn["parameters"]["required"], ["tool_names"])
|
||||||
|
|
||||||
|
|
||||||
|
class PersonalizationFieldsTest(unittest.TestCase):
|
||||||
|
def test_sanitize_defaults(self):
|
||||||
|
from modules.personalization_manager import sanitize_personalization_payload
|
||||||
|
|
||||||
|
result = sanitize_personalization_payload({})
|
||||||
|
self.assertTrue(result["tool_loading_enabled"])
|
||||||
|
self.assertEqual(len(result["tool_loading_deferred"]), 18)
|
||||||
|
|
||||||
|
def test_sanitize_explicit_values(self):
|
||||||
|
from modules.personalization_manager import sanitize_personalization_payload
|
||||||
|
|
||||||
|
result = sanitize_personalization_payload({
|
||||||
|
"tool_loading_enabled": False,
|
||||||
|
"tool_loading_deferred": ["activate_workflow", "bogus", 123],
|
||||||
|
})
|
||||||
|
self.assertFalse(result["tool_loading_enabled"])
|
||||||
|
self.assertEqual(result["tool_loading_deferred"], ["activate_workflow"])
|
||||||
|
|
||||||
|
def test_sanitize_empty_list_means_defer_nothing(self):
|
||||||
|
from modules.personalization_manager import sanitize_personalization_payload
|
||||||
|
|
||||||
|
result = sanitize_personalization_payload({"tool_loading_deferred": []})
|
||||||
|
self.assertEqual(result["tool_loading_deferred"], [])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@ -59,8 +59,18 @@ def atomic_write_json(path: PathLike, data: Dict[str, Any]) -> None:
|
|||||||
|
|
||||||
防止写中断留下半截文件(直接 open(path,"w") 覆写的风险)。
|
防止写中断留下半截文件(直接 open(path,"w") 覆写的风险)。
|
||||||
临时文件与目标同目录,保证 os.replace 同卷原子性;异常时清理临时文件。
|
临时文件与目标同目录,保证 os.replace 同卷原子性;异常时清理临时文件。
|
||||||
|
|
||||||
|
符号链接写穿(2026-09-10 修复):目标是符号链接时必须先解析到真实文件再
|
||||||
|
原子替换。否则 os.replace 会把链接本身替换成普通文件(分叉),真实文件
|
||||||
|
根本没被写入——个人空间 personalization.json 共享链接曾因此被
|
||||||
|
user_manager 的链接校准判为分叉并丢弃,表现为「设置保存后立刻回退」。
|
||||||
"""
|
"""
|
||||||
target = Path(path)
|
target = Path(path)
|
||||||
|
try:
|
||||||
|
if target.is_symlink():
|
||||||
|
target = target.resolve()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
target.parent.mkdir(parents=True, exist_ok=True)
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
fd, tmp_name = tempfile.mkstemp(
|
fd, tmp_name = tempfile.mkstemp(
|
||||||
prefix=f".{target.name}.", suffix=".tmp", dir=str(target.parent)
|
prefix=f".{target.name}.", suffix=".tmp", dir=str(target.parent)
|
||||||
|
|||||||
@ -274,6 +274,29 @@ class CompressionMixin:
|
|||||||
msg["metadata"] = metadata
|
msg["metadata"] = metadata
|
||||||
history[idx] = msg
|
history[idx] = msg
|
||||||
self.conversation_history = history
|
self.conversation_history = history
|
||||||
|
# 工具动态加载:被标记的消息在构建时会被替换内容,load_tools 返回的
|
||||||
|
# 工具定义随之从上下文消失,loaded 状态必须回滚(pending 回满)。
|
||||||
|
try:
|
||||||
|
from core.tool_loading import (
|
||||||
|
METADATA_KEY as _TL_META_KEY,
|
||||||
|
get_tool_loading_state as _tl_get_state,
|
||||||
|
reset_state_after_compression as _tl_reset,
|
||||||
|
)
|
||||||
|
_tl_state = _tl_get_state(getattr(self, "conversation_metadata", None))
|
||||||
|
if _tl_state:
|
||||||
|
_tl_new = _tl_reset(_tl_state)
|
||||||
|
_tl_conv_id = getattr(self, "current_conversation_id", None)
|
||||||
|
if _tl_conv_id:
|
||||||
|
_tl_mgr = (
|
||||||
|
self._get_conversation_manager_for_id(_tl_conv_id)
|
||||||
|
if hasattr(self, "_get_conversation_manager_for_id")
|
||||||
|
else self.conversation_manager
|
||||||
|
)
|
||||||
|
_tl_mgr.update_conversation_metadata(_tl_conv_id, {_TL_META_KEY: _tl_new})
|
||||||
|
if isinstance(getattr(self, "conversation_metadata", None), dict):
|
||||||
|
self.conversation_metadata[_TL_META_KEY] = _tl_new
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
return len(candidates)
|
return len(candidates)
|
||||||
|
|
||||||
def compress_conversation(self, conversation_id: str) -> Dict:
|
def compress_conversation(self, conversation_id: str) -> Dict:
|
||||||
@ -398,6 +421,21 @@ class CompressionMixin:
|
|||||||
has_images = metadata.get("has_images", False)
|
has_images = metadata.get("has_images", False)
|
||||||
original_title = conversation_data.get("title")
|
original_title = conversation_data.get("title")
|
||||||
|
|
||||||
|
# 工具动态加载:仅源对话已启用时延续配置,且 loaded 回滚
|
||||||
|
# (load_tools 定义不随压缩摘要进入新历史);老对话保持未启用。
|
||||||
|
_tl_overrides: Dict[str, Any] = {}
|
||||||
|
try:
|
||||||
|
from core.tool_loading import (
|
||||||
|
METADATA_KEY as _TL_META_KEY,
|
||||||
|
get_tool_loading_state as _tl_get_state,
|
||||||
|
reset_state_after_compression as _tl_reset,
|
||||||
|
)
|
||||||
|
_tl_src = _tl_get_state(metadata)
|
||||||
|
if _tl_src:
|
||||||
|
_tl_overrides[_TL_META_KEY] = _tl_reset(_tl_src)
|
||||||
|
except Exception:
|
||||||
|
_tl_overrides = {}
|
||||||
|
|
||||||
compressed_conversation_id = self.conversation_manager.create_conversation(
|
compressed_conversation_id = self.conversation_manager.create_conversation(
|
||||||
project_path=project_path,
|
project_path=project_path,
|
||||||
thinking_mode=thinking_mode,
|
thinking_mode=thinking_mode,
|
||||||
@ -407,6 +445,7 @@ class CompressionMixin:
|
|||||||
has_images=has_images,
|
has_images=has_images,
|
||||||
metadata_overrides={
|
metadata_overrides={
|
||||||
"permission_mode": metadata.get("permission_mode", "unrestricted"),
|
"permission_mode": metadata.get("permission_mode", "unrestricted"),
|
||||||
|
**_tl_overrides,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -157,6 +157,16 @@ class ConversationMixin:
|
|||||||
|
|
||||||
# 创建新对话:多智能体对话使用独立的 conversation_manager
|
# 创建新对话:多智能体对话使用独立的 conversation_manager
|
||||||
is_multi_agent = bool((metadata_overrides or {}).get("multi_agent_mode"))
|
is_multi_agent = bool((metadata_overrides or {}).get("multi_agent_mode"))
|
||||||
|
# 工具动态加载:创建即钉死快照(多智能体对话 v1 不启用;老对话无字段=不启用)
|
||||||
|
try:
|
||||||
|
from core.tool_loading import snapshot_overrides_from_prefs
|
||||||
|
from modules.personalization_manager import load_personalization_config as _load_tl_prefs
|
||||||
|
_tl_prefs = getattr(self, "custom_personalization_config", None) or _load_tl_prefs(self.data_dir)
|
||||||
|
_tl_overrides = snapshot_overrides_from_prefs(_tl_prefs, multi_agent_mode=is_multi_agent)
|
||||||
|
if _tl_overrides:
|
||||||
|
metadata_overrides = {**_tl_overrides, **(metadata_overrides or {})}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
target_manager = self._get_conversation_manager_for_multi_agent_mode(is_multi_agent)
|
target_manager = self._get_conversation_manager_for_multi_agent_mode(is_multi_agent)
|
||||||
conversation_id = target_manager.create_conversation(
|
conversation_id = target_manager.create_conversation(
|
||||||
project_path=project_path,
|
project_path=project_path,
|
||||||
@ -520,6 +530,9 @@ class ConversationMixin:
|
|||||||
has_images=has_images,
|
has_images=has_images,
|
||||||
metadata_overrides={
|
metadata_overrides={
|
||||||
"permission_mode": metadata.get("permission_mode", "unrestricted"),
|
"permission_mode": metadata.get("permission_mode", "unrestricted"),
|
||||||
|
# 复制对话是同一对话的分叉,延续源对话的工具动态加载状态
|
||||||
|
**({"tool_loading": deepcopy(metadata["tool_loading"])}
|
||||||
|
if isinstance(metadata.get("tool_loading"), dict) else {}),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
from utils.tool_result_formatter.common import (
|
from utils.tool_result_formatter.common import (
|
||||||
_format_failure, _preview_text, _summarize_output_block, _summarize_todo_tasks
|
_format_failure, _preview_text, _summarize_output_block, _summarize_todo_tasks
|
||||||
@ -256,14 +257,27 @@ def _format_get_sub_agent_status(result_data: Dict[str, Any]) -> str:
|
|||||||
blocks.append("\n".join(lines))
|
blocks.append("\n".join(lines))
|
||||||
return "\n\n".join(blocks)
|
return "\n\n".join(blocks)
|
||||||
|
|
||||||
def _format_close_sub_agent(result_data: Dict[str, Any]) -> str:
|
def _format_load_tools(result_data: Dict[str, Any]) -> str:
|
||||||
if not result_data.get("success"):
|
if not result_data.get("success"):
|
||||||
return _format_failure("close_sub_agent", result_data)
|
return _format_failure("load_tools", result_data)
|
||||||
message = result_data.get("message") or tr("fmt_agent2.closed")
|
tools = result_data.get("tools") or []
|
||||||
task_id = result_data.get("task_id")
|
names = [
|
||||||
status = result_data.get("status")
|
name for name in ((t.get("function") or {}).get("name") for t in tools)
|
||||||
status_note = tr("fmt_agent2.status_note", status=status) if status else ""
|
if name
|
||||||
return f"{message}{status_note}(task_id={task_id})"
|
]
|
||||||
|
header = result_data.get("message") or tr(
|
||||||
|
"fmt_tool_loading.loaded", n=len(tools), names=", ".join(names)
|
||||||
|
)
|
||||||
|
parts = [header]
|
||||||
|
already = result_data.get("already_loaded") or []
|
||||||
|
if already:
|
||||||
|
parts.append(tr("fmt_tool_loading.already", names=", ".join(already)))
|
||||||
|
# 关键:完整 JSON 定义必须保留在上下文输出里——模型后续调用的依据就是
|
||||||
|
# 这份 schema;若只写摘要,历史重建后模型将失去参数结构(与压缩重置
|
||||||
|
# 同源设计:定义只存在于 tool result 中)。
|
||||||
|
parts.append(tr("fmt_tool_loading.defs_header"))
|
||||||
|
parts.append("```json\n" + json.dumps(tools, ensure_ascii=False, indent=2) + "\n```")
|
||||||
|
return "\n\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
def _format_terminate_sub_agent(result_data: Dict[str, Any]) -> str:
|
def _format_terminate_sub_agent(result_data: Dict[str, Any]) -> str:
|
||||||
|
|||||||
@ -34,7 +34,7 @@ from utils.tool_result_formatter.agent_context import (
|
|||||||
_format_todo_update_task,
|
_format_todo_update_task,
|
||||||
_format_update_memory,
|
_format_update_memory,
|
||||||
_format_create_sub_agent,
|
_format_create_sub_agent,
|
||||||
_format_close_sub_agent,
|
_format_load_tools,
|
||||||
_format_get_sub_agent_status,
|
_format_get_sub_agent_status,
|
||||||
_format_terminate_sub_agent,
|
_format_terminate_sub_agent,
|
||||||
_format_send_message_to_sub_agent,
|
_format_send_message_to_sub_agent,
|
||||||
@ -139,7 +139,7 @@ TOOL_FORMATTERS = {
|
|||||||
"save_workflow": _format_save_workflow,
|
"save_workflow": _format_save_workflow,
|
||||||
"manage_personalization": _format_manage_personalization,
|
"manage_personalization": _format_manage_personalization,
|
||||||
"create_sub_agent": _format_create_sub_agent,
|
"create_sub_agent": _format_create_sub_agent,
|
||||||
"close_sub_agent": _format_close_sub_agent,
|
"load_tools": _format_load_tools,
|
||||||
"terminate_sub_agent": _format_terminate_sub_agent,
|
"terminate_sub_agent": _format_terminate_sub_agent,
|
||||||
"send_message_to_sub_agent": _format_send_message_to_sub_agent,
|
"send_message_to_sub_agent": _format_send_message_to_sub_agent,
|
||||||
"stop_sub_agent": _format_stop_sub_agent,
|
"stop_sub_agent": _format_stop_sub_agent,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user