feat(toolset): 工具系统重构为 toolset 统一管理,新增系统预置工具集

将工具管理从"agent 挂单个 tool"改为"agent 挂 toolset"模式:
- 三个系统预置工具集(system_basic/system_chat/system_workflow)入 DB
- 新增 send_file 工具(系统对话工具集)、修复 approval actor 调用 bug
- 后端 agent 加载全部走 toolset 链路,移除 load_tools_from_list
- 前端工具集中心卡片展示 + agent 配置改为 toolset 多选
- resource API 增加 category 过滤与系统 toolset 保护

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 18:03:49 +00:00
parent d39c80743d
commit 0e57c5cf16
23 changed files with 584 additions and 169 deletions
@@ -112,6 +112,7 @@ class GlobalStateMachine:
for scope, name_to_cls in tm.tool_mapper.items()
},
system_tools_by_scope=system_tools_by_scope,
all_funcs=dict(tm._all_funcs),
)
def _publish_snapshot(self) -> None:
@@ -270,22 +271,31 @@ class GlobalStateMachine:
tools: List[str],
description: Optional[str] = None,
owner_id: Optional[str] = None,
is_system: bool = False,
category: str = "user",
) -> Dict[str, Any]:
"""新增/更新一个自定义工具组:仅允许引用非 system/非 mcp 的工具。"""
# 校验:只能放第三方(非 system / 非 mcp)工具
invalid = [
t for t in tools if not self._global_tool_manager.is_third_party_tool(t)
]
if invalid:
raise ValueError(
f"自定义工具组只允许包含第三方工具,以下不合法:{invalid}"
)
"""新增/更新一个工具集。
- 系统 toolset``is_system=True``)允许包含 system 工具,由启动期种子写入;
API 调用方一般不应直接传 ``is_system=True``
- 用户 toolset``is_system=False``)只允许引用非 system / 非 mcp 的工具
"""
if not is_system:
invalid = [
t for t in tools if not self._global_tool_manager.is_third_party_tool(t)
]
if invalid:
raise ValueError(
f"用户工具集只允许包含第三方工具,以下不合法:{invalid}"
)
saved = await self.postgres_database.upsert_custom_toolset.remote(
toolset_id=toolset_id,
name=name,
tools=list(tools),
description=description,
owner_id=owner_id,
is_system=is_system,
category=category,
)
self._custom_toolsets[toolset_id] = saved
self._global_tool_manager.rebuild_custom_toolsets(self._custom_toolsets)
@@ -68,6 +68,8 @@ class GSMSnapshot:
# 客户端按名字 + ``tool_funcs`` 在自己进程里重建 FunctionToolset
# 避开把不可序列化/版本耦合的 toolset 实例塞进快照的坑。
system_tools_by_scope: Dict[str, List[str]] = field(default_factory=dict)
# 全部插件函数(system + 第三方),用于 toolset 装配时统一查表。
all_funcs: Dict[str, Callable[..., Any]] = field(default_factory=dict)
_local_cache: Dict[str, Any] = {"version": -1, "snapshot": None}
@@ -141,18 +143,22 @@ def reset_local_cache() -> None:
def build_toolsets_for_scope(
snapshot: GSMSnapshot, scope: str
snapshot: GSMSnapshot,
scope: str,
toolset_ids: Optional[List[str]] = None,
) -> List[Any]:
"""在调用方进程里按 ``snapshot`` 现场组装 FunctionToolset 列表。
复刻 ``GlobalToolManager.get_toolsets_for_scope`` 的合并逻辑:
新模型下"系统工具集"也存在 ``custom_toolsets`` 里(``is_system=True``),
所以本函数只按 ``toolset_ids`` 在 ``custom_toolsets`` 中按需挑选并装配;
所有工具函数(system + 第三方)都从 ``snapshot.all_funcs`` 统一查表。
- 系统 toolset:按 ``default`` + ``scope`` 两个 bucket 拼装
- 自定义 toolset``custom_toolsets`` 里所有有效项
返回的 toolset 是 *进程局部* 的——pydantic-ai FunctionToolset 实例不能跨进程
共享,但函数对象本身已经躺在 snapshot 里被 cloudpickle 还原过,
重新 ``FunctionToolset(tools=[...])`` 几乎零代价
Args:
snapshot: 当前 GSM 快照。
scope: 调用方所属 scope(保留参数:未来可按 scope 过滤系统 toolset
的可见性,目前仅用于命名/日志)。
toolset_ids: agent 配置的 toolset 列表;为 None 表示返回全部 toolset
(兼容老调用,但建议传入显式列表)
"""
try:
from pydantic_ai.toolsets import FunctionToolset
@@ -161,32 +167,24 @@ def build_toolsets_for_scope(
return []
result: List[Any] = []
for bucket in ("default", scope):
names = snapshot.system_tools_by_scope.get(bucket) or []
funcs = [snapshot.tool_funcs[n] for n in names if n in snapshot.tool_funcs]
if not funcs:
target_ids = (
list(toolset_ids)
if toolset_ids is not None
else list(snapshot.custom_toolsets.keys())
)
for toolset_id in target_ids:
defn = snapshot.custom_toolsets.get(toolset_id)
if not defn:
continue
try:
result.append(
FunctionToolset(tools=funcs, id=f"system::{bucket}")
)
except Exception as e: # pragma: no cover - 防御
_logger.error(f"build system toolset {bucket} failed: {e}")
for toolset_id, defn in snapshot.custom_toolsets.items():
names = defn.get("tools") or []
funcs = [
snapshot.third_party_funcs[n]
for n in names
if n in snapshot.third_party_funcs
]
funcs = [snapshot.all_funcs[n] for n in names if n in snapshot.all_funcs]
if not funcs:
continue
try:
result.append(
FunctionToolset(tools=funcs, id=f"custom::{toolset_id}")
FunctionToolset(tools=funcs, id=f"toolset::{toolset_id}")
)
except Exception as e: # pragma: no cover - 防御
_logger.error(f"build custom toolset {toolset_id} failed: {e}")
_logger.error(f"build toolset {toolset_id} failed: {e}")
return result
@@ -29,6 +29,7 @@ class GlobalToolManager:
_system_toolsets: Dict[str, Any]
_custom_toolsets: Dict[str, Any]
_third_party_funcs: Dict[str, Callable]
_all_funcs: Dict[str, Callable]
tool_mapper: Dict[str, Dict[str, Type[BaseToolData]]]
def __init__(self) -> None:
@@ -39,6 +40,7 @@ class GlobalToolManager:
self._retrieval_toolsets = {}
self._custom_toolsets = {}
self._third_party_funcs = {}
self._all_funcs = {}
self.tool_mapper = defaultdict(dict)
tool_plugin_dir = (
@@ -91,6 +93,8 @@ class GlobalToolManager:
if category == "mcp":
continue
self._all_funcs[plugin_name] = tool_func
scopes = [s for s in action_scopes if s] or ["default"]
if is_system:
@@ -138,7 +142,11 @@ class GlobalToolManager:
logger.error(f"Failed to build retrieval toolset {scope}: {e}")
def rebuild_custom_toolsets(self, custom_defs: Dict[str, Dict[str, Any]]) -> None:
"""根据 DB 中的自定义工具组定义重建 custom FunctionToolset。"""
"""根据 DB 中的 toolset 定义重建 FunctionToolset。
系统 toolsetis_system=True)允许包含 system 工具,用户 toolset 只取得到 callable
的工具(理论上业务层已校验只包含第三方工具)。
"""
FunctionToolset = self._import_function_toolset()
if FunctionToolset is None:
self._custom_toolsets = {}
@@ -146,22 +154,21 @@ class GlobalToolManager:
new_map: Dict[str, Any] = {}
for toolset_id, defn in custom_defs.items():
tools_names = defn.get("tools") or []
funcs = [
self._third_party_funcs[n]
for n in tools_names
if n in self._third_party_funcs
]
funcs = [self._all_funcs[n] for n in tools_names if n in self._all_funcs]
if not funcs:
continue
try:
new_map[toolset_id] = FunctionToolset(
tools=funcs,
id=f"custom::{toolset_id}",
id=f"toolset::{toolset_id}",
)
except Exception as e:
logger.error(f"Failed to build custom toolset {toolset_id}: {e}")
logger.error(f"Failed to build toolset {toolset_id}: {e}")
self._custom_toolsets = new_map
def get_toolset_by_id(self, toolset_id: str) -> Any | None:
return self._custom_toolsets.get(toolset_id)
@staticmethod
def _import_function_toolset():
try: