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:
@@ -45,14 +45,12 @@ class ConsciousnessNode:
global_state_machine: GlobalStateMachine,
provider_title: str,
model_id: str,
tools_list: list[str] = None,
toolsets=None,
locale: str | None = None,
custom_system_prompt: str | None = None,
) -> None:
system_prompt: str = agent_prompt("consciousness_node", locale=locale, custom_system_prompt=custom_system_prompt)
output_type = Union[ForregulatoryNode, ForWorkflow, ForWorkflowEngine]
from kilostar.utils.get_tool import load_tools_from_list
from kilostar.core.global_state_machine.gsm_snapshot import fetch_snapshot
snapshot = await fetch_snapshot(gsm_actor=global_state_machine)
@@ -62,7 +60,6 @@ class ConsciousnessNode:
raise ValueError(t("provider_not_registered", locale=locale, provider_title=provider_title))
agent_factory = AgentFactory()
callables = load_tools_from_list(tools_list)
self.agent = agent_factory.create_agent(
provider=provider,
model_id=model_id,
@@ -70,7 +67,6 @@ class ConsciousnessNode:
system_prompt=system_prompt,
deps_type=ConsciousnessNodeDeps,
agent_name="consciousness_node",
tools=callables,
toolsets=toolsets,
)
@@ -47,7 +47,6 @@ class RegulatoryNode:
global_state_machine: GlobalStateMachine,
provider_title: str,
model_id: str,
tools_list: list[str] = None,
toolsets=None,
locale: str | None = None,
custom_system_prompt: str | None = None,
@@ -60,7 +59,7 @@ class RegulatoryNode:
global_state_machine: 全局状态机
provider_title: 供应商名
model_id: 模型id
tools_list: 工具列表
toolsets: 已装配好的 FunctionToolset 列表
locale: 语言代码(zh/en),控制system prompt语言
custom_system_prompt: 管理员自定义追加提示词(可选)
Returns:
@@ -68,7 +67,6 @@ class RegulatoryNode:
"""
system_prompt: str = agent_prompt("regulatory_node", locale=locale, custom_system_prompt=custom_system_prompt)
output_type = Union[MessageResponse]
from kilostar.utils.get_tool import load_tools_from_list
from kilostar.core.global_state_machine.gsm_snapshot import fetch_snapshot
# 走 Object Store 快照而不是 actor RPC:高频读路径不再受单 actor 串行限制
@@ -79,7 +77,6 @@ class RegulatoryNode:
raise ValueError(t("provider_not_registered", locale=locale, provider_title=provider_title))
agent_factory = AgentFactory()
callables = load_tools_from_list(tools_list)
self.agent = agent_factory.create_agent(
provider=provider,
model_id=model_id,
@@ -87,7 +84,6 @@ class RegulatoryNode:
system_prompt=system_prompt,
deps_type=RegulatoryNodeDeps,
agent_name="regulatory_node",
tools=callables,
toolsets=toolsets,
)
@@ -1,7 +1,7 @@
from datetime import datetime
from typing import List, Optional
from sqlalchemy import String, Text, DateTime, func
from sqlalchemy import String, Text, DateTime, Boolean, func, text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
@@ -9,10 +9,12 @@ from .base import BaseDataModel
class CustomToolsetModel(BaseDataModel):
"""用户自定义工具:把若干个非 system / 非 mcp 的工具插件打包成一个 toolset。
"""工具:把若干个工具插件打包成一个 toolset。
``tools`` 字段保存工具名列表(即 ``plugin/tool_plugin/`` 下的目录名);
GSM 启动时按列表把对应工具函数装进同一个 ``FunctionToolset``
系统预置工具集(is_system=True)由启动期种子写入,前端不允许修改/删除。
用户自定义工具集(is_system=False)只能包含第三方工具
``tools`` 字段保存工具名列表(即 ``plugin/tool_plugin/`` 下的目录名)。
"""
__tablename__ = "custom_toolset"
@@ -22,7 +24,23 @@ class CustomToolsetModel(BaseDataModel):
description: Mapped[Optional[str]] = mapped_column(Text)
owner_id: Mapped[Optional[str]] = mapped_column(String(64), index=True)
tools: Mapped[List[str]] = mapped_column(
JSONB, default=list, comment="工具名列表,仅允许非 system/非 mcp 的工具"
JSONB, default=list, comment="工具名列表"
)
is_system: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=False,
server_default=text("false"),
index=True,
comment="是否系统预置工具集(不可修改/删除)",
)
category: Mapped[str] = mapped_column(
String(32),
nullable=False,
default="user",
server_default=text("'user'"),
index=True,
comment="分类:system_basic/system_chat/system_workflow/user",
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
@@ -33,7 +33,7 @@ class SystemNodeConfigModel(BaseDataModel):
provider_title: Mapped[str] = mapped_column(String(50), nullable=False)
model_id: Mapped[str] = mapped_column(String(100), nullable=False)
tools: Mapped[Optional[List[str]]] = mapped_column(
JSONB, default=list, comment="节点可调用的工具标识列表"
JSONB, default=list, comment="节点挂载的工具集 ID 列表(custom_toolset.toolset_id"
)
persona_id: Mapped[Optional[str]] = mapped_column(
ForeignKey("persona_template.template_id", ondelete="SET NULL"),
@@ -7,7 +7,7 @@ from kilostar.core.postgres_database.model.custom_toolset import CustomToolsetMo
class CustomToolsetDatabase:
"""用户自定义工具 DAO。``tools`` 字段是工具名列表,业务层负责保证只放非 system/非 mcp 的工具"""
"""工具 DAO。包含系统预置 toolsetis_system=True)和用户自定义 toolset"""
def __init__(self, async_session_maker):
self.async_session_maker = async_session_maker
@@ -20,6 +20,8 @@ class CustomToolsetDatabase:
"description": row.description,
"owner_id": row.owner_id,
"tools": list(row.tools or []),
"is_system": bool(row.is_system),
"category": row.category,
}
@database_exception
@@ -30,6 +32,8 @@ class CustomToolsetDatabase:
tools: List[str],
description: Optional[str] = None,
owner_id: Optional[str] = None,
is_system: bool = False,
category: str = "user",
) -> Dict[str, Any]:
async with self.async_session_maker() as session:
stmt = select(CustomToolsetModel).where(
@@ -41,6 +45,8 @@ class CustomToolsetDatabase:
row.description = description
row.owner_id = owner_id
row.tools = list(tools)
row.is_system = is_system
row.category = category
else:
row = CustomToolsetModel(
toolset_id=toolset_id,
@@ -48,6 +54,8 @@ class CustomToolsetDatabase:
description=description,
owner_id=owner_id,
tools=list(tools),
is_system=is_system,
category=category,
)
session.add(row)
await session.commit()