- 新增 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)
323 lines
12 KiB
Python
323 lines
12 KiB
Python
"""工具动态加载(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)
|