From 70e16fccfb0e286199146efe3a49711deeeefdec Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Thu, 10 Sep 2026 17:07:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(tools):=20=E5=B7=A5=E5=85=B7=E5=8A=A8?= =?UTF-8?q?=E6=80=81=E5=8A=A0=E8=BD=BD=E2=80=94=E2=80=94=E4=BD=8E=E9=A2=91?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E6=8C=89=E9=9C=80=E6=B3=A8=E5=85=A5=EF=BC=8C?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D=E6=96=87=E4=BB=B6=E5=BF=AB=E7=85=A7=E4=B8=BA?= =?UTF-8?q?=E5=87=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 core/tool_loading.py:可延迟注册表(8 类目 18 工具:工作流/子智能体/ 对话回顾/记忆写入/个性化/技能/MCP/彩蛋)、快照与状态迁移、目录渲染、守门判定 - 常驻 load_tools 工具:按名在 tool result 返回完整 JSON 定义后直接调用; 执行守门拦截未加载的延迟工具并引导先加载;只读/计划模式放行 - 对话创建时快照 metadata.tool_loading(enabled/deferred_set/initial_exposed/ loaded/pending),创建即钉死,个人空间设置只影响新建对话;老对话无字段 不生效(全量工具,零迁移);多智能体对话 v1 不启用 - prompt 目录走现有冻结机制(frozen_tool_loading_prompt,skills 段之后), 创建即冻结终身不变,不破坏前缀缓存;tools 数组恒为「构建集−deferred_set」 - 压缩三钩子(深度/浅度/手动)重置 loaded→空、pending→deferred_set, frozen_tool_loading_prompt 纳入 REBUILD_FROZEN_KEYS - 个人空间新增「按需加载的工具」设置区(总开关默认开+类目分组勾选, 双语 i18n),GET /api/personalization 下发注册表 - load_tools 上下文 formatter 保留完整 JSON 定义(定义靠 tool result 存续); 前端渲染名称+状态徽章;新增 19 个单元测试 - AGENTS.md 新增 §13(硬约束与新增可延迟工具 checklist) --- AGENTS.md | 28 +- core/main_terminal_parts/context/messages.py | 37 ++ .../tools_definition/main.py | 56 ++- core/main_terminal_parts/tools_execution.py | 92 +++++ core/tool_loading.py | 322 ++++++++++++++++++ modules/i18n_messages/misc_utils2.py | 12 + modules/i18n_messages/tools_execution.py | 26 ++ modules/personalization_manager.py | 30 ++ prompts/tool_loading.txt | 9 + server/chat/settings.py | 2 + server/conversation.py | 7 + server/deep_compression.py | 14 + server/runtime/service.py | 24 +- server/workflow_runtime_api.py | 6 + .../components/chat/actions/toolRenderers.ts | 47 +++ .../personalization/PersonalizationDrawer.vue | 3 +- .../personalization/tabs/ToolsTab.vue | 76 ++++- static/src/locales/en-US/personalization.ts | 13 + static/src/locales/en-US/toolResults.ts | 4 + static/src/locales/zh-CN/personalization.ts | 13 + static/src/locales/zh-CN/toolResults.ts | 4 + static/src/stores/personalization.ts | 23 ++ static/src/utils/avatarFace.ts | 1 + static/src/utils/icons.ts | 1 + test/test_tool_loading.py | 167 +++++++++ utils/context_manager/compression_mixin.py | 39 +++ utils/context_manager/conversation_mixin.py | 13 + utils/tool_result_formatter/agent_context.py | 24 ++ utils/tool_result_formatter/dispatch.py | 2 + 29 files changed, 1088 insertions(+), 7 deletions(-) create mode 100644 core/tool_loading.py create mode 100644 prompts/tool_loading.txt create mode 100644 test/test_tool_loading.py diff --git a/AGENTS.md b/AGENTS.md index 1262c7e3..ea67dba9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -592,4 +592,30 @@ AI 执行以下流程时,每一步都要向用户说明在做什么: 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→恢复旧值」正是此前标志卡死的原因。 -3. **不要依赖 build_messages 防御层掩盖并发问题**:`core/main_terminal_parts/context/messages.py` 的孤儿 tool 消息剥离只是「坏数据不再 400」的止血层,乱序段本身意味着历史已被污染;发现剥离 warning 日志应按事故排查,而不是视为正常。 \ No newline at end of file +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.` 需双语补齐)。 +4. 跑 `test/test_tool_loading.py`(注册表完整性断言会校验数量与结构)。 diff --git a/core/main_terminal_parts/context/messages.py b/core/main_terminal_parts/context/messages.py index ece0b740..98dcfca4 100644 --- a/core/main_terminal_parts/context/messages.py +++ b/core/main_terminal_parts/context/messages.py @@ -359,6 +359,43 @@ class MessagesMixin: if 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_flow.refresh_workflow_system_segment 同步刷新, # 压缩不影响(system 段不在压缩范围),天然免疫压缩丢失。 diff --git a/core/main_terminal_parts/tools_definition/main.py b/core/main_terminal_parts/tools_definition/main.py index f0e3b868..71c6a241 100644 --- a/core/main_terminal_parts/tools_definition/main.py +++ b/core/main_terminal_parts/tools_definition/main.py @@ -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 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.context_manager import ContextManager from utils.tool_result_formatter import format_tool_result_for_context @@ -86,8 +90,12 @@ DISABLE_LENGTH_CHECK = True 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.extend(self._build_core_tools()) tools.extend(self._build_file_tools()) @@ -157,7 +165,49 @@ class ToolsDefinitionMainMixin: tool for tool in 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 级,不上终端) tool_names = [t.get("function", {}).get("name") for t in tools] logger.debug("[define_tools] 可用工具列表: %s", tool_names) diff --git a/core/main_terminal_parts/tools_execution.py b/core/main_terminal_parts/tools_execution.py index 2dfa15ba..c0217361 100644 --- a/core/main_terminal_parts/tools_execution.py +++ b/core/main_terminal_parts/tools_execution.py @@ -223,6 +223,8 @@ class MainTerminalToolsExecutionMixin: # 工作流只读查询(激活/推进/停用/保存等状态写操作仍禁止) "list_workflows", "get_workflow_status", + # 工具动态加载入口(只读发现性质,各权限模式放行) + "load_tools", } _APPROVAL_REQUIRED_TOOLS = { "run_command", @@ -1072,6 +1074,21 @@ class MainTerminalToolsExecutionMixin: if not await self.confirm_action(tool_name, arguments): 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: # 检查参数总大小 @@ -2530,6 +2547,81 @@ class MainTerminalToolsExecutionMixin: elif tool_name == "trigger_easter_egg": 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": logger.info("[handle_tool_call] 进入manage_personalization分支") result = await self._execute_manage_personalization(arguments) diff --git a/core/tool_loading.py b/core/tool_loading.py new file mode 100644 index 00000000..c100f4dd --- /dev/null +++ b/core/tool_loading.py @@ -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) diff --git a/modules/i18n_messages/misc_utils2.py b/modules/i18n_messages/misc_utils2.py index e90b7533..4345146d 100644 --- a/modules/i18n_messages/misc_utils2.py +++ b/modules/i18n_messages/misc_utils2.py @@ -168,6 +168,18 @@ MESSAGES = { "zh-CN": "子智能体已被强制关闭。", "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": { "zh-CN": "子智能体已暂停。", "en-US": "Sub-agent paused.", diff --git a/modules/i18n_messages/tools_execution.py b/modules/i18n_messages/tools_execution.py index cf987d71..d92034cc 100644 --- a/modules/i18n_messages/tools_execution.py +++ b/modules/i18n_messages/tools_execution.py @@ -522,6 +522,32 @@ MESSAGES = { "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": { "zh-CN": "个性化功能总开关", diff --git a/modules/personalization_manager.py b/modules/personalization_manager.py index 40ef1f4f..d5a68a27 100644 --- a/modules/personalization_manager.py +++ b/modules/personalization_manager.py @@ -94,6 +94,10 @@ DEFAULT_PERSONALIZATION_CONFIG: Dict[str, Any] = { "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_background_enabled": False, # 强约束:run_command 后台模式需先阅读 run-command-guide + # 工具动态加载:总开关(默认开启)+ 默认延迟工具集(None=注册表全集)。 + # 仅在创建对话时快照一次;已有对话以其对话文件中的快照为准,改这里不影响。 + "tool_loading_enabled": True, + "tool_loading_deferred": None, "default_model": None, "image_compression": "original", # original / 1080p / 720p / 540p "auto_shallow_compress_enabled": False, @@ -311,6 +315,16 @@ def sanitize_personalization_payload( else: 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 提示系统开关 if "skill_hints_enabled" in data: base["skill_hints_enabled"] = bool(data.get("skill_hints_enabled")) @@ -1061,6 +1075,22 @@ def _sanitize_considerations(value: Any) -> str: 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: if not isinstance(value, list): return [] diff --git a/prompts/tool_loading.txt b/prompts/tool_loading.txt new file mode 100644 index 00000000..b4b12171 --- /dev/null +++ b/prompts/tool_loading.txt @@ -0,0 +1,9 @@ +## 按需加载的工具 + +本对话启用了工具动态加载:下列工具默认未加载。需要使用某类能力时,先调用 load_tools(tool_names 参数,一次可传多个工具名)获取完整工具定义,之后即可像普通工具一样直接调用。已加载的工具在本对话中持续可用,无需重复加载;未加载就直接调用会被拒绝并返回引导提示。 + +{tool_catalog} + +使用建议: +- 用户意图明确属于某个类目时,先一次性加载该类目所需的全部工具再行动,避免边做边停 +- load_tools 只接受上方列出的工具名;上方未列出的工具均已默认加载,无需处理 diff --git a/server/chat/settings.py b/server/chat/settings.py index 163b9a59..5e1eb41d 100644 --- a/server/chat/settings.py +++ b/server/chat/settings.py @@ -34,6 +34,7 @@ from modules.upload_security import UploadSecurityError from modules.host_sandbox_policy import load_policy, save_policy from modules.user_manager import UserWorkspace from core.web_terminal import WebTerminal +from core.tool_loading import build_registry_payload 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 @@ -305,6 +306,7 @@ def get_personalization_settings(terminal: WebTerminal, workspace: UserWorkspace "data": data_out, "tool_categories": terminal.get_tool_settings_snapshot(), "skills_catalog": skills_catalog, + "tool_loading_registry": build_registry_payload(), "context_compression_settings": { **compression_settings, "context_window_tokens": min( diff --git a/server/conversation.py b/server/conversation.py index 7e11fdcd..bc74bf17 100644 --- a/server/conversation.py +++ b/server/conversation.py @@ -734,6 +734,12 @@ def create_conversation(terminal: WebTerminal, workspace: UserWorkspace, usernam default_effort = None else: 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( project_path=str(workspace.project_path), thinking_mode=safe_thinking, @@ -747,6 +753,7 @@ def create_conversation(terminal: WebTerminal, workspace: UserWorkspace, usernam "pre_plan_permission_mode": safe_pre_plan_permission, "multi_agent_mode": bool(multi_agent_mode), "reasoning_effort": default_effort, + **_tl_overrides, }, ) try: diff --git a/server/deep_compression.py b/server/deep_compression.py index 9e8210b7..dace143b 100644 --- a/server/deep_compression.py +++ b/server/deep_compression.py @@ -636,6 +636,7 @@ async def run_deep_compression( "frozen_workspace_prompt", "frozen_agents_md_prompt", "frozen_skills_prompt", + "frozen_tool_loading_prompt", "frozen_memory_prompt", "frozen_custom_system_prompt", "frozen_disabled_tools_prompt", @@ -657,6 +658,19 @@ async def run_deep_compression( } for frozen_key in REBUILD_FROZEN_KEYS: 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) # 同步内存中的 metadata,清除 frozen 缓存 try: diff --git a/server/runtime/service.py b/server/runtime/service.py index 86b8759b..2d84d9e2 100644 --- a/server/runtime/service.py +++ b/server/runtime/service.py @@ -81,11 +81,22 @@ class RuntimeService: if thinking_mode is None: thinking_mode = p.preferred_thinking_mode 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( project_path=str(getattr(workspace, "project_path", "") or "."), run_mode=run_mode, thinking_mode=thinking_mode, model_key=params.model_key or p.preferred_model_key, + metadata_overrides=_tl_overrides or None, ) debug_log(f"[RuntimeService] 未携带 conversation_id,已补建对话: {conversation_id}") return conversation_id @@ -260,12 +271,23 @@ class RuntimeService: resolved_thinking = ( 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( project_path=str(getattr(workspace, "project_path", "") or "."), run_mode=resolved_run_mode, thinking_mode=resolved_thinking, 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} diff --git a/server/workflow_runtime_api.py b/server/workflow_runtime_api.py index b290a254..f068b8f9 100644 --- a/server/workflow_runtime_api.py +++ b/server/workflow_runtime_api.py @@ -96,6 +96,12 @@ def api_activate_workflow(terminal, workspace, username): "pre_plan_permission_mode": _pre_plan_permission, "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: conversation_id = cm.create_conversation( project_path=str(getattr(workspace, "project_path", "") or "."), diff --git a/static/src/components/chat/actions/toolRenderers.ts b/static/src/components/chat/actions/toolRenderers.ts index b5749fc5..71f855c5 100644 --- a/static/src/components/chat/actions/toolRenderers.ts +++ b/static/src/components/chat/actions/toolRenderers.ts @@ -189,6 +189,11 @@ export function renderEnhancedToolResult( return renderGetSubAgentStatus(result); } + // 工具动态加载 + else if (name === 'load_tools') { + return renderLoadTools(result, args); + } + // 默认回退:显示重要参数 + 结果摘要,不再直接返回空字符串 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 = '
'; + html += `
${escapeHtml(t('toolResults.labels.status'))}${status}
`; + if (requested.length) { + html += `
${escapeHtml(t('toolResults.labels.requestedTools'))}${escapeHtml(requested.join(', '))}
`; + } + html += '
'; + + if (result?.success && toolDefs.length) { + html += '
'; + if (loadedNow.length) { + html += `
${escapeHtml(t('toolResults.labels.loadedNow'))}${escapeHtml(loadedNow.join(', '))}
`; + } + if (already.length) { + html += `
${escapeHtml(t('toolResults.labels.alreadyLoaded'))}${escapeHtml(already.join(', '))}
`; + } + if (unavailable.length) { + html += `
${escapeHtml(t('toolResults.labels.unavailableTools'))}${escapeHtml(unavailable.join(', '))}
`; + } + for (const def of toolDefs) { + const fn = def?.function || {}; + const toolName = String(fn.name || ''); + // 只展示首行描述,完整 JSON 定义留给模型上下文,不上界面 + const desc = String(fn.description || '').split('\n')[0]; + html += `
${escapeHtml(toolName)}${desc ? ` — ${escapeHtml(desc)}` : ''}
`; + } + html += '
'; + } else if (!result?.success && result?.error) { + html += `
${escapeHtml(String(result.error))}
`; + } + + return html; +} + function renderCreateSubAgent(result: any, args: any): string { const status = formatToolStatusLabel(result, t('toolResults.status.created'), t('toolResults.status.createFailed')); const agentId = result.agent_id ?? args.agent_id ?? ''; diff --git a/static/src/components/personalization/PersonalizationDrawer.vue b/static/src/components/personalization/PersonalizationDrawer.vue index 45bf34f3..477a7e0b 100644 --- a/static/src/components/personalization/PersonalizationDrawer.vue +++ b/static/src/components/personalization/PersonalizationDrawer.vue @@ -198,6 +198,7 @@ const { saving, toggleUpdating, toolCategories, + toolLoadingRegistry, skillsCatalog, recentConversationsPromptLimitRange, projectMemoryInjectLimitMin, @@ -1489,7 +1490,7 @@ const drawerContext = { showMcpConfigEntry, skillsCatalog, stackedHideBorders, startTutorial, subAgentCompressThreshold, subAgentMaxTurns, subAgentModels, subAgentRoles, subAgentRolesLoading, subAgentSettingsSaving, themeLabel, themeOptions, toggleCategory, toggleDropdown, toggleGoalTokenLimit, toggleUpdating, - tonePresets, toolCategories, updateReviewAgent, updateReviewAgentInt, usageError, usageLoading, + tonePresets, toolCategories, toolLoadingRegistry, updateReviewAgent, updateReviewAgentInt, usageError, usageLoading, usageSummary, usageUpdatedText, versioningBackupModeLabel, voiceDownloadMsg, voiceDownloadPercent, voiceDownloading, voiceModelPartial, voiceModelReady, workModeLabel, runModeOptions, reasoningEffortOptions, permissionModeOptions, workModeOptions, diff --git a/static/src/components/personalization/tabs/ToolsTab.vue b/static/src/components/personalization/tabs/ToolsTab.vue index 992db305..1e96d00a 100644 --- a/static/src/components/personalization/tabs/ToolsTab.vue +++ b/static/src/components/personalization/tabs/ToolsTab.vue @@ -14,8 +14,44 @@ const { personalization, skillsCatalog, toggleCategory, - toolCategories + toolCategories, + toolLoadingRegistry } = 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( + 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) }); +}; diff --git a/static/src/locales/en-US/personalization.ts b/static/src/locales/en-US/personalization.ts index a3d9ff5d..e5618885 100644 --- a/static/src/locales/en-US/personalization.ts +++ b/static/src/locales/en-US/personalization.ts @@ -240,6 +240,19 @@ export default { availableSkillsDesc: 'Checked skills are injected into the system prompt and synced to .astrion/skills/ in the workspace', disabledToolCategoriesTitle: 'Default disabled tool categories', 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 ── imageCompressionTitle: 'Image compression', diff --git a/static/src/locales/en-US/toolResults.ts b/static/src/locales/en-US/toolResults.ts index b6b03a9b..6534619d 100644 --- a/static/src/locales/en-US/toolResults.ts +++ b/static/src/locales/en-US/toolResults.ts @@ -98,6 +98,10 @@ export default { userAnswer: 'User answer:', subAgent: 'Sub-agent:', subAgentId: 'Sub-agent ID:', + requestedTools: 'Requested:', + loadedNow: 'Newly loaded:', + alreadyLoaded: 'Already loaded:', + unavailableTools: 'Unavailable:', deliverablesDir: 'Deliverables dir:', task: 'Task:', workTime: 'Work time:', diff --git a/static/src/locales/zh-CN/personalization.ts b/static/src/locales/zh-CN/personalization.ts index 532f2554..edb71574 100644 --- a/static/src/locales/zh-CN/personalization.ts +++ b/static/src/locales/zh-CN/personalization.ts @@ -241,6 +241,19 @@ export default { availableSkillsDesc: '勾选后会注入 system prompt,并同步到工作区的 .astrion/skills/ 目录', disabledToolCategoriesTitle: '默认禁用工具类别', disabledToolCategoriesDesc: '选择后,这些类别在新任务中保持关闭', + toolLoadingTitle: '按需加载的工具(动态加载)', + toolLoadingDesc: '勾选的工具默认不注入上下文,模型需要时经 load_tools 按需获取完整定义,可省 token 并提高工具选择准确率;仅对新对话生效,已有对话以创建时的快照为准', + toolLoadingEnabledTitle: '启用工具动态加载', + toolLoadingCat: { + workflow: '工作流', + sub_agent: '子智能体', + conversation: '对话回顾', + memory_write: '记忆写入', + personalization: '个性化', + skill_create: '技能', + mcp: 'MCP', + misc: '彩蛋' + }, // ── 文件与图片 ── imageCompressionTitle: '图片压缩', diff --git a/static/src/locales/zh-CN/toolResults.ts b/static/src/locales/zh-CN/toolResults.ts index 3fd607a4..600f72cc 100644 --- a/static/src/locales/zh-CN/toolResults.ts +++ b/static/src/locales/zh-CN/toolResults.ts @@ -105,6 +105,10 @@ export default { userAnswer: '用户回答:', subAgent: '子智能体:', subAgentId: '子智能体 ID:', + requestedTools: '请求加载:', + loadedNow: '新加载:', + alreadyLoaded: '此前已加载:', + unavailableTools: '不可用:', deliverablesDir: '交付目录:', task: '任务:', workTime: '工作时间:', diff --git a/static/src/stores/personalization.ts b/static/src/stores/personalization.ts index a05e1c9a..d92d7074 100644 --- a/static/src/stores/personalization.ts +++ b/static/src/stores/personalization.ts @@ -100,6 +100,9 @@ interface PersonalForm { recent_conversations_prompt_limit: number | string; project_memory_inject_limit: number | string | null; tool_intent_enabled: boolean; + tool_loading_enabled: boolean; + /** 默认延迟加载的工具名列表(仅影响新建对话) */ + tool_loading_deferred: string[]; skill_hints_enabled: boolean; skill_strict_terminal_enabled: boolean; skill_strict_sub_agent_enabled: boolean; @@ -179,6 +182,7 @@ interface PersonalizationState { overlayPressActive: boolean; form: PersonalForm; toolCategories: Array<{ id: string; label: string }>; + toolLoadingRegistry: Array<{ key: string; tools: string[] }>; skillsCatalog: Array<{ id: string; label: string; description?: string }>; recentConversationsPromptLimitRange: { min: number; max: number }; projectMemoryInjectLimitMin: number; @@ -308,6 +312,8 @@ const defaultForm = (): PersonalForm => ({ recent_conversations_prompt_limit: DEFAULT_RECENT_CONVERSATIONS_PROMPT_LIMIT, project_memory_inject_limit: DEFAULT_PROJECT_MEMORY_INJECT_LIMIT, tool_intent_enabled: true, + tool_loading_enabled: true, + tool_loading_deferred: [], skill_hints_enabled: false, skill_strict_terminal_enabled: false, skill_strict_sub_agent_enabled: false, @@ -433,6 +439,7 @@ export const usePersonalizationStore = defineStore('personalization', { overlayPressActive: false, form: defaultForm(), toolCategories: [], + toolLoadingRegistry: [], skillsCatalog: [], recentConversationsPromptLimitRange: { ...DEFAULT_RECENT_CONVERSATIONS_PROMPT_LIMIT_RANGE }, projectMemoryInjectLimitMin: PROJECT_MEMORY_INJECT_LIMIT_MIN, @@ -529,6 +536,10 @@ export const usePersonalizationStore = defineStore('personalization', { data.project_memory_inject_limit ), 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_strict_terminal_enabled: !!data.skill_strict_terminal_enabled, skill_strict_sub_agent_enabled: !!data.skill_strict_sub_agent_enabled, @@ -780,6 +791,18 @@ export const usePersonalizationStore = defineStore('personalization', { } else { 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)) { this.skillsCatalog = payload.skills_catalog .map((item: { id?: string; label?: string; description?: string } = {}) => ({ diff --git a/static/src/utils/avatarFace.ts b/static/src/utils/avatarFace.ts index dd3832fa..ee8b3ed0 100644 --- a/static/src/utils/avatarFace.ts +++ b/static/src/utils/avatarFace.ts @@ -28,6 +28,7 @@ const TOOL_FACE_MAP: Record = { todo_create: 'note', todo_update_task: 'check', create_sub_agent: 'subagent', + load_tools: 'search', terminate_sub_agent: 'subagent', get_sub_agent_status: 'subagent', manage_personalization: 'persona', diff --git a/static/src/utils/icons.ts b/static/src/utils/icons.ts index 4da358f4..2424cff6 100644 --- a/static/src/utils/icons.ts +++ b/static/src/utils/icons.ts @@ -63,6 +63,7 @@ export const ICONS = Object.freeze({ }); export const TOOL_ICON_MAP = Object.freeze({ + load_tools: 'wrench', create_file: 'file', create_skill: 'sparkles', manage_personalization: 'userPen', diff --git a/test/test_tool_loading.py b/test/test_tool_loading.py new file mode 100644 index 00000000..e9779465 --- /dev/null +++ b/test/test_tool_loading.py @@ -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() diff --git a/utils/context_manager/compression_mixin.py b/utils/context_manager/compression_mixin.py index 8e509541..ab266723 100644 --- a/utils/context_manager/compression_mixin.py +++ b/utils/context_manager/compression_mixin.py @@ -274,6 +274,29 @@ class CompressionMixin: msg["metadata"] = metadata history[idx] = msg 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) def compress_conversation(self, conversation_id: str) -> Dict: @@ -398,6 +421,21 @@ class CompressionMixin: has_images = metadata.get("has_images", False) 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( project_path=project_path, thinking_mode=thinking_mode, @@ -407,6 +445,7 @@ class CompressionMixin: has_images=has_images, metadata_overrides={ "permission_mode": metadata.get("permission_mode", "unrestricted"), + **_tl_overrides, }, ) diff --git a/utils/context_manager/conversation_mixin.py b/utils/context_manager/conversation_mixin.py index 7be3a227..df8d0c38 100644 --- a/utils/context_manager/conversation_mixin.py +++ b/utils/context_manager/conversation_mixin.py @@ -157,6 +157,16 @@ class ConversationMixin: # 创建新对话:多智能体对话使用独立的 conversation_manager 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) conversation_id = target_manager.create_conversation( project_path=project_path, @@ -520,6 +530,9 @@ class ConversationMixin: has_images=has_images, metadata_overrides={ "permission_mode": metadata.get("permission_mode", "unrestricted"), + # 复制对话是同一对话的分叉,延续源对话的工具动态加载状态 + **({"tool_loading": deepcopy(metadata["tool_loading"])} + if isinstance(metadata.get("tool_loading"), dict) else {}), }, ) diff --git a/utils/tool_result_formatter/agent_context.py b/utils/tool_result_formatter/agent_context.py index 341ca6e0..cba8d938 100644 --- a/utils/tool_result_formatter/agent_context.py +++ b/utils/tool_result_formatter/agent_context.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json from typing import Any, Dict, List, Optional, Tuple from utils.tool_result_formatter.common import ( _format_failure, _preview_text, _summarize_output_block, _summarize_todo_tasks @@ -256,6 +257,29 @@ def _format_get_sub_agent_status(result_data: Dict[str, Any]) -> str: blocks.append("\n".join(lines)) return "\n\n".join(blocks) +def _format_load_tools(result_data: Dict[str, Any]) -> str: + if not result_data.get("success"): + return _format_failure("load_tools", result_data) + tools = result_data.get("tools") or [] + names = [ + name for name in ((t.get("function") or {}).get("name") for t in tools) + if name + ] + 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: if not result_data.get("success"): return _format_failure("terminate_sub_agent", result_data) diff --git a/utils/tool_result_formatter/dispatch.py b/utils/tool_result_formatter/dispatch.py index edd510a5..b57742f4 100644 --- a/utils/tool_result_formatter/dispatch.py +++ b/utils/tool_result_formatter/dispatch.py @@ -34,6 +34,7 @@ from utils.tool_result_formatter.agent_context import ( _format_todo_update_task, _format_update_memory, _format_create_sub_agent, + _format_load_tools, _format_get_sub_agent_status, _format_terminate_sub_agent, _format_send_message_to_sub_agent, @@ -138,6 +139,7 @@ TOOL_FORMATTERS = { "save_workflow": _format_save_workflow, "manage_personalization": _format_manage_personalization, "create_sub_agent": _format_create_sub_agent, + "load_tools": _format_load_tools, "terminate_sub_agent": _format_terminate_sub_agent, "send_message_to_sub_agent": _format_send_message_to_sub_agent, "stop_sub_agent": _format_stop_sub_agent,