diff --git a/backend/db/models.py b/backend/db/models.py index da4a8b7..f12fb63 100644 --- a/backend/db/models.py +++ b/backend/db/models.py @@ -154,8 +154,6 @@ class Assistant(Base): enable_interrupt: Mapped[bool] = mapped_column(Boolean, default=True) turn_config: Mapped[dict] = mapped_column(JSON, default=dict) startup: Mapped[dict] = mapped_column(JSON, default=dict) - # Prompt 助手级系统工具;Workflow 的权限保存在各 Agent 节点中。 - system_tools: Mapped[list] = mapped_column(JSON, default=list) vision_enabled: Mapped[bool] = mapped_column(Boolean, default=False) vision_model_resource_id: Mapped[str | None] = mapped_column( String(40), diff --git a/backend/db/session.py b/backend/db/session.py index 2887134..7efdce6 100644 --- a/backend/db/session.py +++ b/backend/db/session.py @@ -10,6 +10,7 @@ import json import settings from services.interface_catalog import INTERFACE_DEFINITIONS +from services.system_tools import SYSTEM_TOOL_SPECS from sqlalchemy import text from sqlalchemy.ext.asyncio import ( AsyncSession, @@ -49,24 +50,31 @@ async def sync_interface_definitions() -> None: async def sync_default_tools() -> None: """Ensure system-provided reusable tools exist without overwriting edits.""" - default_tools = [ - { - "id": "tool_end_call_default", - "name": "结束对话", - "function_name": "end_call", - "type": "system", - "description": "当用户明确要求结束对话,或任务已完成时调用。", - "definition": { - "schema_version": 1, - "type": "system", - "config": { - "kind": "end_conversation", + default_system_tools = [] + for kind, spec in SYSTEM_TOOL_SPECS.items(): + config: dict[str, object] = {"kind": kind} + if kind == "end_conversation": + config.update( + { "message_type": "none", "custom_message": "", "capture_reason": True, + } + ) + default_system_tools.append( + { + **spec, + "type": "system", + "definition": { + "schema_version": 1, + "type": "system", + "config": config, }, - }, - }, + } + ) + + default_tools = [ + *default_system_tools, { "id": "tool_show_message_default", "name": "显示确认消息", diff --git a/backend/migrations/versions/20260804_0012_unify_system_tool_resources.py b/backend/migrations/versions/20260804_0012_unify_system_tool_resources.py new file mode 100644 index 0000000..d016a91 --- /dev/null +++ b/backend/migrations/versions/20260804_0012_unify_system_tool_resources.py @@ -0,0 +1,235 @@ +"""unify system tools as reusable tool resources + +Revision ID: 20260804_0012 +Revises: 20260804_0011 +""" + +from __future__ import annotations + +from collections.abc import Sequence +import json + +from alembic import op +import sqlalchemy as sa + + +revision: str = "20260804_0012" +down_revision: str | Sequence[str] | None = "20260804_0011" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +SYSTEM_TOOLS = { + "end_conversation": { + "id": "tool_end_call_default", + "name": "结束对话", + "function_name": "end_conversation", + "description": ( + "礼貌地结束本次对话。当用户明确告别、表示任务已完成" + "或要求挂断时调用。" + ), + }, + "update_state": { + "id": "tool_update_state_default", + "name": "更新状态", + "function_name": "update_state", + "description": ( + "静默更新本次对话中已经声明并明确授权的动态变量。" + "只提交本轮获得或确认的信息,更新后继续当前回答。" + ), + }, + "skip_turn": { + "id": "tool_skip_turn_default", + "name": "跳过本轮", + "function_name": "skip_turn", + "description": ( + "跳过当前轮次,不生成任何语音回复。" + "仅当用户明确要求稍等、话还没说完,或输入可确认只是噪音时调用。" + ), + }, + "request_human_handoff": { + "id": "tool_request_human_handoff_default", + "name": "转接人工", + "function_name": "request_human_handoff", + "description": ( + "提交人工接管请求。当用户明确要求人工服务、投诉升级或 AI 无法" + "解决时调用。该工具只提交请求,不代表人工已经接通;调用后继续" + "回复用户并说明正在等待人工响应。" + ), + }, +} + + +def _definition(kind: str) -> dict: + config: dict[str, object] = {"kind": kind} + if kind == "end_conversation": + config.update( + { + "message_type": "none", + "custom_message": "", + "capture_reason": True, + } + ) + return {"schema_version": 1, "type": "system", "config": config} + + +def _copy_global_agent_config(data: dict, settings: dict) -> None: + """Freeze an inherited Agent before moving its node-local System tools.""" + key_pairs = { + "defaultLlmResourceId": "llmResourceId", + "defaultAsrResourceId": "asrResourceId", + "defaultTtsResourceId": "ttsResourceId", + } + for source, target in key_pairs.items(): + data[target] = settings.get(source) + for key in ( + "visionEnabled", + "visionModelResourceId", + "knowledgeBaseId", + "knowledgeMode", + "knowledgeTopN", + "knowledgeScoreThreshold", + "enableInterrupt", + "turnConfig", + ): + if key in settings: + data[key] = settings[key] + data["toolIds"] = list(settings.get("toolIds") or []) + data["inheritGlobalConfig"] = False + + +def upgrade() -> None: + connection = op.get_bind() + for kind, spec in SYSTEM_TOOLS.items(): + if kind == "end_conversation": + connection.execute( + sa.text( + """ + UPDATE tools + SET name = :name, + function_name = :function_name, + type = 'system', + description = :description, + definition = CAST(:definition AS jsonb), + status = 'active', + updated_at = now() + WHERE id = :id + """ + ), + {**spec, "definition": json.dumps(_definition(kind))}, + ) + connection.execute( + sa.text( + """ + INSERT INTO tools + (id, name, function_name, type, description, definition, secrets, status) + VALUES + (:id, :name, :function_name, 'system', :description, + CAST(:definition AS jsonb), '{}'::jsonb, 'active') + ON CONFLICT (function_name) DO NOTHING + """ + ), + {**spec, "definition": json.dumps(_definition(kind))}, + ) + + assistants = connection.execute( + sa.text("SELECT id, system_tools, graph FROM assistants") + ).mappings().all() + for assistant in assistants: + assistant_id = str(assistant["id"]) + for kind in assistant["system_tools"] or []: + spec = SYSTEM_TOOLS.get(str(kind)) + if not spec: + continue + connection.execute( + sa.text( + """ + INSERT INTO assistant_tool_bindings (assistant_id, tool_id) + VALUES (:assistant_id, :tool_id) + ON CONFLICT DO NOTHING + """ + ), + {"assistant_id": assistant_id, "tool_id": spec["id"]}, + ) + + graph = assistant["graph"] or {} + if not isinstance(graph, dict): + continue + settings = graph.get("settings") or {} + changed = False + for node in graph.get("nodes") or []: + if node.get("type") != "agent": + continue + data = node.get("data") or {} + had_legacy_selection = "systemTools" in data + kinds = list(dict.fromkeys(data.pop("systemTools", []) or [])) + if had_legacy_selection: + node["data"] = data + changed = True + if not kinds: + continue + if data.get("inheritGlobalConfig", True): + _copy_global_agent_config(data, settings) + tool_ids = list(data.get("toolIds") or []) + for kind in kinds: + spec = SYSTEM_TOOLS.get(str(kind)) + if spec and spec["id"] not in tool_ids: + tool_ids.append(spec["id"]) + connection.execute( + sa.text( + """ + INSERT INTO assistant_tool_bindings (assistant_id, tool_id) + VALUES (:assistant_id, :tool_id) + ON CONFLICT DO NOTHING + """ + ), + {"assistant_id": assistant_id, "tool_id": spec["id"]}, + ) + data["toolIds"] = tool_ids + node["data"] = data + if changed: + connection.execute( + sa.text("UPDATE assistants SET graph = CAST(:graph AS json) WHERE id = :id"), + {"id": assistant_id, "graph": json.dumps(graph)}, + ) + + op.drop_column("assistants", "system_tools") + + +def downgrade() -> None: + op.add_column( + "assistants", + sa.Column( + "system_tools", + sa.JSON(), + server_default=sa.text("'[]'"), + nullable=False, + ), + ) + op.execute( + """ + DELETE FROM assistant_tool_bindings + WHERE tool_id IN ( + 'tool_update_state_default', + 'tool_skip_turn_default', + 'tool_request_human_handoff_default' + ) + """ + ) + op.execute( + """ + DELETE FROM tools + WHERE id IN ( + 'tool_update_state_default', + 'tool_skip_turn_default', + 'tool_request_human_handoff_default' + ) + """ + ) + op.execute( + """ + UPDATE tools + SET function_name = 'end_call' + WHERE id = 'tool_end_call_default' + """ + ) diff --git a/backend/models.py b/backend/models.py index 538f62c..c71f6da 100644 --- a/backend/models.py +++ b/backend/models.py @@ -114,8 +114,6 @@ class AssistantConfig(BaseModel): # every item in ``tools``. tools: list[RuntimeTool] = Field(default_factory=list) llm_tool_ids: list[str] | None = None - # 助手级系统工具仅供 Prompt Pipeline;Workflow 在 Agent 节点中配置。 - system_tools: list[str] = Field(default_factory=list) knowledge_base_id: str | None = None knowledge_base_name: str = "" knowledge_base_description: str = "" diff --git a/backend/routes/assistants.py b/backend/routes/assistants.py index e61b53f..cdd7d4c 100644 --- a/backend/routes/assistants.py +++ b/backend/routes/assistants.py @@ -17,6 +17,7 @@ from schemas import AssistantOut, AssistantUpsert from services.auth import require_admin from services.masking import mask, resolve_incoming_key from services.node_specs import graph_references, normalize_graph, validate_graph +from services.system_tools import system_tool_kind from services.tool_policy import policy_for_tool from services.workflow_engine import WorkflowEngine from sqlalchemy import select @@ -58,13 +59,6 @@ def _validate_workflow(body: AssistantUpsert) -> None: f"Agent 节点 {node_id} 授权了未声明变量:" + ",".join(unknown) ) - if ( - "update_state" in (data.get("systemTools") or []) - and not authorized - ): - errors.append( - f"Agent 节点 {node_id} 启用更新状态工具时必须授权至少一个变量" - ) if errors: raise HTTPException(400, "工作流校验失败:" + ";".join(errors)) # Graph settings are the source of truth. The flat flag is only a session @@ -141,6 +135,60 @@ async def _validate_workflow_references( raise HTTPException(400, f"Workflow 引用了无效知识库:{knowledge_id}") +async def _validate_system_tool_selection( + session: AsyncSession, + body: AssistantUpsert, +) -> None: + """Validate System resources in the same selection path as every other tool.""" + selected_ids = list(dict.fromkeys(body.tool_ids)) + if not selected_ids or body.type not in {"prompt", "workflow"}: + return + rows = ( + await session.execute(select(Tool).where(Tool.id.in_(selected_ids))) + ).scalars().all() + tools_by_id = {tool.id: tool for tool in rows if tool.status == "active"} + system_kinds = { + tool.id: system_tool_kind(tool.definition or {}) + for tool in rows + if tool.status == "active" and tool.type == "system" + } + invalid = [tool_id for tool_id, kind in system_kinds.items() if kind is None] + if invalid: + raise HTTPException(400, "系统工具配置无效: " + ", ".join(invalid)) + + if body.type == "prompt": + if body.runtime_mode == "realtime" and system_kinds: + raise HTTPException(400, "Prompt Realtime 模式暂不支持系统工具") + if ( + "update_state" in system_kinds.values() + and not body.dynamic_variable_definitions + ): + raise HTTPException(400, "启用更新状态工具前必须声明至少一个动态变量") + return + + engine = WorkflowEngine(body.graph) + for node_id, node in engine.nodes.items(): + node_type = node.get("type") + data = node.get("data") or {} + if node_type == "agent": + stage = engine.agent_stage_config(node_id) + kinds = { + system_kinds[tool_id] + for tool_id in stage.tool_ids + if tool_id in system_kinds + } + if "update_state" in kinds and not stage.state_variable_names: + raise HTTPException( + 400, + f"Agent 节点 {node_id} 启用更新状态工具时必须授权至少一个变量", + ) + elif node_type == "action": + tool_id = str(data.get("toolId") or "") + tool = tools_by_id.get(tool_id) + if tool and tool.type == "system": + raise HTTPException(400, f"Action 节点 {node_id} 不能调用系统工具") + + async def _validate_startup_actions( session: AsyncSession, body: AssistantUpsert, @@ -312,7 +360,6 @@ async def _to_out(session: AsyncSession, assistant: Assistant) -> AssistantOut: enable_interrupt=assistant.enable_interrupt, turn_config=assistant.turn_config or {}, startup=assistant.startup or {}, - system_tools=assistant.system_tools or [], vision_enabled=assistant.vision_enabled, vision_model_resource_id=assistant.vision_model_resource_id, model_resource_ids=await _resource_ids(session, assistant.id), @@ -347,6 +394,7 @@ async def create_assistant( ): _validate_workflow(body) await _validate_workflow_references(session, body) + await _validate_system_tool_selection(session, body) await _validate_startup_actions(session, body) await _validate_vision_model(session, body) await _validate_knowledge_base(session, body) @@ -389,7 +437,6 @@ async def duplicate_assistant( enable_interrupt=source.enable_interrupt, turn_config=dict(source.turn_config or {}), startup=dict(source.startup or {}), - system_tools=list(source.system_tools or []), vision_enabled=source.vision_enabled, vision_model_resource_id=source.vision_model_resource_id, knowledge_base_id=source.knowledge_base_id, @@ -423,6 +470,7 @@ async def update_assistant( raise HTTPException(404, "助手不存在") _validate_workflow(body) await _validate_workflow_references(session, body) + await _validate_system_tool_selection(session, body) await _validate_startup_actions(session, body) await _validate_vision_model(session, body) await _validate_knowledge_base(session, body) diff --git a/backend/schemas.py b/backend/schemas.py index 3918295..bf02295 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -159,9 +159,6 @@ class AssistantUpsert(CamelModel): startup: StartupConfig = Field(default_factory=StartupConfig) vision_enabled: bool = False vision_model_resource_id: str | None = None - # 内置系统工具开关(仅 prompt 类型可用,类似 ElevenLabs Agent 的系统工具)。 - system_tools: list[SystemToolKind] = Field(default_factory=list, max_length=4) - model_resource_ids: dict[ModelType, str] = Field(default_factory=dict) knowledge_base_id: str | None = None knowledge_retrieval_config: KnowledgeRetrievalConfig = Field( @@ -199,8 +196,6 @@ class AssistantUpsert(CamelModel): for field in ("prompt", "api_url", "api_key", "app_id"): if field not in allowed: setattr(self, field, "") - if self.type != "prompt": - self.system_tools = [] if "graph" not in allowed: self.graph = {} if self.type == "workflow": @@ -220,15 +215,6 @@ class AssistantUpsert(CamelModel): # 外部托管大脑只能 cascade,拦住不兼容的 realtime if self.runtime_mode == "realtime" and self.type not in REALTIME_CAPABLE_TYPES: raise ValueError(f"类型 {self.type} 不支持 realtime 运行模式") - if self.type == "prompt": - self.system_tools = list(dict.fromkeys(self.system_tools)) - if self.runtime_mode == "realtime" and self.system_tools: - raise ValueError("Prompt Realtime 模式暂不支持系统工具") - if ( - "update_state" in self.system_tools - and not self.dynamic_variable_definitions - ): - raise ValueError("启用更新状态工具前必须声明至少一个动态变量") return self @@ -270,7 +256,7 @@ class ToolParameter(CamelModel): class SystemToolConfig(CamelModel): - kind: Literal["end_conversation"] = "end_conversation" + kind: SystemToolKind = "end_conversation" message_type: Literal["none", "custom"] = "none" custom_message: str = "" capture_reason: bool = True diff --git a/backend/services/brains/prompt_brain.py b/backend/services/brains/prompt_brain.py index f05fa33..1098b98 100644 --- a/backend/services/brains/prompt_brain.py +++ b/backend/services/brains/prompt_brain.py @@ -44,7 +44,7 @@ from services.message_stage import ( MessageStageSpec, ) from services.runtime_variables import DynamicVariableError, DynamicVariableStore -from services.system_tools import SYSTEM_TOOL_KINDS, state_update_properties +from services.system_tools import state_update_properties, system_tool_kind from services.tool_executor import ToolExecutionError, ToolExecutor from services.tool_policy import policy_for_tool @@ -128,11 +128,16 @@ class PromptBrain(BaseBrain): if llm_tool_ids is not None and tool.id not in llm_tool_ids: continue if tool.type == "system": - schema, handler = self._make_end_call_tool(tool, runtime) + schema, handler = self._make_system_tool(tool, runtime) elif tool.type in {"http", "mcp", "client"}: schema, handler = self._make_remote_tool(tool, runtime) else: continue + if schema.name in registered_names: + logger.warning( + f"跳过工具 {tool.id}: 函数名 {schema.name} 已被占用" + ) + continue schemas.append(schema) registered_names.add(schema.name) policy = policy_for_tool(tool) @@ -141,19 +146,6 @@ class PromptBrain(BaseBrain): handler, cancel_on_interruption=policy.cancel_on_interruption, ) - for kind in cfg.system_tools or []: - if kind not in SYSTEM_TOOL_KINDS: - logger.warning(f"忽略未知系统工具: {kind}") - continue - schema, handler = self._make_system_tool(kind, runtime) - if schema.name in registered_names: - logger.warning( - f"跳过系统工具 {schema.name}: 与已绑定工具函数名冲突" - ) - continue - registered_names.add(schema.name) - schemas.append(schema) - runtime.llm.register_function(schema.name, handler) runtime.set_tools(schemas) async def run_preflight(self) -> None: @@ -598,50 +590,23 @@ class PromptBrain(BaseBrain): ) return schema, end_call - # ---------- 内置系统工具(助手配置 system_tools 开关) ---------- + # ---------- System 工具资源 ---------- - def _make_system_tool(self, kind: str, runtime: BrainRuntime): + def _make_system_tool(self, tool, runtime: BrainRuntime): + kind = system_tool_kind(tool.definition or {}) + if not kind: + raise ValueError(f"系统工具 {tool.id} 缺少有效 kind") if kind == "end_conversation": - return self._make_end_conversation_tool(runtime) + return self._make_end_call_tool(tool, runtime) if kind == "update_state": - return self._make_update_state_tool(runtime) + return self._make_update_state_tool(tool, runtime) if kind == "skip_turn": - return self._make_skip_turn_tool() + return self._make_skip_turn_tool(tool) if kind == "request_human_handoff": - return self._make_handoff_tool(runtime) + return self._make_handoff_tool(tool, runtime) raise ValueError(f"未知系统工具: {kind}") - def _make_end_conversation_tool(self, runtime: BrainRuntime): - """结束本次对话,等待模型已生成的告别语播完后再挂断。""" - - async def end_conversation(params: FunctionCallParams) -> None: - reason = str( - params.arguments.get("reason") or "end_conversation" - ).strip() - self._waiting_for_generated_end_speech = True - runtime.call_end.begin(reason) - await params.result_callback( - {"status": "success", "action": "ending_call"}, - properties=FunctionCallResultProperties(run_llm=False), - ) - - schema = FunctionSchema( - name="end_conversation", - description=( - "礼貌地结束本次对话。当用户明确告别、表示任务已完成" - "或要求挂断时调用。" - ), - properties={ - "reason": { - "type": "string", - "description": "结束对话的简短原因。", - } - }, - required=[], - ) - return schema, end_conversation - - def _make_update_state_tool(self, runtime: BrainRuntime): + def _make_update_state_tool(self, tool, runtime: BrainRuntime): """更新已声明的动态变量(会话状态),并让模型继续当前回答。""" writable = state_update_properties(self._cfg.dynamic_variable_definitions) @@ -676,8 +641,8 @@ class PromptBrain(BaseBrain): ) schema = FunctionSchema( - name="update_state", - description=( + name=tool.function_name, + description=tool.description or ( "静默更新本次对话中已经声明并明确列出的动态变量。" "只提交本轮获得或确认的信息,更新后继续当前回答。" ), @@ -686,7 +651,7 @@ class PromptBrain(BaseBrain): ) return schema, update_state - def _make_skip_turn_tool(self): + def _make_skip_turn_tool(self, tool): """跳过当前轮次,不生成任何语音回复。""" async def skip_turn(params: FunctionCallParams) -> None: @@ -700,8 +665,8 @@ class PromptBrain(BaseBrain): ) schema = FunctionSchema( - name="skip_turn", - description=( + name=tool.function_name, + description=tool.description or ( "跳过当前轮次,不生成任何语音回复。" "仅当用户明确要求稍等、话还没说完,或输入可确认只是噪音时调用。" ), @@ -715,7 +680,7 @@ class PromptBrain(BaseBrain): ) return schema, skip_turn - def _make_handoff_tool(self, runtime: BrainRuntime): + def _make_handoff_tool(self, tool, runtime: BrainRuntime): """提交人工接管请求;请求完成前保持当前 AI 会话可用。""" async def request_human_handoff(params: FunctionCallParams) -> None: @@ -741,8 +706,8 @@ class PromptBrain(BaseBrain): ) schema = FunctionSchema( - name="request_human_handoff", - description=( + name=tool.function_name, + description=tool.description or ( "提交人工接管请求。当用户明确要求人工服务、投诉升级或 AI 无法" "解决时调用。该工具只提交请求,不代表人工已经接通;调用后继续" "回复用户并说明正在等待人工响应。" diff --git a/backend/services/brains/workflow_brain.py b/backend/services/brains/workflow_brain.py index 28c2ff6..a969c4f 100644 --- a/backend/services/brains/workflow_brain.py +++ b/backend/services/brains/workflow_brain.py @@ -57,7 +57,7 @@ from services.message_stage import ( MessageStageSpec, ) from services.runtime_variables import DynamicVariableError, DynamicVariableStore -from services.system_tools import SYSTEM_TOOL_KINDS, state_update_properties +from services.system_tools import state_update_properties, system_tool_kind from services.tool_executor import ToolExecutionError, ToolExecutor from services.tool_policy import policy_for_tool from services.workflow.agent import WorkflowAgentStage @@ -564,22 +564,21 @@ class WorkflowBrain(BaseBrain): for tool_id in stage.tool_ids: tool = self._tool_by_id.get(str(tool_id)) - if tool and tool.type in {"http", "mcp", "client"}: + if not tool: + continue + if tool.type == "system": + append_function( + self._workflow_system_tool( + tool, + node_id=node_id, + state_variable_names=stage.state_variable_names, + ) + ) + elif tool.type in {"http", "mcp", "client"}: append_function(self._flow_tool(tool, node_id)) append_function(self._knowledge_function(node_id)) if stage.vision_enabled and self._require_runtime().vision_function: append_function(self._require_runtime().vision_function) - for kind in stage.system_tools: - if kind not in SYSTEM_TOOL_KINDS: - logger.warning(f"忽略 Agent {node_id} 的未知系统工具: {kind}") - continue - append_function( - self._workflow_system_tool( - kind, - node_id=node_id, - state_variable_names=stage.state_variable_names, - ) - ) return self._require_agent_stage().node_config( node_id, functions=functions, @@ -738,27 +737,30 @@ class WorkflowBrain(BaseBrain): def _workflow_system_tool( self, - kind: str, + tool: RuntimeTool, *, node_id: str, state_variable_names: tuple[str, ...], ) -> FlowsFunctionSchema: """Build one platform-owned tool scoped to the active Agent node.""" + kind = system_tool_kind(tool.definition or {}) if kind == "update_state": return self._workflow_update_state_tool( + tool, node_id, state_variable_names=state_variable_names, ) if kind == "skip_turn": - return self._workflow_skip_turn_tool() + return self._workflow_skip_turn_tool(tool) if kind == "request_human_handoff": - return self._workflow_handoff_tool(node_id) + return self._workflow_handoff_tool(tool, node_id) if kind == "end_conversation": - return self._workflow_end_conversation_tool() - raise ValueError(f"未知系统工具: {kind}") + return self._workflow_end_conversation_tool(tool, node_id) + raise ValueError(f"系统工具 {tool.id} 缺少有效 kind") def _workflow_update_state_tool( self, + tool: RuntimeTool, node_id: str, *, state_variable_names: tuple[str, ...], @@ -791,8 +793,8 @@ class WorkflowBrain(BaseBrain): } return FlowsFunctionSchema( - name="update_state", - description=( + name=tool.function_name, + description=tool.description or ( "静默更新当前阶段明确授权的动态变量。" "只提交本轮获得或确认的信息,更新后继续当前回答。" ), @@ -805,7 +807,7 @@ class WorkflowBrain(BaseBrain): ) @staticmethod - def _workflow_skip_turn_tool() -> FlowsFunctionSchema: + def _workflow_skip_turn_tool(tool: RuntimeTool) -> FlowsFunctionSchema: async def handler(args, _flow_manager): reason = str((args or {}).get("reason") or "").strip() result = {"status": "success", "action": "skip_turn"} @@ -815,8 +817,8 @@ class WorkflowBrain(BaseBrain): setattr(handler, "_suppress_followup_llm", True) return FlowsFunctionSchema( - name="skip_turn", - description=( + name=tool.function_name, + description=tool.description or ( "跳过当前轮次,不生成任何语音回复。" "仅当用户明确要求稍等、话还没说完,或输入可确认只是噪音时调用。" ), @@ -830,7 +832,9 @@ class WorkflowBrain(BaseBrain): handler=handler, ) - def _workflow_handoff_tool(self, node_id: str) -> FlowsFunctionSchema: + def _workflow_handoff_tool( + self, tool: RuntimeTool, node_id: str + ) -> FlowsFunctionSchema: async def handler(args, _flow_manager): reason = str((args or {}).get("reason") or "human_handoff").strip() await self._require_runtime().queue_frame( @@ -852,8 +856,8 @@ class WorkflowBrain(BaseBrain): } return FlowsFunctionSchema( - name="request_human_handoff", - description=( + name=tool.function_name, + description=tool.description or ( "提交人工接管请求。当用户明确要求人工服务、投诉升级或 AI 无法" "解决时调用。该工具只提交请求,不代表人工已经接通;调用后继续" "回复用户并说明正在等待人工响应。" @@ -865,27 +869,55 @@ class WorkflowBrain(BaseBrain): handler=handler, ) - def _workflow_end_conversation_tool(self) -> FlowsFunctionSchema: + def _workflow_end_conversation_tool( + self, tool: RuntimeTool, node_id: str + ) -> FlowsFunctionSchema: + config = (tool.definition or {}).get("config") or {} + message_type = str(config.get("message_type") or "none") + custom_message = str(config.get("custom_message") or "").strip() + capture_reason = bool(config.get("capture_reason", True)) + async def handler(args, _flow_manager): reason = str((args or {}).get("reason") or "end_conversation").strip() - self._waiting_for_generated_end_speech = True - self._require_runtime().call_end.begin(reason) + uses_custom_message = message_type == "custom" and bool(custom_message) + self._waiting_for_generated_end_speech = not uses_custom_message + runtime = self._require_runtime() + runtime.call_end.begin(reason) + if uses_custom_message: + await self._queue_visible_speech( + custom_message, + source="workflow-system-tool", + node_id=node_id, + ) + arm_tracked = getattr( + runtime.call_end, + "arm_after_tracked_speech", + None, + ) + if callable(arm_tracked): + await arm_tracked() + else: + runtime.call_end.arm_after_speech() return {"status": "success", "action": "ending_call"} setattr(handler, "_suppress_followup_llm", True) return FlowsFunctionSchema( - name="end_conversation", - description=( + name=tool.function_name, + description=tool.description or ( "礼貌地结束本次对话。当用户明确告别、表示任务已完成" "或要求挂断时调用。" ), - properties={ - "reason": { - "type": "string", - "description": "结束对话的简短原因。", + properties=( + { + "reason": { + "type": "string", + "description": "结束对话的简短原因。", + } } - }, - required=[], + if capture_reason + else {} + ), + required=["reason"] if capture_reason else [], handler=handler, ) diff --git a/backend/services/config_resolver.py b/backend/services/config_resolver.py index 63a1529..c551ca2 100644 --- a/backend/services/config_resolver.py +++ b/backend/services/config_resolver.py @@ -266,7 +266,6 @@ async def resolve_runtime_config( enableInterrupt=assistant.enable_interrupt, turnConfig=assistant.turn_config or {}, startup=assistant.startup or {}, - system_tools=assistant.system_tools or [], tools=runtime_tools, llm_tool_ids=llm_tool_ids, knowledge_base_id=assistant.knowledge_base_id, diff --git a/backend/services/node_specs.py b/backend/services/node_specs.py index 7e9fe2d..7c11ad7 100644 --- a/backend/services/node_specs.py +++ b/backend/services/node_specs.py @@ -11,7 +11,6 @@ from services.message_policy import ( MESSAGE_CONFIRMATION, MESSAGE_PLAYBACK, ) -from services.system_tools import SYSTEM_TOOL_KINDS, normalize_system_tools SPEC_VERSION = "3" @@ -193,7 +192,7 @@ def _normalize_agent_data(data: dict[str, Any]) -> None: if data.get("entryMode") not in AGENT_ENTRY_MODES: data["entryMode"] = "wait_user" data.pop("entrySpeech", None) - data["systemTools"] = list(normalize_system_tools(data.get("systemTools"))) + data.pop("systemTools", None) state_names = data.get("stateVariableNames") data["stateVariableNames"] = list( dict.fromkeys(str(name) for name in state_names or [] if str(name)) @@ -439,11 +438,6 @@ def validate_graph(graph: dict[str, Any]) -> list[str]: entry_mode = data.get("entryMode", "wait_user") if entry_mode not in AGENT_ENTRY_MODES: errors.append(f"Agent 节点 {node_id} 的进入模式无效:{entry_mode}") - system_tools = data.get("systemTools", []) - if not isinstance(system_tools, list) or any( - tool not in SYSTEM_TOOL_KINDS for tool in system_tools - ): - errors.append(f"Agent 节点 {node_id} 的系统工具配置无效") state_names = data.get("stateVariableNames", []) if not isinstance(state_names, list) or any( not isinstance(name, str) for name in state_names diff --git a/backend/services/system_tools.py b/backend/services/system_tools.py index 106e35c..f43ceed 100644 --- a/backend/services/system_tools.py +++ b/backend/services/system_tools.py @@ -6,25 +6,56 @@ from collections.abc import Iterable, Mapping from typing import Any -SYSTEM_TOOL_KINDS = frozenset( - { - "end_conversation", - "update_state", - "skip_turn", - "request_human_handoff", - } -) +SYSTEM_TOOL_SPECS: dict[str, dict[str, str]] = { + "end_conversation": { + "id": "tool_end_call_default", + "name": "结束对话", + "function_name": "end_conversation", + "description": ( + "礼貌地结束本次对话。当用户明确告别、表示任务已完成" + "或要求挂断时调用。" + ), + }, + "update_state": { + "id": "tool_update_state_default", + "name": "更新状态", + "function_name": "update_state", + "description": ( + "静默更新本次对话中已经声明并明确授权的动态变量。" + "只提交本轮获得或确认的信息,更新后继续当前回答。" + ), + }, + "skip_turn": { + "id": "tool_skip_turn_default", + "name": "跳过本轮", + "function_name": "skip_turn", + "description": ( + "跳过当前轮次,不生成任何语音回复。" + "仅当用户明确要求稍等、话还没说完,或输入可确认只是噪音时调用。" + ), + }, + "request_human_handoff": { + "id": "tool_request_human_handoff_default", + "name": "转接人工", + "function_name": "request_human_handoff", + "description": ( + "提交人工接管请求。当用户明确要求人工服务、投诉升级或 AI 无法" + "解决时调用。该工具只提交请求,不代表人工已经接通;调用后继续" + "回复用户并说明正在等待人工响应。" + ), + }, +} + +SYSTEM_TOOL_KINDS = frozenset(SYSTEM_TOOL_SPECS) -def normalize_system_tools(values: Iterable[Any] | None) -> tuple[str, ...]: - """Return known tool names once each while preserving editor order.""" - return tuple( - dict.fromkeys( - str(value) - for value in values or () - if str(value) in SYSTEM_TOOL_KINDS - ) - ) +def system_tool_kind(definition: Mapping[str, Any] | None) -> str | None: + """Return the supported platform behavior declared by a System resource.""" + config = (definition or {}).get("config") + if not isinstance(config, Mapping): + return None + kind = str(config.get("kind") or "") + return kind if kind in SYSTEM_TOOL_KINDS else None def state_update_properties( diff --git a/backend/services/workflow_engine.py b/backend/services/workflow_engine.py index f293d3b..1a19066 100644 --- a/backend/services/workflow_engine.py +++ b/backend/services/workflow_engine.py @@ -11,7 +11,6 @@ from typing import Any from services.node_specs import normalize_graph from services.runtime_variables import DynamicVariableStore -from services.system_tools import normalize_system_tools @dataclass(frozen=True) @@ -25,7 +24,6 @@ class AgentStageConfig: vision_enabled: bool vision_model_resource_id: str | None tool_ids: tuple[str, ...] - system_tools: tuple[str, ...] state_variable_names: tuple[str, ...] knowledge_base_id: str | None knowledge_mode: str @@ -148,9 +146,8 @@ class WorkflowEngine: str(source.get("visionModelResourceId") or "") or None ), tool_ids=tuple(str(tool_id) for tool_id in source.get("toolIds") or []), - # System tools and their writable state scope always belong to the - # Agent node. They are permissions, not inheritable model config. - system_tools=normalize_system_tools(data.get("systemTools")), + # Variable write scope always belongs to the Agent node, even when + # its selected tool resources come from Workflow defaults. state_variable_names=tuple( dict.fromkeys( str(name) diff --git a/backend/tests/test_brains.py b/backend/tests/test_brains.py index f3d8e17..7618531 100644 --- a/backend/tests/test_brains.py +++ b/backend/tests/test_brains.py @@ -21,7 +21,7 @@ from pipecat.frames.frames import ( ) from pipecat.processors.aggregators.llm_context import LLMContext from pipecat.processors.frame_processor import FrameDirection -from schemas import AssistantUpsert, REALTIME_CAPABLE_TYPES +from schemas import AssistantUpsert, REALTIME_CAPABLE_TYPES, SystemToolConfig from services.brains import BrainRuntime, SPECS, build_brain from services.brains.base import GREETING_CONTEXT_MARKER from services.brains.dify_llm import ( @@ -104,6 +104,17 @@ async def noop_queue_frame(_frame): return None +def system_runtime_tool(kind: str) -> RuntimeTool: + return RuntimeTool( + id=f"tool_{kind}", + name=kind, + function_name=kind, + type="system", + description=f"{kind} system tool", + definition={"type": "system", "config": {"kind": kind}}, + ) + + class BrainRegistryTests(unittest.TestCase): def test_capability_matrix(self): self.assertEqual( @@ -154,45 +165,18 @@ class BrainRegistryTests(unittest.TestCase): ) self.assertIn("user_name", assistant.dynamic_variable_definitions) - def test_system_tools_are_prompt_pipeline_only(self): - assistant = AssistantUpsert( - name="prompt", - type="prompt", - systemTools=["end_conversation", "skip_turn", "end_conversation"], - ) - self.assertEqual(assistant.system_tools, ["end_conversation", "skip_turn"]) - - workflow = AssistantUpsert( - name="workflow", - type="workflow", - systemTools=["update_state"], - graph={}, - ) - self.assertEqual(workflow.system_tools, []) + def test_system_tool_config_accepts_all_platform_actions(self): + for kind in ( + "end_conversation", + "update_state", + "skip_turn", + "request_human_handoff", + ): + self.assertEqual(SystemToolConfig(kind=kind).kind, kind) + def test_system_tool_config_rejects_unknown_kind(self): with self.assertRaises(ValueError): - AssistantUpsert( - name="realtime prompt", - type="prompt", - runtimeMode="realtime", - systemTools=["skip_turn"], - ) - - def test_system_tools_reject_unknown_kind(self): - with self.assertRaises(ValueError): - AssistantUpsert( - name="prompt", - type="prompt", - systemTools=["update_state", "magic"], - ) - - def test_prompt_update_state_requires_a_declared_variable(self): - with self.assertRaisesRegex(ValueError, "必须声明至少一个动态变量"): - AssistantUpsert( - name="prompt", - type="prompt", - systemTools=["update_state"], - ) + SystemToolConfig(kind="magic") def test_workflow_keeps_dynamic_variables_and_tool_bindings(self): assistant = AssistantUpsert( @@ -916,11 +900,11 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase): "default": None, } }, - system_tools=[ - "end_conversation", - "update_state", - "skip_turn", - "request_human_handoff", + tools=[ + system_runtime_tool("end_conversation"), + system_runtime_tool("update_state"), + system_runtime_tool("skip_turn"), + system_runtime_tool("request_human_handoff"), ], ) brain = build_brain(cfg) @@ -971,7 +955,7 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase): async def test_end_conversation_ends_call_after_generated_speech(self): cfg = AssistantConfig( type="prompt", - system_tools=["end_conversation"], + tools=[system_runtime_tool("end_conversation")], ) brain = build_brain(cfg) llm = FakeLLM() @@ -1009,7 +993,7 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase): async def test_end_conversation_finishes_when_no_speech(self): cfg = AssistantConfig( type="prompt", - system_tools=["end_conversation"], + tools=[system_runtime_tool("end_conversation")], ) brain = build_brain(cfg) llm = FakeLLM() @@ -1039,7 +1023,7 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase): dynamic_variable_definitions={ "user_name": {"type": "string", "required": False, "default": None} }, - system_tools=["update_state"], + tools=[system_runtime_tool("update_state")], ) brain = build_brain(cfg) llm = FakeLLM() @@ -1083,7 +1067,7 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase): async def test_update_state_rejects_undeclared_variable(self): cfg = AssistantConfig( type="prompt", - system_tools=["update_state"], + tools=[system_runtime_tool("update_state")], ) brain = build_brain(cfg) llm = FakeLLM() @@ -1108,7 +1092,7 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase): async def test_skip_turn_suppresses_response(self): cfg = AssistantConfig( type="prompt", - system_tools=["skip_turn"], + tools=[system_runtime_tool("skip_turn")], ) brain = build_brain(cfg) llm = FakeLLM() @@ -1134,7 +1118,7 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase): async def test_request_human_handoff_keeps_call_available(self): cfg = AssistantConfig( type="prompt", - system_tools=["request_human_handoff"], + tools=[system_runtime_tool("request_human_handoff")], ) brain = build_brain(cfg) llm = FakeLLM() @@ -1299,10 +1283,11 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase): "id": "agent", "type": "agent", "data": { - "systemTools": [ - "update_state", - "skip_turn", - "request_human_handoff", + "inheritGlobalConfig": False, + "toolIds": [ + "tool_update_state", + "tool_skip_turn", + "tool_request_human_handoff", ], "stateVariableNames": ["customer_name"], }, @@ -1322,6 +1307,11 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase): "default": None, }, }, + tools=[ + system_runtime_tool("update_state"), + system_runtime_tool("skip_turn"), + system_runtime_tool("request_human_handoff"), + ], ), {}, assistant_id="asst_workflow_system_tools", @@ -1462,7 +1452,10 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase): set_tools=lambda _tools: None, call_end=call_end, ) - tool = brain._workflow_end_conversation_tool() + tool = brain._workflow_end_conversation_tool( + system_runtime_tool("end_conversation"), + "agent", + ) await brain.on_assistant_text_start("turn-1") result = await tool.handler({"reason": "用户告别"}, None) diff --git a/backend/tests/test_workflow_v3.py b/backend/tests/test_workflow_v3.py index bc306cc..100b69c 100644 --- a/backend/tests/test_workflow_v3.py +++ b/backend/tests/test_workflow_v3.py @@ -1,12 +1,18 @@ from __future__ import annotations +import asyncio import unittest from copy import deepcopy from types import SimpleNamespace +from unittest.mock import AsyncMock from models import AssistantConfig, RuntimeModelResource from fastapi import HTTPException -from routes.assistants import _validate_workflow, _validate_workflow_references +from routes.assistants import ( + _validate_system_tool_selection, + _validate_workflow, + _validate_workflow_references, +) from schemas import AssistantUpsert from services.pipecat.service_factory import config_with_resource from services.node_specs import graph_references, normalize_graph, validate_graph @@ -363,7 +369,6 @@ class WorkflowGraphTests(unittest.TestCase): agent = next(node for node in graph["nodes"] if node["id"] == "agent") agent["data"].update( { - "systemTools": ["update_state", "skip_turn"], "stateVariableNames": ["customer", "order_status"], } ) @@ -374,7 +379,7 @@ class WorkflowGraphTests(unittest.TestCase): "defaultTtsResourceId": "tts_global", "visionEnabled": True, "visionModelResourceId": "vision_global", - "toolIds": ["tool_global"], + "toolIds": ["tool_global", "update_state", "skip_turn"], "knowledgeBaseId": "kb_global", "knowledgeMode": "on_demand", "knowledgeTopN": 8, @@ -395,8 +400,10 @@ class WorkflowGraphTests(unittest.TestCase): "vision_global", ) self.assertTrue(engine.uses_vision()) - self.assertEqual(inherited.tool_ids, ("tool_global",)) - self.assertEqual(inherited.system_tools, ("update_state", "skip_turn")) + self.assertEqual( + inherited.tool_ids, + ("tool_global", "update_state", "skip_turn"), + ) self.assertEqual( inherited.state_variable_names, ("customer", "order_status"), @@ -412,7 +419,7 @@ class WorkflowGraphTests(unittest.TestCase): { "inheritGlobalConfig": False, "llmResourceId": "llm_agent", - "toolIds": ["tool_agent"], + "toolIds": ["tool_agent", "update_state", "skip_turn"], "knowledgeBaseId": "", "visionEnabled": False, "visionModelResourceId": "", @@ -428,8 +435,10 @@ class WorkflowGraphTests(unittest.TestCase): self.assertFalse(custom.vision_enabled) self.assertIsNone(custom.vision_model_resource_id) self.assertFalse(engine.uses_vision()) - self.assertEqual(custom.tool_ids, ("tool_agent",)) - self.assertEqual(custom.system_tools, ("update_state", "skip_turn")) + self.assertEqual( + custom.tool_ids, + ("tool_agent", "update_state", "skip_turn"), + ) self.assertEqual(custom.knowledge_mode, "disabled") self.assertTrue(custom.enable_interrupt) self.assertEqual( @@ -463,7 +472,6 @@ class WorkflowGraphTests(unittest.TestCase): agent = next(node for node in graph["nodes"] if node["id"] == "agent") agent["data"].update( { - "systemTools": ["update_state"], "stateVariableNames": ["customer"], } ) @@ -497,8 +505,8 @@ class WorkflowGraphTests(unittest.TestCase): def test_agent_update_state_tool_requires_an_authorized_variable(self): graph = valid_graph() + graph["settings"]["toolIds"] = ["tool_update_state"] agent = next(node for node in graph["nodes"] if node["id"] == "agent") - agent["data"]["systemTools"] = ["update_state"] body = AssistantUpsert( name="缺少授权", type="workflow", @@ -512,8 +520,19 @@ class WorkflowGraphTests(unittest.TestCase): graph=graph, ) + _validate_workflow(body) + system_tool = SimpleNamespace( + id="tool_update_state", + status="active", + type="system", + definition={"config": {"kind": "update_state"}}, + ) + result = SimpleNamespace( + scalars=lambda: SimpleNamespace(all=lambda: [system_tool]) + ) + session = SimpleNamespace(execute=AsyncMock(return_value=result)) with self.assertRaisesRegex(HTTPException, "必须授权至少一个变量"): - _validate_workflow(body) + asyncio.run(_validate_system_tool_selection(session, body)) def test_vision_resource_creates_isolated_runtime_config(self): base = AssistantConfig(type="workflow", model="text-only") diff --git a/frontend/src/components/assistant-editor/editor-controls.tsx b/frontend/src/components/assistant-editor/editor-controls.tsx index 09ae158..203316a 100644 --- a/frontend/src/components/assistant-editor/editor-controls.tsx +++ b/frontend/src/components/assistant-editor/editor-controls.tsx @@ -8,7 +8,6 @@ import { ChevronLeft, Copy, Pencil, - PhoneOff, Plus, ServerCog, Settings2, @@ -19,7 +18,6 @@ import { } from "lucide-react"; import { HelpHint } from "@/components/editor/section-card"; -import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -47,10 +45,8 @@ import { import { Textarea } from "@/components/ui/textarea"; import type { KnowledgeRetrievalConfig, - SystemToolKind, Tool, } from "@/lib/api"; -import { SYSTEM_TOOL_OPTIONS } from "@/lib/system-tools"; import type { RuntimeMode } from "./types"; @@ -487,34 +483,20 @@ export function ToolPicker({ tools, selectedIds, onChange, - selectedSystemTools, - onSystemToolsChange, - showBuiltInSystemTools, }: { tools: Tool[]; selectedIds: string[]; onChange: (ids: string[]) => void; - selectedSystemTools: SystemToolKind[]; - onSystemToolsChange: (tools: SystemToolKind[]) => void; - showBuiltInSystemTools: boolean; }) { const [open, setOpen] = useState(false); const [activeTab, setActiveTab] = useState("system"); const [draftIds, setDraftIds] = useState(selectedIds); - const [draftSystemTools, setDraftSystemTools] = - useState(selectedSystemTools); const selectedTools = selectedIds .map((id) => tools.find((tool) => tool.id === id)) .filter((tool): tool is Tool => Boolean(tool)); - const selectedBuiltInTools = selectedSystemTools - .map((kind) => SYSTEM_TOOL_OPTIONS.find((option) => option.value === kind)) - .filter((option): option is (typeof SYSTEM_TOOL_OPTIONS)[number] => - Boolean(option), - ); function openPicker() { setDraftIds(selectedIds); - setDraftSystemTools(selectedSystemTools); setOpen(true); } @@ -526,14 +508,6 @@ export function ToolPicker({ ); } - function toggleBuiltInTool(kind: SystemToolKind) { - setDraftSystemTools((current) => - current.includes(kind) - ? current.filter((item) => item !== kind) - : [...current, kind], - ); - } - const tabs: Array<{ value: Tool["type"]; label: string }> = [ { value: "system", label: "System" }, { value: "http", label: "HTTP" }, @@ -544,34 +518,13 @@ export function ToolPicker({ return ( <>
- {selectedBuiltInTools.map((tool) => ( -
- - {tool.label} - -
- ))} {selectedTools.map((tool) => (
{tool.type === "system" ? ( - + ) : tool.type === "mcp" ? ( ) : ( @@ -606,7 +559,7 @@ export function ToolPicker({ 选择工具 - 按类型选择内置系统工具或已经启用的工具资源。 + 按类型选择已经启用的工具资源。 @@ -631,9 +584,7 @@ export function ToolPicker({ {tabs.map((tab) => { const resources = tools.filter((tool) => tool.type === tab.value); - const hasBuiltIns = - tab.value === "system" && showBuiltInSystemTools; - const isEmpty = resources.length === 0 && !hasBuiltIns; + const isEmpty = resources.length === 0; return ( @@ -643,35 +594,6 @@ export function ToolPicker({
) : (
- {hasBuiltIns && - SYSTEM_TOOL_OPTIONS.map((option) => { - const checked = draftSystemTools.includes(option.value); - return ( - - ); - })} - {resources.map((tool) => { const checked = draftIds.includes(tool.id); return ( @@ -686,13 +608,8 @@ export function ToolPicker({ className="size-4 accent-primary" />
-
- - {tool.name} - - {tool.type === "system" && ( - 资源 - )} +
+ {tool.name}
{tool.functionName} @@ -715,7 +632,6 @@ export function ToolPicker({