From 0e57c5cf16f47b99328d3455f858888f8acd64ec Mon Sep 17 00:00:00 2001 From: zhaoxi Date: Fri, 5 Jun 2026 18:03:49 +0000 Subject: [PATCH] =?UTF-8?q?feat(toolset):=20=E5=B7=A5=E5=85=B7=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E9=87=8D=E6=9E=84=E4=B8=BA=20toolset=20=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E7=AE=A1=E7=90=86=EF=BC=8C=E6=96=B0=E5=A2=9E=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E9=A2=84=E7=BD=AE=E5=B7=A5=E5=85=B7=E9=9B=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将工具管理从"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 --- ...06_05_0001-0008_toolset_system_refactor.py | 149 +++++++++++++ .../Agent/WorkerIndividualSettings.tsx | 54 +++-- .../src/components/Plugin/ToolSettings.tsx | 197 ++++++++++++++---- frontend/src/i18n/locales/en.json | 16 +- frontend/src/i18n/locales/zh.json | 14 +- kilostar/api/agent.py | 16 +- kilostar/api/resource.py | 16 +- .../global_state_machine.py | 28 ++- .../core/global_state_machine/gsm_snapshot.py | 52 +++-- .../core/global_state_machine/tool_manager.py | 23 +- .../consciousness_node/consciousness_node.py | 4 - .../regulatory_node/regulatory_node.py | 6 +- .../postgres_database/model/custom_toolset.py | 28 ++- .../postgres_database/model/system_node.py | 2 +- .../module/custom_toolset.py | 10 +- .../plugin/tool_plugin/approval/approval.py | 6 +- .../plugin/tool_plugin/send_file/__init__.py | 17 ++ .../plugin/tool_plugin/send_file/send_file.py | 60 ++++++ kilostar/utils/mcp_helper.py | 15 +- kilostar/worker_individual/base_individual.py | 16 +- tests/unit/test_api_agent_template.py | 4 +- tests/unit/test_gsm_snapshot.py | 18 +- tests/unit/test_utils_mcp_helper.py | 2 +- 23 files changed, 584 insertions(+), 169 deletions(-) create mode 100644 alembic/versions/2026_06_05_0001-0008_toolset_system_refactor.py create mode 100644 kilostar/plugin/tool_plugin/send_file/__init__.py create mode 100644 kilostar/plugin/tool_plugin/send_file/send_file.py diff --git a/alembic/versions/2026_06_05_0001-0008_toolset_system_refactor.py b/alembic/versions/2026_06_05_0001-0008_toolset_system_refactor.py new file mode 100644 index 0000000..99da5c3 --- /dev/null +++ b/alembic/versions/2026_06_05_0001-0008_toolset_system_refactor.py @@ -0,0 +1,149 @@ +"""toolset system refactor: add is_system/category to custom_toolset, seed system toolsets, migrate node tools to toolset_ids + +Revision ID: 0008 +Revises: 0007 +Create Date: 2026-06-05 +""" + +from alembic import op +import sqlalchemy as sa +from ulid import ULID + +revision = "0008" +down_revision = "0007" +branch_labels = None +depends_on = None + + +# 系统预置 toolset 的固定 ID(前端可能引用) +SYSTEM_TOOLSETS = [ + { + "toolset_id": "system_basic", + "name": "系统基础工具集", + "description": "文件读写、搜索、Python/Shell 执行等基础能力", + "category": "system_basic", + "tools": [ + "file_reader", + "write_file", + "edit_file", + "search_file", + "python_executor", + "shell_executor", + ], + }, + { + "toolset_id": "system_chat", + "name": "系统对话工具集", + "description": "对话场景专用工具,例如向用户发送文件附件", + "category": "system_chat", + "tools": ["send_file"], + }, + { + "toolset_id": "system_workflow", + "name": "系统工作流工具集", + "description": "工作流场景专用工具,例如人工审批", + "category": "system_workflow", + "tools": ["approval"], + }, +] + + +def upgrade() -> None: + # 1. 加 is_system / category 列 + op.add_column( + "custom_toolset", + sa.Column( + "is_system", + sa.Boolean(), + nullable=False, + server_default=sa.text("false"), + ), + ) + op.add_column( + "custom_toolset", + sa.Column( + "category", + sa.String(32), + nullable=False, + server_default=sa.text("'user'"), + ), + ) + op.create_index( + "ix_custom_toolset_is_system", "custom_toolset", ["is_system"] + ) + op.create_index("ix_custom_toolset_category", "custom_toolset", ["category"]) + + # 2. 种子三个系统工具集 + conn = op.get_bind() + for ts in SYSTEM_TOOLSETS: + conn.execute( + sa.text( + """ + INSERT INTO custom_toolset + (toolset_id, name, description, owner_id, tools, is_system, category) + VALUES (:tid, :name, :desc, NULL, CAST(:tools AS JSONB), true, :cat) + ON CONFLICT (toolset_id) DO NOTHING + """ + ), + { + "tid": ts["toolset_id"], + "name": ts["name"], + "desc": ts["description"], + "tools": _json_dump(ts["tools"]), + "cat": ts["category"], + }, + ) + + # 3. 迁移 system_node_config.tools:旧值是 tool_name 列表,按工具名归属推断 toolset_id + _migrate_tools_to_toolsets(conn, "system_node_config", "node_name") + # 4. 同样迁移 specialist_individual / ordinary_individual + _migrate_tools_to_toolsets(conn, "specialist_individual", "agent_id") + _migrate_tools_to_toolsets(conn, "ordinary_individual", "agent_id") + + +def downgrade() -> None: + op.drop_index("ix_custom_toolset_category", table_name="custom_toolset") + op.drop_index("ix_custom_toolset_is_system", table_name="custom_toolset") + op.drop_column("custom_toolset", "category") + op.drop_column("custom_toolset", "is_system") + # 注意:tools 字段语义变更不可逆——保留 toolset_ids,不还原 + + +def _json_dump(value) -> str: + import json + + return json.dumps(value, ensure_ascii=False) + + +_TOOL_TO_TOOLSET = { + "file_reader": "system_basic", + "write_file": "system_basic", + "edit_file": "system_basic", + "search_file": "system_basic", + "python_executor": "system_basic", + "shell_executor": "system_basic", + "send_file": "system_chat", + "approval": "system_workflow", +} + + +def _migrate_tools_to_toolsets(conn, table: str, pk_col: str) -> None: + """把表里 ``tools`` 字段(旧:tool_name 列表)转换为 toolset_id 列表。 + + 第三方/未知工具名直接丢弃(这些应该由用户自定义的 toolset 承载,迁移期不识别)。 + """ + rows = conn.execute( + sa.text(f"SELECT {pk_col}, tools FROM {table} WHERE tools IS NOT NULL") + ).fetchall() + for pk, old_tools in rows: + if not old_tools: + continue + toolset_ids = sorted({ + _TOOL_TO_TOOLSET[t] for t in old_tools if t in _TOOL_TO_TOOLSET + }) + conn.execute( + sa.text( + f"UPDATE {table} SET tools = CAST(:val AS JSONB) WHERE {pk_col} = :pk" + ), + {"val": _json_dump(list(toolset_ids)), "pk": pk}, + ) diff --git a/frontend/src/components/Agent/WorkerIndividualSettings.tsx b/frontend/src/components/Agent/WorkerIndividualSettings.tsx index 16aefc2..182f88d 100644 --- a/frontend/src/components/Agent/WorkerIndividualSettings.tsx +++ b/frontend/src/components/Agent/WorkerIndividualSettings.tsx @@ -15,7 +15,7 @@ interface WorkerIndividual { output_template?: string; bound_skill?: string; workspace?: string; - tools?: string; + toolsets?: string; } interface PersonaTemplate { @@ -24,6 +24,15 @@ interface PersonaTemplate { system_prompt: string; } +interface ToolsetItem { + toolset_id: string; + name: string; + description?: string; + tools: string[]; + is_system: boolean; + category: string; +} + export function WorkerIndividualSettings() { const { t } = useTranslation(); const [providers, setProviders] = useState([]); @@ -31,7 +40,7 @@ export function WorkerIndividualSettings() { const [systemNodes, setSystemNodes] = useState([]); const [personaTemplates, setPersonaTemplates] = useState([]); const [availableSkills, setAvailableSkills] = useState([]); - const [availableTools, setAvailableTools] = useState([]); + const [availableToolsets, setAvailableToolsets] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [isEditing, setIsEditing] = useState(false); @@ -43,17 +52,17 @@ export function WorkerIndividualSettings() { const fetchData = async () => { setLoading(true); try { - const [provRes, workRes, sysRes, toolsRes, skillsRes, tplRes] = await Promise.all([ + const [provRes, workRes, sysRes, toolsetRes, skillsRes, tplRes] = await Promise.all([ apiClient.get('/api/v1/provider/list'), apiClient.get('/api/v1/agent/worker'), apiClient.get('/api/v1/agent'), - apiClient.get('/api/v1/resource/tool'), + apiClient.get('/api/v1/resource/custom-toolset'), apiClient.get('/api/v1/resource/skill'), apiClient.get('/api/v1/agent/template') ]); setProviders(Object.values(provRes.data.provider_list || {})); setWorkers(workRes.data.workers || []); - setAvailableTools(toolsRes.data.tools || []); + setAvailableToolsets(toolsetRes.data.toolsets || []); setAvailableSkills(Object.keys(skillsRes.data.skills || {})); setPersonaTemplates(tplRes.data.templates || []); @@ -69,7 +78,7 @@ export function WorkerIndividualSettings() { display_name: found?.display_name || '', provider_title: found?.provider_title || defaultProvider, model_id: found?.model_id || '', - tools: found?.tools ? JSON.stringify(found.tools) : '[]', + toolsets: found?.tools ? JSON.stringify(found.tools) : '[]', persona_id: found?.persona_id || '', is_system: true }; @@ -89,7 +98,7 @@ export function WorkerIndividualSettings() { output_template: typeof worker.output_template === 'string' ? worker.output_template : JSON.stringify(worker.output_template || {}), bound_skill: typeof worker.bound_skill === 'string' ? worker.bound_skill : JSON.stringify(worker.bound_skill || {}), workspace: typeof worker.workspace === 'string' ? worker.workspace : JSON.stringify(worker.workspace || []), - tools: typeof worker.tools === 'string' ? worker.tools : JSON.stringify(worker.tools || []) + toolsets: typeof worker.toolsets === 'string' ? worker.toolsets : JSON.stringify(worker.toolsets || worker.tools || []) }); setIsNew(false); setIsEditing(true); @@ -99,7 +108,7 @@ export function WorkerIndividualSettings() { const handleAddNew = () => { setEditData({ agent_name: '', agent_type: 'ordinary_individual', description: '', provider_title: providers.length > 0 ? providers[0].provider_title : '', model_id: '', - persona_id: '', output_template: '{}', bound_skill: '{}', workspace: '[]', tools: '[]' }); + persona_id: '', output_template: '{}', bound_skill: '{}', workspace: '[]', toolsets: '[]' }); setIsNew(true); setIsEditing(true); setModalMessage(''); @@ -120,7 +129,7 @@ export function WorkerIndividualSettings() { individual_name: editData.agent_name, provider_title: editData.provider_title, model_id: editData.model_id, - tools: JSON.parse(editData.tools || '[]'), + toolsets: JSON.parse(editData.toolsets || '[]'), persona_id: (editData as any).persona_id || null, display_name: (editData as any).display_name || null }); @@ -130,7 +139,7 @@ export function WorkerIndividualSettings() { output_template: JSON.parse(editData.output_template || '{}'), bound_skill: JSON.parse(editData.bound_skill || '{}'), workspace: JSON.parse(editData.workspace || '[]'), - tools: JSON.parse(editData.tools || '[]') + toolsets: JSON.parse(editData.toolsets || '[]') }; if (isNew) await apiClient.post('/api/v1/agent/worker', payload); else await apiClient.put(`/api/v1/agent/worker/${editData.agent_id}`, payload); @@ -336,23 +345,24 @@ export function WorkerIndividualSettings() { )}
- -
- {availableTools.map(tool => { - let currentTools: string[] = []; - try { currentTools = JSON.parse(editData.tools || '[]'); } catch { } - const isSelected = currentTools.includes(tool); + +
+ {availableToolsets.map(ts => { + let currentToolsets: string[] = []; + try { currentToolsets = JSON.parse(editData.toolsets || '[]'); } catch { } + const isSelected = currentToolsets.includes(ts.toolset_id); return ( - ); })} - {availableTools.length === 0 && {t('agent.noTools')}} + {availableToolsets.length === 0 && {t('plugin.toolsetEmpty')}}
{modalMessage &&
{modalMessage}
} diff --git a/frontend/src/components/Plugin/ToolSettings.tsx b/frontend/src/components/Plugin/ToolSettings.tsx index f332836..fdfe036 100644 --- a/frontend/src/components/Plugin/ToolSettings.tsx +++ b/frontend/src/components/Plugin/ToolSettings.tsx @@ -1,69 +1,192 @@ import { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import { Package, Wrench, Loader2, Box } from 'lucide-react'; +import { Package, Wrench, Loader2, Box, Shield, X } from 'lucide-react'; import apiClient from '../../api/client'; +interface Toolset { + toolset_id: string; + name: string; + description?: string; + tools: string[]; + is_system: boolean; + category: string; +} + export function ToolSettings() { const { t } = useTranslation(); - const [tools, setTools] = useState([]); + const [toolsets, setToolsets] = useState([]); const [loading, setLoading] = useState(true); + const [selected, setSelected] = useState(null); - useEffect(() => { fetchTools(); }, []); + useEffect(() => { fetchToolsets(); }, []); - const fetchTools = async () => { + const fetchToolsets = async () => { try { setLoading(true); - const response = await apiClient.get('/api/v1/resource/tool'); - setTools(response.data.tools || []); + const res = await apiClient.get('/api/v1/resource/custom-toolset'); + setToolsets(res.data.toolsets || []); } catch (err) { - console.error('Failed to fetch tools:', err); + console.error('Failed to fetch toolsets:', err); } finally { setLoading(false); } }; + const systemToolsets = toolsets.filter(ts => ts.is_system); + const userToolsets = toolsets.filter(ts => !ts.is_system); + return (
-

{t('plugin.toolManagement')}

+

+ {t('plugin.toolManagement')} +

{t('plugin.toolDesc')}

-
-
-
-

{t('plugin.availableTools')}

-

{t('plugin.toolSubDesc')}

-
+ {loading ? ( +
+ + {t('common.loading')}
-
- {loading ? ( -
- - {t('common.loading')} -
- ) : tools.length === 0 ? ( -
- - {t('plugin.noTools')} -
- ) : ( -
- {tools.map((tool) => ( -
-
- -
- {tool} -
- ))} -
+ ) : toolsets.length === 0 ? ( +
+ + {t('plugin.toolsetEmpty')} +
+ ) : ( + <> + {systemToolsets.length > 0 && ( + )} -
+ {userToolsets.length > 0 && ( + + )} + + )} + + {selected && ( + setSelected(null)} /> + )} +
+ ); +} +/* PLACEHOLDER_TOOLSET_GROUP */ + +function ToolsetGroup({ + title, + toolsets, + onSelect, +}: { + title: string; + toolsets: Toolset[]; + onSelect: (ts: Toolset) => void; +}) { + const { t } = useTranslation(); + return ( +
+

+ {title} +

+
+ {toolsets.map((ts) => ( + + ))}
); } +/* PLACEHOLDER_TOOLSET_MODAL */ + +function ToolsetModal({ + toolset, + onClose, +}: { + toolset: Toolset; + onClose: () => void; +}) { + const { t } = useTranslation(); + return ( +
+
e.stopPropagation()} + > +
+
+

+ {toolset.name} +

+ {toolset.description && ( +

+ {toolset.description} +

+ )} +
+ +
+
+

+ {t('plugin.toolsetTools')} +

+ {toolset.tools.map((tool) => ( +
+ + {tool} +
+ ))} +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 8cea60f..35f69de 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -198,8 +198,10 @@ "outputTemplate": "Output Template (JSON)", "boundSkill": "Bound Skill", "workspace": "Workspace (JSON)", - "tools": "Tools", - "noTools": "No tools", + "tools": "Toolsets", + "noTools": "No toolsets", + "toolsets": "Toolsets", + "toolsCount": "tools", "system": "System", "type.ordinary_individual": "Ordinary", "type.skill_individual": "Skill", @@ -224,11 +226,17 @@ "persona": "Persona" }, "plugin": { - "toolManagement": "Tool Management", - "toolDesc": "Manage agent tools and functions", + "toolManagement": "Toolset Center", + "toolDesc": "Manage system and custom toolsets", "availableTools": "Available Tools", "toolSubDesc": "Installed tools for agents", "noTools": "No tools installed yet.", + "systemToolsets": "System Toolsets", + "userToolsets": "My Toolsets", + "toolsetTools": "Included Tools", + "toolsetEmpty": "No toolsets available", + "toolsetSystem": "System", + "toolsetCount": "{{count}} tools", "skillManagement": "Skill Management", "skillDesc": "Manage agent skills and functions", "installSkill": "Install Skill", diff --git a/frontend/src/i18n/locales/zh.json b/frontend/src/i18n/locales/zh.json index fb878d5..a441eae 100644 --- a/frontend/src/i18n/locales/zh.json +++ b/frontend/src/i18n/locales/zh.json @@ -198,8 +198,10 @@ "outputTemplate": "输出模板 (JSON)", "boundSkill": "绑定技能", "workspace": "工作空间 (JSON)", - "tools": "工具", + "tools": "工具集", "noTools": "暂无工具", + "toolsets": "工具集", + "toolsCount": "个工具", "system": "系统", "type.ordinary_individual": "普通", "type.skill_individual": "技能", @@ -224,11 +226,17 @@ "persona": "人设" }, "plugin": { - "toolManagement": "工具管理", - "toolDesc": "管理代理工具和函数", + "toolManagement": "工具集中心", + "toolDesc": "管理系统和自定义工具集", "availableTools": "可用工具", "toolSubDesc": "已安装的工具", "noTools": "暂无已安装的工具", + "systemToolsets": "系统工具集", + "userToolsets": "我的工具集", + "toolsetTools": "包含工具", + "toolsetEmpty": "暂无工具集", + "toolsetSystem": "系统", + "toolsetCount": "{{count}} 个工具", "skillManagement": "技能管理", "skillDesc": "管理代理技能和函数", "installSkill": "安装技能", diff --git a/kilostar/api/agent.py b/kilostar/api/agent.py index f2d1045..2ca8d05 100644 --- a/kilostar/api/agent.py +++ b/kilostar/api/agent.py @@ -35,7 +35,7 @@ class AgentRegister(BaseModel): provider_title: str model_id: str individual_name: str - tools: Optional[List[str]] = None + toolsets: Optional[List[str]] = None persona_id: Optional[str] = None display_name: Optional[str] = None @@ -45,7 +45,7 @@ class AgentLocalRegister(BaseModel): path: str individual_name: str - tools: Optional[List[str]] = None + toolsets: Optional[List[str]] = None @agent_router.get("") @@ -78,13 +78,15 @@ async def load_agent( agent_register.individual_name, agent_register.provider_title, agent_register.model_id, - agent_register.tools, + agent_register.toolsets, agent_register.persona_id, agent_register.display_name, ) scope = agent_register.individual_name - toolsets = await get_all_toolsets_for_scope(scope) + toolsets = await get_all_toolsets_for_scope( + scope, toolset_ids=agent_register.toolsets + ) # Resolve persona system_prompt from DB persona_prompt = None @@ -100,7 +102,6 @@ async def load_agent( global_state_machine, agent_register.provider_title, agent_register.model_id, - agent_register.tools, toolsets, accept_lang, persona_prompt, @@ -111,7 +112,6 @@ async def load_agent( global_state_machine, agent_register.provider_title, agent_register.model_id, - agent_register.tools, toolsets, accept_lang, persona_prompt, @@ -140,7 +140,7 @@ class WorkerIndividualCreate(BaseModel): output_template: dict bound_skill: Dict[str, List[str]] workspace: List[str] - tools: Optional[List[str]] = None + toolsets: Optional[List[str]] = None node_affinity: str = "cpu" @field_validator("node_affinity") @@ -163,7 +163,7 @@ class WorkerIndividualUpdate(BaseModel): output_template: Optional[dict] = None bound_skill: Optional[Dict[str, List[str]]] = None workspace: Optional[List[str]] = None - tools: Optional[List[str]] = None + toolsets: Optional[List[str]] = None node_affinity: Optional[str] = None @field_validator("node_affinity") diff --git a/kilostar/api/resource.py b/kilostar/api/resource.py index 37ba638..bcebeee 100644 --- a/kilostar/api/resource.py +++ b/kilostar/api/resource.py @@ -290,16 +290,22 @@ async def create_custom_toolset( @resource_router.get("/custom-toolset") async def list_custom_toolsets( + category: Optional[str] = None, token_data: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.USER)), ): - """列出工具组:USER 只能看到自己的;ADMIN 及以上可看全部。""" + """列出工具组:支持按 category 过滤。USER 只能看到自己的+系统的;ADMIN 看全部。""" from kilostar.utils.check_user.role_check import get_authority global_state_machine = ray_actor_hook("global_state_machine").global_state_machine toolsets = await global_state_machine.list_custom_toolsets.remote() authority = await get_authority(token_data.user_id) if authority < UserAuthority.ADMINISTRATOR: - toolsets = [t for t in toolsets if t.get("owner_id") == token_data.user_id] + toolsets = [ + t for t in toolsets + if t.get("is_system") or t.get("owner_id") == token_data.user_id + ] + if category: + toolsets = [t for t in toolsets if t.get("category") == category] return {"toolsets": toolsets} @@ -326,6 +332,8 @@ async def update_custom_toolset( existing = await global_state_machine.get_custom_toolset.remote(toolset_id) if not existing: raise HTTPException(status_code=404, detail="Custom toolset not found") + if existing.get("is_system"): + raise HTTPException(status_code=403, detail="系统预置工具集不可修改") await _assert_toolset_owner_or_admin(existing, token_data) name = body.name if body.name is not None else existing["name"] tools = body.tools if body.tools is not None else existing["tools"] @@ -348,11 +356,13 @@ async def delete_custom_toolset( toolset_id: str, token_data: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.USER)), ): - """删除工具组:USER 只能删自己的;ADMIN 及以上可删任意。""" + """删除工具组:系统预置不可删;USER 只能删自己的;ADMIN 及以上可删任意用户的。""" global_state_machine = ray_actor_hook("global_state_machine").global_state_machine existing = await global_state_machine.get_custom_toolset.remote(toolset_id) if not existing: raise HTTPException(status_code=404, detail="Custom toolset not found") + if existing.get("is_system"): + raise HTTPException(status_code=403, detail="系统预置工具集不可删除") await _assert_toolset_owner_or_admin(existing, token_data) ok = await global_state_machine.delete_custom_toolset.remote(toolset_id) if not ok: diff --git a/kilostar/core/global_state_machine/global_state_machine.py b/kilostar/core/global_state_machine/global_state_machine.py index 355a9a5..e27f4e4 100644 --- a/kilostar/core/global_state_machine/global_state_machine.py +++ b/kilostar/core/global_state_machine/global_state_machine.py @@ -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) diff --git a/kilostar/core/global_state_machine/gsm_snapshot.py b/kilostar/core/global_state_machine/gsm_snapshot.py index b683095..d72d3ac 100644 --- a/kilostar/core/global_state_machine/gsm_snapshot.py +++ b/kilostar/core/global_state_machine/gsm_snapshot.py @@ -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 diff --git a/kilostar/core/global_state_machine/tool_manager.py b/kilostar/core/global_state_machine/tool_manager.py index e8d0490..ddfa8da 100644 --- a/kilostar/core/global_state_machine/tool_manager.py +++ b/kilostar/core/global_state_machine/tool_manager.py @@ -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。 + + 系统 toolset(is_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: diff --git a/kilostar/core/individual/consciousness_node/consciousness_node.py b/kilostar/core/individual/consciousness_node/consciousness_node.py index 8b43cb1..b86430a 100644 --- a/kilostar/core/individual/consciousness_node/consciousness_node.py +++ b/kilostar/core/individual/consciousness_node/consciousness_node.py @@ -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, ) diff --git a/kilostar/core/individual/regulatory_node/regulatory_node.py b/kilostar/core/individual/regulatory_node/regulatory_node.py index 9955fd2..f998b27 100644 --- a/kilostar/core/individual/regulatory_node/regulatory_node.py +++ b/kilostar/core/individual/regulatory_node/regulatory_node.py @@ -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, ) diff --git a/kilostar/core/postgres_database/model/custom_toolset.py b/kilostar/core/postgres_database/model/custom_toolset.py index af3a995..fa4604f 100644 --- a/kilostar/core/postgres_database/model/custom_toolset.py +++ b/kilostar/core/postgres_database/model/custom_toolset.py @@ -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() diff --git a/kilostar/core/postgres_database/model/system_node.py b/kilostar/core/postgres_database/model/system_node.py index e0c4ee9..e1592df 100644 --- a/kilostar/core/postgres_database/model/system_node.py +++ b/kilostar/core/postgres_database/model/system_node.py @@ -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"), diff --git a/kilostar/core/postgres_database/module/custom_toolset.py b/kilostar/core/postgres_database/module/custom_toolset.py index 62d0f3d..f656ab9 100644 --- a/kilostar/core/postgres_database/module/custom_toolset.py +++ b/kilostar/core/postgres_database/module/custom_toolset.py @@ -7,7 +7,7 @@ from kilostar.core.postgres_database.model.custom_toolset import CustomToolsetMo class CustomToolsetDatabase: - """用户自定义工具组 DAO。``tools`` 字段是工具名列表,业务层负责保证只放非 system/非 mcp 的工具。""" + """工具集 DAO。包含系统预置 toolset(is_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() diff --git a/kilostar/plugin/tool_plugin/approval/approval.py b/kilostar/plugin/tool_plugin/approval/approval.py index e3b1b3b..105964c 100644 --- a/kilostar/plugin/tool_plugin/approval/approval.py +++ b/kilostar/plugin/tool_plugin/approval/approval.py @@ -44,7 +44,7 @@ async def approval(message: str, trace_id: str) -> str: Returns: 用户的审批结果 """ - actor_list = ray_actor_hook("global_state_machine") - await actor_list.global_state_machine.put_pending.remote(trace_id, message) - reply = await actor_list.global_state_machine.get_received.remote(trace_id) + actor_list = ray_actor_hook("global_workflow_manager") + await actor_list.global_workflow_manager.put_pending.remote(trace_id, message) + reply = await actor_list.global_workflow_manager.get_received.remote(trace_id) return reply diff --git a/kilostar/plugin/tool_plugin/send_file/__init__.py b/kilostar/plugin/tool_plugin/send_file/__init__.py new file mode 100644 index 0000000..bf8a177 --- /dev/null +++ b/kilostar/plugin/tool_plugin/send_file/__init__.py @@ -0,0 +1,17 @@ +# Copyright 2026 zhaoxi826 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from .send_file import SendFileToolData, send_file + +__all__ = ["SendFileToolData", "send_file"] diff --git a/kilostar/plugin/tool_plugin/send_file/send_file.py b/kilostar/plugin/tool_plugin/send_file/send_file.py new file mode 100644 index 0000000..8bacfae --- /dev/null +++ b/kilostar/plugin/tool_plugin/send_file/send_file.py @@ -0,0 +1,60 @@ +# Copyright 2026 zhaoxi826 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json +from kilostar.plugin.tool_plugin.base_tool import BaseToolData +from kilostar.utils.ray_hook import ray_actor_hook +from typing import List, Literal, Dict + + +class SendFileToolData(BaseToolData): + """``send_file`` 工具元数据:把 agent 生成的文件作为附件推送到对话窗口。""" + + is_system: bool = True + action_scope: List[ + Literal[ + "control_node", + "consciousness_node", + "regulatory_node", + "growth_node", + "", + ] + ] = [] + config_args: Dict[str, str] = {} + category: str = "system" + + +async def send_file(filename: str, content: str, trace_id: str) -> str: + """把 agent 生成的文件作为附件发送给当前对话窗口。 + + 通过 global_workflow_manager 的 pending 队列推送一条带特殊前缀的 JSON 消息, + 前端识别后渲染为可下载的文件卡片。 + + Args: + filename: 文件名(含扩展名),如 "report.md" / "main.py" + content: 文件内容(UTF-8 文本) + trace_id: 当前会话/工作流的 trace_id + + Returns: + 发送结果说明 + """ + payload = json.dumps( + {"type": "file", "filename": filename, "content": content}, + ensure_ascii=False, + ) + actor_list = ray_actor_hook("global_workflow_manager") + await actor_list.global_workflow_manager.put_pending.remote( + trace_id, f"__FILE__{payload}" + ) + return f"已发送文件: {filename}" diff --git a/kilostar/utils/mcp_helper.py b/kilostar/utils/mcp_helper.py index 69274a6..26421f3 100644 --- a/kilostar/utils/mcp_helper.py +++ b/kilostar/utils/mcp_helper.py @@ -14,7 +14,7 @@ """MCP 辅助模块:根据全局状态机中的配置创建 pydantic-ai MCPServer 实例。""" -from typing import Dict, List, Any, Sequence +from typing import Dict, List, Any, Optional, Sequence from kilostar.utils.logger import get_logger @@ -100,10 +100,16 @@ async def get_mcp_toolsets_from_gsm() -> List[Any]: return [] -async def get_all_toolsets_for_scope(scope: str) -> List[Any]: +async def get_all_toolsets_for_scope( + scope: str, toolset_ids: Optional[List[str]] = None +) -> List[Any]: """汇总某个 scope 下的全部 toolset:system + personal + mcp。 - 返回顺序保持稳定:先本地 toolset(system → personal),再 MCP toolset。 + Args: + scope: 调用方所属 scope。 + toolset_ids: agent 配置的 toolset 列表;为 None 表示返回全部。 + + 返回顺序保持稳定:先本地 toolset(按 toolset_ids),再 MCP toolset。 任意一类拉取失败仅记录日志,不影响其他类。 """ toolsets: List[Any] = [] @@ -113,9 +119,8 @@ async def get_all_toolsets_for_scope(scope: str) -> List[Any]: fetch_snapshot, ) - # 一次快照拉取覆盖 system + custom toolsets,本地按 scope 重建 FunctionToolset snapshot = await fetch_snapshot() - local = build_toolsets_for_scope(snapshot, scope) + local = build_toolsets_for_scope(snapshot, scope, toolset_ids=toolset_ids) if local: toolsets.extend(local) except Exception as e: diff --git a/kilostar/worker_individual/base_individual.py b/kilostar/worker_individual/base_individual.py index 4b41ea9..478b88f 100644 --- a/kilostar/worker_individual/base_individual.py +++ b/kilostar/worker_individual/base_individual.py @@ -56,15 +56,14 @@ class BaseIndividual: """根据 agent_config 拉起一个 pydantic-ai Agent 实例。 从 GlobalStateMachine 取出 Provider,按 agent_config 中的 provider_title - 和 model_id 选择模型,加载工具列表,并把 system_prompt 注册为动态提示词。 - 若调用方未显式提供 ``toolsets``,会自动从全局状态机拉取 MCP toolsets 注入。 + 和 model_id 选择模型,加载工具集,并把 system_prompt 注册为动态提示词。 + 若调用方未显式提供 ``toolsets``,会自动从全局状态机拉取配置的工具集。 Args: agent_name: Agent 的人类可读名称,用于日志与展示。 system_prompt: 该 Agent 的基础系统提示词,会和 task_event 拼接成动态提示词。 - toolsets: 显式传入的外部工具集;为 ``None`` 时会自动拉取 MCP toolsets。 + toolsets: 显式传入的外部工具集;为 ``None`` 时会自动按配置拉取。 """ - from kilostar.utils.get_tool import load_tools_from_list from kilostar.utils.mcp_helper import get_all_toolsets_for_scope from kilostar.core.global_state_machine.gsm_snapshot import fetch_snapshot @@ -75,7 +74,7 @@ class BaseIndividual: "provider_title", "openai" ) # default fallback model_id = self.agent_config.get("model_id", "gpt-4o") # default fallback - tools_list = self.agent_config.get("tools", None) + toolset_ids = self.agent_config.get("tools", None) # 直读快照,避开 actor RPC 单线程串行 snapshot = await fetch_snapshot(gsm_actor=global_state_machine) @@ -84,10 +83,10 @@ class BaseIndividual: raise ValueError(f"Provider {provider_title!r} 未注册") agent_factory = AgentFactory() - callables = load_tools_from_list(tools_list) - if toolsets is None: - toolsets = await get_all_toolsets_for_scope(agent_name) + toolsets = await get_all_toolsets_for_scope( + agent_name, toolset_ids=toolset_ids + ) self.agent = agent_factory.create_agent( provider=provider, @@ -96,7 +95,6 @@ class BaseIndividual: system_prompt=system_prompt, deps_type=WorkerIndividualDeps, agent_name=agent_name, - tools=callables, toolsets=toolsets, ) diff --git a/tests/unit/test_api_agent_template.py b/tests/unit/test_api_agent_template.py index 3ffbf06..41b0a48 100644 --- a/tests/unit/test_api_agent_template.py +++ b/tests/unit/test_api_agent_template.py @@ -50,7 +50,7 @@ def test_valid_affinities_accepted(): for aff in _VALID_AFFINITIES: m = WorkerIndividualCreate( agent_name="x", agent_type="ordinary", description="d", - provider_title="p", model_id="m", system_prompt="s", + provider_title="p", model_id="m", persona_id="pid", output_template={}, bound_skill={}, workspace=[], node_affinity=aff, ) assert m.node_affinity == aff @@ -62,7 +62,7 @@ def test_invalid_affinity_raises(): with pytest.raises(ValidationError): WorkerIndividualCreate( agent_name="x", agent_type="ordinary", description="d", - provider_title="p", model_id="m", system_prompt="s", + provider_title="p", model_id="m", persona_id="pid", output_template={}, bound_skill={}, workspace=[], node_affinity="bad", ) diff --git a/tests/unit/test_gsm_snapshot.py b/tests/unit/test_gsm_snapshot.py index dd6d8e1..67a4141 100644 --- a/tests/unit/test_gsm_snapshot.py +++ b/tests/unit/test_gsm_snapshot.py @@ -312,7 +312,7 @@ async def test_fetch_snapshot_use_cache_false_skips_cache(monkeypatch): def test_build_toolsets_for_scope_assembles_system_and_custom(): - """客户端按 snapshot 的 system_tools_by_scope + custom_toolsets 现场组装。""" + """客户端按 snapshot 的 custom_toolsets + all_funcs 现场组装。""" from kilostar.core.global_state_machine.gsm_snapshot import ( build_toolsets_for_scope, ) @@ -327,22 +327,17 @@ def test_build_toolsets_for_scope_assembles_system_and_custom(): return "a" snap = GSMSnapshot( - tool_funcs={"sys_default": _sys_default, "sys_scope": _sys_scope}, - third_party_funcs={"tp_a": _tp_a}, - system_tools_by_scope={ - "default": ["sys_default"], - "control_node": ["sys_scope"], - }, + all_funcs={"sys_default": _sys_default, "sys_scope": _sys_scope, "tp_a": _tp_a}, custom_toolsets={ + "system_basic": {"toolset_id": "system_basic", "tools": ["sys_default", "sys_scope"]}, "grp": {"toolset_id": "grp", "tools": ["tp_a"]}, }, ) result = build_toolsets_for_scope(snap, "control_node") - # 应包含两个 system bucket + 一个 custom toolset - assert len(result) == 3 + assert len(result) == 2 ids = [getattr(t, "id", None) for t in result] - assert ids == ["system::default", "system::control_node", "custom::grp"] + assert ids == ["toolset::system_basic", "toolset::grp"] def test_build_toolsets_for_scope_skips_empty_buckets(): @@ -352,8 +347,7 @@ def test_build_toolsets_for_scope_skips_empty_buckets(): ) snap = GSMSnapshot( - tool_funcs={}, - system_tools_by_scope={"default": [], "control_node": []}, + all_funcs={}, custom_toolsets={}, ) assert build_toolsets_for_scope(snap, "control_node") == [] diff --git a/tests/unit/test_utils_mcp_helper.py b/tests/unit/test_utils_mcp_helper.py index 347a72a..2863581 100644 --- a/tests/unit/test_utils_mcp_helper.py +++ b/tests/unit/test_utils_mcp_helper.py @@ -127,7 +127,7 @@ async def test_get_all_toolsets_for_scope_merges_local_and_mcp(monkeypatch): monkeypatch.setattr( snap_mod, "build_toolsets_for_scope", - lambda s, scope: [local_a, local_b], + lambda s, scope, **kw: [local_a, local_b], ) result = await mcp_helper.get_all_toolsets_for_scope("control_node")