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
@@ -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},
)
@@ -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<Provider[]>([]);
@@ -31,7 +40,7 @@ export function WorkerIndividualSettings() {
const [systemNodes, setSystemNodes] = useState<any[]>([]);
const [personaTemplates, setPersonaTemplates] = useState<PersonaTemplate[]>([]);
const [availableSkills, setAvailableSkills] = useState<string[]>([]);
const [availableTools, setAvailableTools] = useState<string[]>([]);
const [availableToolsets, setAvailableToolsets] = useState<ToolsetItem[]>([]);
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() {
</>
)}
<div>
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('agent.tools')}</label>
<div className="flex flex-wrap gap-1.5 p-3 bg-bg-input border border-border-primary rounded-xl max-h-40 overflow-y-auto">
{availableTools.map(tool => {
let currentTools: string[] = [];
try { currentTools = JSON.parse(editData.tools || '[]'); } catch { }
const isSelected = currentTools.includes(tool);
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('agent.toolsets')}</label>
<div className="grid grid-cols-2 gap-2 p-3 bg-bg-input border border-border-primary rounded-xl max-h-48 overflow-y-auto">
{availableToolsets.map(ts => {
let currentToolsets: string[] = [];
try { currentToolsets = JSON.parse(editData.toolsets || '[]'); } catch { }
const isSelected = currentToolsets.includes(ts.toolset_id);
return (
<button key={tool} type="button" onClick={() => {
const updated = isSelected ? currentTools.filter(t => t !== tool) : [...currentTools, tool];
setEditData({...editData, tools: JSON.stringify(updated)});
<button key={ts.toolset_id} type="button" onClick={() => {
const updated = isSelected ? currentToolsets.filter(id => id !== ts.toolset_id) : [...currentToolsets, ts.toolset_id];
setEditData({...editData, toolsets: JSON.stringify(updated)});
}}
className={`px-2.5 py-1 rounded-lg text-xs font-medium transition-all ${isSelected ? 'bg-accent-light text-accent border border-accent/20' : 'bg-bg-secondary text-text-muted border border-border-primary hover:border-text-muted'}`}>
{tool}
className={`flex flex-col items-start p-2.5 rounded-lg text-left transition-all ${isSelected ? 'bg-accent-light border border-accent/30' : 'bg-bg-secondary border border-border-primary hover:border-text-muted'}`}>
<span className="text-xs font-medium text-text-primary">{ts.name}</span>
<span className="text-[10px] text-text-muted mt-0.5">{ts.tools.length} {t('agent.toolsCount')}</span>
</button>
);
})}
{availableTools.length === 0 && <span className="text-xs text-text-muted">{t('agent.noTools')}</span>}
{availableToolsets.length === 0 && <span className="text-xs text-text-muted col-span-2">{t('plugin.toolsetEmpty')}</span>}
</div>
</div>
{modalMessage && <div className="p-3 bg-danger-bg text-danger text-sm rounded-xl border border-danger/20">{modalMessage}</div>}
+159 -36
View File
@@ -1,67 +1,190 @@
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<string[]>([]);
const [toolsets, setToolsets] = useState<Toolset[]>([]);
const [loading, setLoading] = useState(true);
const [selected, setSelected] = useState<Toolset | null>(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 (
<div className="max-w-4xl space-y-6">
<div>
<div className="flex items-center gap-2 mb-1">
<Wrench size={16} className="text-accent" />
<h3 className="text-lg font-bold text-text-primary">{t('plugin.toolManagement')}</h3>
<h3 className="text-lg font-bold text-text-primary">
{t('plugin.toolManagement')}
</h3>
</div>
<p className="text-sm text-text-muted">{t('plugin.toolDesc')}</p>
</div>
<div className="bg-bg-card border border-border-primary rounded-2xl shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-border-primary flex justify-between items-center bg-bg-secondary">
<div>
<h4 className="text-sm font-bold text-text-primary">{t('plugin.availableTools')}</h4>
<p className="text-[11px] text-text-muted">{t('plugin.toolSubDesc')}</p>
</div>
{loading ? (
<div className="flex flex-col items-center justify-center py-12 text-text-muted">
<Loader2 size={24} className="animate-spin mb-3" />
<span className="text-sm">{t('common.loading')}</span>
</div>
<div className="p-6">
{loading ? (
<div className="flex flex-col items-center justify-center py-12 text-text-muted">
<Loader2 size={24} className="animate-spin mb-3" />
<span className="text-sm">{t('common.loading')}</span>
</div>
) : tools.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-text-muted">
<Box size={32} className="mb-3 opacity-40" />
<span className="text-sm">{t('plugin.noTools')}</span>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{tools.map((tool) => (
<div key={tool} className="flex items-center gap-3 p-3.5 bg-bg-secondary border border-border-secondary rounded-xl hover:border-accent/30 transition-all">
<div className="w-9 h-9 rounded-lg bg-accent-light flex items-center justify-center">
<Package size={16} className="text-accent" />
</div>
<span className="text-sm font-medium text-text-primary">{tool}</span>
</div>
))}
</div>
) : toolsets.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-text-muted">
<Box size={32} className="mb-3 opacity-40" />
<span className="text-sm">{t('plugin.toolsetEmpty')}</span>
</div>
) : (
<>
{systemToolsets.length > 0 && (
<ToolsetGroup
title={t('plugin.systemToolsets')}
toolsets={systemToolsets}
onSelect={setSelected}
/>
)}
{userToolsets.length > 0 && (
<ToolsetGroup
title={t('plugin.userToolsets')}
toolsets={userToolsets}
onSelect={setSelected}
/>
)}
</>
)}
{selected && (
<ToolsetModal toolset={selected} onClose={() => setSelected(null)} />
)}
</div>
);
}
/* PLACEHOLDER_TOOLSET_GROUP */
function ToolsetGroup({
title,
toolsets,
onSelect,
}: {
title: string;
toolsets: Toolset[];
onSelect: (ts: Toolset) => void;
}) {
const { t } = useTranslation();
return (
<div>
<h4 className="text-sm font-semibold text-text-secondary mb-3">
{title}
</h4>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{toolsets.map((ts) => (
<button
key={ts.toolset_id}
onClick={() => onSelect(ts)}
className="relative flex flex-col items-start p-4 bg-bg-card border border-border-primary rounded-xl hover:border-accent/40 hover:shadow-sm transition-all text-left cursor-pointer"
>
{ts.is_system && (
<span className="absolute top-2 right-2 text-[10px] font-medium px-1.5 py-0.5 rounded bg-accent/10 text-accent">
{t('plugin.toolsetSystem')}
</span>
)}
<div className="w-9 h-9 rounded-lg bg-accent-light flex items-center justify-center mb-2">
{ts.is_system ? (
<Shield size={16} className="text-accent" />
) : (
<Package size={16} className="text-accent" />
)}
</div>
<span className="text-sm font-medium text-text-primary">
{ts.name}
</span>
{ts.description && (
<span className="text-[11px] text-text-muted mt-0.5 line-clamp-2">
{ts.description}
</span>
)}
<span className="text-[11px] text-text-muted mt-1">
{t('plugin.toolsetCount', { count: ts.tools.length })}
</span>
</button>
))}
</div>
</div>
);
}
/* PLACEHOLDER_TOOLSET_MODAL */
function ToolsetModal({
toolset,
onClose,
}: {
toolset: Toolset;
onClose: () => void;
}) {
const { t } = useTranslation();
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
onClick={onClose}
>
<div
className="bg-bg-card border border-border-primary rounded-2xl shadow-lg w-full max-w-md mx-4"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-5 py-4 border-b border-border-primary">
<div>
<h4 className="text-sm font-bold text-text-primary">
{toolset.name}
</h4>
{toolset.description && (
<p className="text-[11px] text-text-muted mt-0.5">
{toolset.description}
</p>
)}
</div>
<button
onClick={onClose}
className="p-1 rounded hover:bg-bg-secondary transition-colors"
>
<X size={16} className="text-text-muted" />
</button>
</div>
<div className="px-5 py-4 space-y-2 max-h-80 overflow-y-auto">
<p className="text-xs font-medium text-text-secondary mb-2">
{t('plugin.toolsetTools')}
</p>
{toolset.tools.map((tool) => (
<div
key={tool}
className="flex items-center gap-2.5 p-2.5 bg-bg-secondary rounded-lg"
>
<Package size={14} className="text-accent shrink-0" />
<span className="text-sm text-text-primary">{tool}</span>
</div>
))}
</div>
</div>
</div>
+12 -4
View File
@@ -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",
+11 -3
View File
@@ -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": "安装技能",
+8 -8
View File
@@ -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")
+13 -3
View File
@@ -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:
@@ -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()
@@ -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
@@ -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"]
@@ -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}"
+10 -5
View File
@@ -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 下的全部 toolsetsystem + personal + mcp。
返回顺序保持稳定:先本地 toolsetsystem → 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:
@@ -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,
)
+2 -2
View File
@@ -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",
)
+6 -12
View File
@@ -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") == []
+1 -1
View File
@@ -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")