feat(tools): 工具动态加载——低频工具按需注入,对话文件快照为准

- 新增 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)
This commit is contained in:
JOJO 2026-09-10 17:07:15 +08:00
parent f6a46b5b0a
commit 70e16fccfb
29 changed files with 1088 additions and 7 deletions

View File

@ -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 日志应按事故排查,而不是视为正常。
3. **不要依赖 build_messages 防御层掩盖并发问题**`core/main_terminal_parts/context/messages.py` 的孤儿 tool 消息剥离只是「坏数据不再 400」的止血层乱序段本身意味着历史已被污染发现剥离 warning 日志应按事故排查,而不是视为正常。
## 13) 工具动态加载tool_loading2026-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-09commit 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`(注册表完整性断言会校验数量与结构)。

View File

@ -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 段不在压缩范围),天然免疫压缩丢失。

View File

@ -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)

View File

@ -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)

322
core/tool_loading.py Normal file
View 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 追加指定工具后的新 statepending 同步重算)。"""
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)

View File

@ -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.",

View File

@ -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": "个性化功能总开关",

View File

@ -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 []

9
prompts/tool_loading.txt Normal file
View File

@ -0,0 +1,9 @@
## 按需加载的工具
本对话启用了工具动态加载:下列工具默认未加载。需要使用某类能力时,先调用 load_toolstool_names 参数,一次可传多个工具名)获取完整工具定义,之后即可像普通工具一样直接调用。已加载的工具在本对话中持续可用,无需重复加载;未加载就直接调用会被拒绝并返回引导提示。
{tool_catalog}
使用建议:
- 用户意图明确属于某个类目时,先一次性加载该类目所需的全部工具再行动,避免边做边停
- load_tools 只接受上方列出的工具名;上方未列出的工具均已默认加载,无需处理

View File

@ -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(

View File

@ -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:

View File

@ -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:

View File

@ -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}

View File

@ -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 "."),

View File

@ -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.6meta=参数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 {
const status = formatToolStatusLabel(result, t('toolResults.status.created'), t('toolResults.status.createFailed'));
const agentId = result.agent_id ?? args.agent_id ?? '';

View File

@ -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,

View File

@ -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<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>
<template>
@ -166,5 +202,43 @@ const {
@change="toggleCategory(category.id)" /><FancyCheck :checked="form.disabled_tool_categories.includes(category.id)" /></label>
</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>
</template>

View File

@ -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',

View File

@ -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:',

View File

@ -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: '图片压缩',

View File

@ -105,6 +105,10 @@ export default {
userAnswer: '用户回答:',
subAgent: '子智能体:',
subAgentId: '子智能体 ID',
requestedTools: '请求加载:',
loadedNow: '新加载:',
alreadyLoaded: '此前已加载:',
unavailableTools: '不可用:',
deliverablesDir: '交付目录:',
task: '任务:',
workTime: '工作时间:',

View File

@ -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 } = {}) => ({

View File

@ -28,6 +28,7 @@ const TOOL_FACE_MAP: Record<string, string> = {
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',

View File

@ -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',

167
test/test_tool_loading.py Normal file
View 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()

View File

@ -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,
},
)

View File

@ -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 {}),
},
)

View File

@ -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)

View File

@ -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,