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:
parent
f6a46b5b0a
commit
70e16fccfb
26
AGENTS.md
26
AGENTS.md
@ -593,3 +593,29 @@ 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 日志应按事故排查,而不是视为正常。
|
||||
|
||||
## 13) 工具动态加载(tool_loading,2026-09 新增)
|
||||
|
||||
> 设计文档:`docs/dynamic_tool_loading_plan.md`。注册表与状态辅助唯一权威:`core/tool_loading.py`。
|
||||
|
||||
### 13.1 机制一句话
|
||||
|
||||
低频工具默认不进顶层 `tools` 数组,只在 system prompt 冻结段保留「类目+什么时候用+工具名」目录;模型需要时调常驻工具 `load_tools(tool_names=[...])`,在 tool result 里拿回完整 JSON 定义后直接调用;执行守门按对话 metadata 放行已加载工具。
|
||||
|
||||
### 13.2 硬约束(改代码必须知道)
|
||||
|
||||
1. **对话文件是唯一权威**:`metadata.tool_loading = {enabled, deferred_set, initial_exposed, loaded, pending}` 在**创建对话时快照一次**(注入点:`conversation_mixin.start_new_conversation`、`server/conversation.py` 活跃任务分支、`workflow_runtime_api.py`、`runtime/service.py`×2;多智能体对话不写入=不启用)。之后个人空间改动只影响新建对话;执行链路**禁止**为该功能读 personalization / Flask session。
|
||||
2. **老对话无字段 = 未启用**:读侧一律走 `get_tool_loading_state()` 防御性解析(None 即未启用,全量工具),不做迁移。字段损坏同样按未启用处理。
|
||||
3. **tools 数组对话级稳定**:过滤语义恒为「构建集 − deferred_set」,loaded 工具**不**回到数组(否则破坏前缀缓存);`initial_exposed` 只是首个请求懒记录的审计快照,不作为过滤依据。
|
||||
4. **prompt 目录走现有冻结机制**:key=`frozen_tool_loading_prompt`(`_get_or_init_frozen_prompt`,拼接在 skills 段之后);模板 `prompts/tool_loading.txt` 只在冻结那一刻读一次。目录**不**随 loaded 状态变化(system prompt 是缓存首段,动了全破)。
|
||||
5. **压缩必须重置**:深度压缩(`server/deep_compression.py`,in-place)、浅度压缩(`compression_mixin._run_auto_shallow_compression`)、手动压缩新建对话(同文件 `compress_conversation`)三处钩子统一做 `reset_state_after_compression`(loaded 清空、pending 回满 deferred_set)——load_tools 返回的定义活在 tool result 里,压缩后模型上下文已没有它们,不回滚守门会放行无定义调用。
|
||||
6. **守门**:`tools_execution.py` 分发链前置——`deferred_set − loaded` 中的工具被调用时返回引导错误(`tools_exec.tool_not_loaded`),不执行;`load_tools` 本身在 `_READONLY_ALLOWED_TOOLS` 中(只读发现性质)。
|
||||
7. **load_tools 的上下文 formatter 必须保留完整 JSON 定义**(`agent_context.py::_format_load_tools`)——定义靠 tool result 在上下文存续,只做摘要会导致历史重建后模型失去参数结构。
|
||||
8. **close_sub_agent 已彻底删除**(2026-09,commit f6a46b5b):它自出生就是 terminate_sub_agent 的别名,拆分重构时执行分支丢失成僵尸。勿恢复;终止子智能体统一用 terminate_sub_agent。
|
||||
|
||||
### 13.3 新增可延迟工具 checklist
|
||||
|
||||
1. `core/tool_loading.py` 的 `DEFERRABLE_REGISTRY` 注册(类目 label + when_to_use 文案 + 工具名);默认延迟集=注册表全集,无需另配。
|
||||
2. 确认后端 formatter(`tool_result_formatter`)与前端 renderer(`toolRenderers.ts`)已覆盖该工具。
|
||||
3. 个人空间勾选 UI 自动出现(注册表经 `/api/personalization` 的 `tool_loading_registry` 下发,类目标签 i18n key `personalization.toolLoadingCat.<key>` 需双语补齐)。
|
||||
4. 跑 `test/test_tool_loading.py`(注册表完整性断言会校验数量与结构)。
|
||||
|
||||
@ -359,6 +359,43 @@ class MessagesMixin:
|
||||
if skills_prompt:
|
||||
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 段不在压缩范围),天然免疫压缩丢失。
|
||||
|
||||
@ -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())
|
||||
@ -158,6 +166,48 @@ class ToolsDefinitionMainMixin:
|
||||
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)
|
||||
|
||||
@ -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
322
core/tool_loading.py
Normal file
@ -0,0 +1,322 @@
|
||||
"""工具动态加载(Deferred Tool Loading)注册表与状态辅助。
|
||||
|
||||
设计文档:docs/dynamic_tool_loading_plan.md
|
||||
|
||||
核心语义(与 runtime_contract 默认值解析优先级对齐:对话元数据绑定 > 用户偏好快照):
|
||||
- 对话创建时把五元组快照写入 conversation metadata["tool_loading"]:
|
||||
enabled / deferred_set / initial_exposed / loaded / pending
|
||||
- 快照创建即钉死,个人空间设置之后只影响新建对话;
|
||||
- load_tools 成功后 loaded/pending 更新(同一写入点);
|
||||
- 对话压缩(深度+浅度)后 reset_state_after_compression 重置 loaded/pending;
|
||||
- 读侧全部防御性解析:字段缺失/损坏一律视为未启用(回退全量工具,安全方向)。
|
||||
|
||||
注意:本模块不读 personalization、不读 Flask session。快照计算所需的用户配置
|
||||
由调用方(适配层)读好后传入。
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Iterable, List, Optional, Set
|
||||
|
||||
METADATA_KEY = "tool_loading"
|
||||
FROZEN_PROMPT_KEY = "frozen_tool_loading_prompt"
|
||||
LOAD_TOOLS_NAME = "load_tools"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 可延迟工具注册表
|
||||
# ---------------------------------------------------------------------------
|
||||
# 类目 key -> {"label": 展示名, "when_to_use": prompt 目录里的「什么时候用」文案,
|
||||
# "tools": [工具名...]}
|
||||
# 新增可延迟工具 checklist(详见 AGENTS.md 工具动态加载一节):
|
||||
# 1. 在此注册类目/工具;2. 评估是否进默认延迟集(DEFAULT_DEFERRED 由注册表全集
|
||||
# 构成,默认即全延迟,无需另配);3. 确认 formatter / 前端 renderer 已覆盖。
|
||||
DEFERRABLE_REGISTRY: Dict[str, Dict[str, Any]] = {
|
||||
"workflow": {
|
||||
"label": "工作流",
|
||||
"when_to_use": "按既定流程执行任务、创建/调整工作流、推进或查询已激活的工作流时",
|
||||
"tools": [
|
||||
"activate_workflow",
|
||||
"report_workflow_stage",
|
||||
"choose_workflow_branch",
|
||||
"get_workflow_status",
|
||||
"deactivate_workflow",
|
||||
"list_workflows",
|
||||
"save_workflow",
|
||||
],
|
||||
},
|
||||
"sub_agent": {
|
||||
"label": "子智能体",
|
||||
"when_to_use": "并行处理独立任务、批量或后台执行、查询/终止子智能体时",
|
||||
"tools": [
|
||||
"create_sub_agent",
|
||||
"get_sub_agent_status",
|
||||
"terminate_sub_agent",
|
||||
],
|
||||
},
|
||||
"conversation": {
|
||||
"label": "对话回顾",
|
||||
"when_to_use": "查找或回顾本工作区的历史对话时",
|
||||
"tools": [
|
||||
"conversation_search",
|
||||
"conversation_review",
|
||||
],
|
||||
},
|
||||
"memory_write": {
|
||||
"label": "记忆写入",
|
||||
"when_to_use": "记录用户偏好、项目约定或重要决策时",
|
||||
"tools": [
|
||||
"update_memory",
|
||||
"update_project_memory",
|
||||
],
|
||||
},
|
||||
"personalization": {
|
||||
"label": "个性化",
|
||||
"when_to_use": "修改称呼、语气、主题等个性化配置时",
|
||||
"tools": [
|
||||
"manage_personalization",
|
||||
],
|
||||
},
|
||||
"skill_create": {
|
||||
"label": "技能",
|
||||
"when_to_use": "把经验沉淀为可复用 skill 时",
|
||||
"tools": [
|
||||
"create_skill",
|
||||
],
|
||||
},
|
||||
"mcp": {
|
||||
"label": "MCP",
|
||||
"when_to_use": "查看或刷新 MCP 服务与工具映射时",
|
||||
"tools": [
|
||||
"list_mcp_servers",
|
||||
],
|
||||
},
|
||||
"misc": {
|
||||
"label": "彩蛋",
|
||||
"when_to_use": "触发隐藏彩蛋时",
|
||||
"tools": [
|
||||
"trigger_easter_egg",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def deferrable_tool_names() -> List[str]:
|
||||
"""注册表内全部可延迟工具名(保序)。"""
|
||||
names: List[str] = []
|
||||
for cat in DEFERRABLE_REGISTRY.values():
|
||||
names.extend(cat["tools"])
|
||||
return names
|
||||
|
||||
|
||||
def category_of_tool(tool_name: str) -> Optional[str]:
|
||||
for cat_key, cat in DEFERRABLE_REGISTRY.items():
|
||||
if tool_name in cat["tools"]:
|
||||
return cat_key
|
||||
return None
|
||||
|
||||
|
||||
def build_registry_payload() -> List[Dict[str, Any]]:
|
||||
"""供个人空间 UI 使用的注册表载荷(类目标签由前端 i18n 解析)。"""
|
||||
return [
|
||||
{"key": cat_key, "tools": list(cat["tools"])}
|
||||
for cat_key, cat in DEFERRABLE_REGISTRY.items()
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 状态读写(全部防御性解析)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _normalize_name_list(value: Any) -> List[str]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
result: List[str] = []
|
||||
for item in value:
|
||||
if isinstance(item, str) and item and item not in result:
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
|
||||
def get_tool_loading_state(metadata: Any) -> Optional[Dict[str, Any]]:
|
||||
"""从对话 metadata 读取并归一化 tool_loading 状态。
|
||||
|
||||
返回 None 表示未启用(老对话无字段 / enabled 非真 / 结构损坏)。
|
||||
返回的 dict 保证含 enabled/deferred_set/initial_exposed/loaded/pending 五键,
|
||||
且 loaded/pending 相互一致(pending = deferred_set − loaded 重算为准)。
|
||||
"""
|
||||
if not isinstance(metadata, dict):
|
||||
return None
|
||||
raw = metadata.get(METADATA_KEY)
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
if raw.get("enabled") is not True:
|
||||
return None
|
||||
deferred_set = [n for n in _normalize_name_list(raw.get("deferred_set"))
|
||||
if category_of_tool(n) is not None]
|
||||
loaded = [n for n in _normalize_name_list(raw.get("loaded")) if n in deferred_set]
|
||||
initial_exposed = _normalize_name_list(raw.get("initial_exposed"))
|
||||
state = {
|
||||
"enabled": True,
|
||||
"deferred_set": deferred_set,
|
||||
"initial_exposed": initial_exposed,
|
||||
"loaded": loaded,
|
||||
"pending": [n for n in deferred_set if n not in loaded],
|
||||
}
|
||||
return state
|
||||
|
||||
|
||||
def is_tool_loading_enabled(metadata: Any) -> bool:
|
||||
return get_tool_loading_state(metadata) is not None
|
||||
|
||||
|
||||
def is_deferred_not_loaded(state: Optional[Dict[str, Any]], tool_name: str) -> bool:
|
||||
"""守门判定:该工具处于「已延迟且尚未加载」状态(应拦截并引导 load_tools)。"""
|
||||
if not state:
|
||||
return False
|
||||
return tool_name in state["pending"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 快照与状态迁移
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def build_snapshot(
|
||||
deferred_config: Optional[Iterable[str]],
|
||||
built_tool_names: Optional[Iterable[str]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""计算创建对话时的 tool_loading 快照(五元组)。
|
||||
|
||||
Args:
|
||||
deferred_config: 用户配置要延迟的工具名(None/非法 => 默认全集)。
|
||||
built_tool_names: 创建时实际暴露的工具名全集(用于记录 initial_exposed;
|
||||
不传则记录为空列表,由 define_tools 以「构建集 − deferred_set」语义
|
||||
动态过滤,不影响行为)。
|
||||
"""
|
||||
registry_names = deferrable_tool_names()
|
||||
if deferred_config is None:
|
||||
deferred_set = list(registry_names)
|
||||
else:
|
||||
requested = set(_normalize_name_list(list(deferred_config)))
|
||||
deferred_set = [n for n in registry_names if n in requested]
|
||||
|
||||
initial_exposed: List[str] = []
|
||||
if built_tool_names is not None:
|
||||
deferred_lookup = set(deferred_set)
|
||||
initial_exposed = [n for n in built_tool_names if n not in deferred_lookup]
|
||||
if deferred_set and LOAD_TOOLS_NAME not in initial_exposed:
|
||||
initial_exposed.append(LOAD_TOOLS_NAME)
|
||||
|
||||
return {
|
||||
"enabled": True,
|
||||
"deferred_set": deferred_set,
|
||||
"initial_exposed": initial_exposed,
|
||||
"loaded": [],
|
||||
"pending": list(deferred_set),
|
||||
}
|
||||
|
||||
|
||||
def snapshot_overrides_from_prefs(
|
||||
personalization_config: Any,
|
||||
multi_agent_mode: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""创建对话时合并进 metadata_overrides 的 tool_loading 部分(适配层调用)。
|
||||
|
||||
返回 {} 表示本对话不启用(多智能体对话 v1 不启用 / 个人空间总开关关闭)。
|
||||
老对话(功能上线前创建)无此字段,读侧一律按未启用处理,不做迁移。
|
||||
|
||||
Args:
|
||||
personalization_config: 调用方已加载的个人空间配置(本函数不做 I/O)。
|
||||
multi_agent_mode: 是否为多智能体对话。
|
||||
"""
|
||||
if multi_agent_mode:
|
||||
return {}
|
||||
prefs = personalization_config if isinstance(personalization_config, dict) else {}
|
||||
if prefs.get("tool_loading_enabled", True) is not True:
|
||||
return {}
|
||||
return {METADATA_KEY: build_snapshot(prefs.get("tool_loading_deferred"))}
|
||||
|
||||
|
||||
def mark_tools_loaded(state: Dict[str, Any], names: Iterable[str]) -> Dict[str, Any]:
|
||||
"""返回 loaded 追加指定工具后的新 state(pending 同步重算)。"""
|
||||
deferred_set = list(state.get("deferred_set") or [])
|
||||
loaded = list(state.get("loaded") or [])
|
||||
for name in names:
|
||||
if name in deferred_set and name not in loaded:
|
||||
loaded.append(name)
|
||||
return {
|
||||
**state,
|
||||
"loaded": loaded,
|
||||
"pending": [n for n in deferred_set if n not in loaded],
|
||||
}
|
||||
|
||||
|
||||
def reset_state_after_compression(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""对话压缩后的重置:loaded 清空、pending 回满 deferred_set。
|
||||
|
||||
压缩会丢弃/摘要历史中的 load_tools 工具结果,模型上下文里已没有这些
|
||||
定义,状态必须随之前滚到「创建时」语义;initial_exposed 与 deferred_set
|
||||
不变,冻结 prompt 目录依然准确。
|
||||
"""
|
||||
deferred_set = list(state.get("deferred_set") or [])
|
||||
return {
|
||||
**state,
|
||||
"loaded": [],
|
||||
"pending": list(deferred_set),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# load_tools 工具定义
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def build_load_tools_definition() -> Dict[str, Any]:
|
||||
"""构建 load_tools 的工具定义(intent 由 define_tools 末尾统一注入)。"""
|
||||
return {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": LOAD_TOOLS_NAME,
|
||||
"description": (
|
||||
"按名加载「按需加载的工具」目录中列出的未加载工具:在工具结果中返回这些工具的"
|
||||
"完整 JSON 定义,之后即可像普通工具一样直接调用。一次可传多个工具名;"
|
||||
"用户意图明确属于某个类目时,建议一次性加载该类目所需的全部工具。"
|
||||
"已加载的工具在本对话中持续可用,重复传入会再次返回定义。"
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tool_names": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": (
|
||||
"要加载的工具名列表,必须来自 system prompt 中「按需加载的工具」"
|
||||
"目录里列出的工具名,例如 [\"activate_workflow\", \"list_workflows\"]"
|
||||
),
|
||||
}
|
||||
},
|
||||
"required": ["tool_names"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# prompt 目录渲染
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def render_catalog(
|
||||
deferred_set: Iterable[str],
|
||||
unavailable: Optional[Set[str]] = None,
|
||||
) -> str:
|
||||
"""按类目渲染 prompt 目录(仅含该对话实际延迟的工具;空类目整行省略)。
|
||||
|
||||
Args:
|
||||
deferred_set: 该对话延迟集。
|
||||
unavailable: 当前不可用的工具(如分类被禁用),从目录中剔除。
|
||||
"""
|
||||
deferred: Set[str] = set(deferred_set or [])
|
||||
skip: Set[str] = set(unavailable or set())
|
||||
lines: List[str] = []
|
||||
for cat in DEFERRABLE_REGISTRY.values():
|
||||
tools = [n for n in cat["tools"] if n in deferred and n not in skip]
|
||||
if not tools:
|
||||
continue
|
||||
lines.append(f"- {cat['label']}({', '.join(tools)}):{cat['when_to_use']}")
|
||||
return "\n".join(lines)
|
||||
@ -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.",
|
||||
|
||||
@ -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": "个性化功能总开关",
|
||||
|
||||
@ -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
9
prompts/tool_loading.txt
Normal file
@ -0,0 +1,9 @@
|
||||
## 按需加载的工具
|
||||
|
||||
本对话启用了工具动态加载:下列工具默认未加载。需要使用某类能力时,先调用 load_tools(tool_names 参数,一次可传多个工具名)获取完整工具定义,之后即可像普通工具一样直接调用。已加载的工具在本对话中持续可用,无需重复加载;未加载就直接调用会被拒绝并返回引导提示。
|
||||
|
||||
{tool_catalog}
|
||||
|
||||
使用建议:
|
||||
- 用户意图明确属于某个类目时,先一次性加载该类目所需的全部工具再行动,避免边做边停
|
||||
- load_tools 只接受上方列出的工具名;上方未列出的工具均已默认加载,无需处理
|
||||
@ -34,6 +34,7 @@ from modules.upload_security import UploadSecurityError
|
||||
from modules.host_sandbox_policy import load_policy, save_policy
|
||||
from modules.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(
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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}
|
||||
|
||||
|
||||
@ -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 "."),
|
||||
|
||||
@ -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 = '<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 ?? '';
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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:',
|
||||
|
||||
@ -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: '图片压缩',
|
||||
|
||||
@ -105,6 +105,10 @@ export default {
|
||||
userAnswer: '用户回答:',
|
||||
subAgent: '子智能体:',
|
||||
subAgentId: '子智能体 ID:',
|
||||
requestedTools: '请求加载:',
|
||||
loadedNow: '新加载:',
|
||||
alreadyLoaded: '此前已加载:',
|
||||
unavailableTools: '不可用:',
|
||||
deliverablesDir: '交付目录:',
|
||||
task: '任务:',
|
||||
workTime: '工作时间:',
|
||||
|
||||
@ -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 } = {}) => ({
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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
167
test/test_tool_loading.py
Normal file
@ -0,0 +1,167 @@
|
||||
"""工具动态加载(core.tool_loading + personalization 字段)单元测试。
|
||||
|
||||
设计文档:docs/dynamic_tool_loading_plan.md
|
||||
运行:.venv/bin/python -m pytest test/test_tool_loading.py -q
|
||||
无 pytest 时:
|
||||
.venv/bin/python -c "import sys; sys.path.insert(0, '.'); import unittest; \\
|
||||
s = unittest.TestLoader().discover('test', pattern='test_tool_loading.py'); \\
|
||||
r = unittest.TextTestRunner().run(s); sys.exit(0 if r.wasSuccessful() else 1)"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from core import tool_loading as tl # noqa: E402
|
||||
|
||||
|
||||
class RegistryIntegrityTest(unittest.TestCase):
|
||||
def test_registry_has_expected_shape(self):
|
||||
names = tl.deferrable_tool_names()
|
||||
self.assertEqual(len(names), 18)
|
||||
self.assertEqual(len(set(names)), len(names), "注册表内工具名重复")
|
||||
for cat in tl.DEFERRABLE_REGISTRY.values():
|
||||
self.assertTrue(cat.get("label"))
|
||||
self.assertTrue(cat.get("when_to_use"))
|
||||
self.assertTrue(cat.get("tools"))
|
||||
|
||||
def test_every_name_has_category(self):
|
||||
for name in tl.deferrable_tool_names():
|
||||
self.assertIsNotNone(tl.category_of_tool(name), name)
|
||||
self.assertIsNone(tl.category_of_tool("read_file"))
|
||||
self.assertIsNone(tl.category_of_tool("no_such_tool"))
|
||||
|
||||
def test_registry_payload(self):
|
||||
payload = tl.build_registry_payload()
|
||||
self.assertEqual(len(payload), len(tl.DEFERRABLE_REGISTRY))
|
||||
total = sum(len(item["tools"]) for item in payload)
|
||||
self.assertEqual(total, 18)
|
||||
|
||||
|
||||
class SnapshotTest(unittest.TestCase):
|
||||
def test_default_defers_everything(self):
|
||||
snap = tl.build_snapshot(None)
|
||||
self.assertTrue(snap["enabled"])
|
||||
self.assertEqual(snap["deferred_set"], tl.deferrable_tool_names())
|
||||
self.assertEqual(snap["loaded"], [])
|
||||
self.assertEqual(snap["pending"], snap["deferred_set"])
|
||||
|
||||
def test_partial_and_invalid_names_clamped(self):
|
||||
snap = tl.build_snapshot(["activate_workflow", "bogus_tool", "save_workflow"])
|
||||
self.assertEqual(snap["deferred_set"], ["activate_workflow", "save_workflow"])
|
||||
|
||||
def test_explicit_empty_defers_nothing(self):
|
||||
snap = tl.build_snapshot([])
|
||||
self.assertEqual(snap["deferred_set"], [])
|
||||
self.assertEqual(snap["pending"], [])
|
||||
|
||||
def test_initial_exposed_recorded_when_built_names_given(self):
|
||||
snap = tl.build_snapshot(
|
||||
["activate_workflow"],
|
||||
built_tool_names=["read_file", "activate_workflow", "web_search"],
|
||||
)
|
||||
self.assertNotIn("activate_workflow", snap["initial_exposed"])
|
||||
self.assertIn("read_file", snap["initial_exposed"])
|
||||
self.assertIn(tl.LOAD_TOOLS_NAME, snap["initial_exposed"])
|
||||
|
||||
def test_overrides_from_prefs(self):
|
||||
self.assertEqual(tl.snapshot_overrides_from_prefs({}, multi_agent_mode=True), {})
|
||||
self.assertEqual(tl.snapshot_overrides_from_prefs({"tool_loading_enabled": False}), {})
|
||||
overrides = tl.snapshot_overrides_from_prefs({})
|
||||
self.assertIn(tl.METADATA_KEY, overrides)
|
||||
self.assertTrue(overrides[tl.METADATA_KEY]["enabled"])
|
||||
|
||||
|
||||
class StateParsingTest(unittest.TestCase):
|
||||
def test_missing_or_disabled_returns_none(self):
|
||||
self.assertIsNone(tl.get_tool_loading_state(None))
|
||||
self.assertIsNone(tl.get_tool_loading_state({}))
|
||||
self.assertIsNone(tl.get_tool_loading_state({"tool_loading": "garbage"}))
|
||||
self.assertIsNone(tl.get_tool_loading_state({"tool_loading": {"enabled": False}}))
|
||||
|
||||
def test_normalization_recomputes_pending_and_drops_ghosts(self):
|
||||
snap = tl.build_snapshot(None)
|
||||
snap["loaded"] = ["activate_workflow", "ghost_tool"]
|
||||
snap["pending"] = ["wrong"]
|
||||
state = tl.get_tool_loading_state({tl.METADATA_KEY: snap})
|
||||
self.assertIsNotNone(state)
|
||||
self.assertEqual(state["loaded"], ["activate_workflow"])
|
||||
self.assertNotIn("activate_workflow", state["pending"])
|
||||
self.assertIn("save_workflow", state["pending"])
|
||||
|
||||
def test_guard_decision(self):
|
||||
state = tl.get_tool_loading_state({tl.METADATA_KEY: tl.build_snapshot(None)})
|
||||
self.assertTrue(tl.is_deferred_not_loaded(state, "save_workflow"))
|
||||
self.assertFalse(tl.is_deferred_not_loaded(state, "read_file"))
|
||||
self.assertFalse(tl.is_deferred_not_loaded(None, "save_workflow"))
|
||||
loaded = tl.mark_tools_loaded(state, ["save_workflow"])
|
||||
self.assertFalse(tl.is_deferred_not_loaded(loaded, "save_workflow"))
|
||||
|
||||
|
||||
class StateTransitionTest(unittest.TestCase):
|
||||
def test_mark_loaded_is_idempotent(self):
|
||||
state = tl.get_tool_loading_state({tl.METADATA_KEY: tl.build_snapshot(None)})
|
||||
once = tl.mark_tools_loaded(state, ["save_workflow"])
|
||||
twice = tl.mark_tools_loaded(once, ["save_workflow", "list_workflows"])
|
||||
self.assertEqual(twice["loaded"].count("save_workflow"), 1)
|
||||
self.assertNotIn("list_workflows", twice["pending"])
|
||||
|
||||
def test_compression_reset(self):
|
||||
state = tl.get_tool_loading_state({tl.METADATA_KEY: tl.build_snapshot(None)})
|
||||
loaded = tl.mark_tools_loaded(state, ["save_workflow", "list_workflows"])
|
||||
reset = tl.reset_state_after_compression(loaded)
|
||||
self.assertEqual(reset["loaded"], [])
|
||||
self.assertEqual(reset["pending"], reset["deferred_set"])
|
||||
self.assertEqual(reset["initial_exposed"], loaded["initial_exposed"])
|
||||
|
||||
|
||||
class CatalogRenderTest(unittest.TestCase):
|
||||
def test_subset_and_empty_categories_omitted(self):
|
||||
catalog = tl.render_catalog(["activate_workflow", "create_sub_agent"])
|
||||
self.assertIn("工作流", catalog)
|
||||
self.assertIn("子智能体", catalog)
|
||||
self.assertNotIn("对话回顾", catalog)
|
||||
self.assertNotIn("彩蛋", catalog)
|
||||
|
||||
def test_unavailable_excluded(self):
|
||||
catalog = tl.render_catalog(["activate_workflow"], unavailable={"activate_workflow"})
|
||||
self.assertEqual(catalog, "")
|
||||
|
||||
def test_load_tools_definition(self):
|
||||
definition = tl.build_load_tools_definition()
|
||||
fn = definition["function"]
|
||||
self.assertEqual(fn["name"], "load_tools")
|
||||
self.assertEqual(fn["parameters"]["required"], ["tool_names"])
|
||||
|
||||
|
||||
class PersonalizationFieldsTest(unittest.TestCase):
|
||||
def test_sanitize_defaults(self):
|
||||
from modules.personalization_manager import sanitize_personalization_payload
|
||||
|
||||
result = sanitize_personalization_payload({})
|
||||
self.assertTrue(result["tool_loading_enabled"])
|
||||
self.assertEqual(len(result["tool_loading_deferred"]), 18)
|
||||
|
||||
def test_sanitize_explicit_values(self):
|
||||
from modules.personalization_manager import sanitize_personalization_payload
|
||||
|
||||
result = sanitize_personalization_payload({
|
||||
"tool_loading_enabled": False,
|
||||
"tool_loading_deferred": ["activate_workflow", "bogus", 123],
|
||||
})
|
||||
self.assertFalse(result["tool_loading_enabled"])
|
||||
self.assertEqual(result["tool_loading_deferred"], ["activate_workflow"])
|
||||
|
||||
def test_sanitize_empty_list_means_defer_nothing(self):
|
||||
from modules.personalization_manager import sanitize_personalization_payload
|
||||
|
||||
result = sanitize_personalization_payload({"tool_loading_deferred": []})
|
||||
self.assertEqual(result["tool_loading_deferred"], [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@ -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 {}),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user