refactor: unify system tools as resources

This commit is contained in:
Xin Wang
2026-08-04 17:05:26 +08:00
parent d1b05f16c7
commit 74a8be2357
26 changed files with 788 additions and 507 deletions

View File

@@ -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),

View File

@@ -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": "显示确认消息",

View File

@@ -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'
"""
)

View File

@@ -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 PipelineWorkflow 在 Agent 节点中配置。
system_tools: list[str] = Field(default_factory=list)
knowledge_base_id: str | None = None
knowledge_base_name: str = ""
knowledge_base_description: str = ""

View File

@@ -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)

View File

@@ -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

View File

@@ -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 无法"
"解决时调用。该工具只提交请求,不代表人工已经接通;调用后继续"
"回复用户并说明正在等待人工响应。"

View File

@@ -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,
)

View File

@@ -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,

View File

@@ -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

View File

@@ -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(

View File

@@ -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)

View File

@@ -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)

View File

@@ -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")

View File

@@ -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<Tool["type"]>("system");
const [draftIds, setDraftIds] = useState<string[]>(selectedIds);
const [draftSystemTools, setDraftSystemTools] =
useState<SystemToolKind[]>(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 (
<>
<div className="flex min-h-9 flex-wrap items-center gap-2">
{selectedBuiltInTools.map((tool) => (
<div
key={`built-in-${tool.value}`}
className="flex h-8 items-center gap-2 rounded-lg border border-hairline-strong bg-background px-2.5 text-sm"
>
<Sparkles size={14} />
<span className="max-w-48 truncate">{tool.label}</span>
<button
type="button"
onClick={() =>
onSystemToolsChange(
selectedSystemTools.filter((kind) => kind !== tool.value),
)
}
className="text-muted-soft transition-colors hover:text-foreground"
aria-label={`移除系统工具 ${tool.label}`}
>
<X size={13} />
</button>
</div>
))}
{selectedTools.map((tool) => (
<div
key={tool.id}
className="flex h-8 items-center gap-2 rounded-lg border border-hairline-strong bg-background px-2.5 text-sm"
>
{tool.type === "system" ? (
<PhoneOff size={14} />
<Sparkles size={14} />
) : tool.type === "mcp" ? (
<ServerCog size={14} />
) : (
@@ -606,7 +559,7 @@ export function ToolPicker({
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
@@ -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 (
<TabsContent key={tab.value} value={tab.value} className="pt-3">
@@ -643,35 +594,6 @@ export function ToolPicker({
</div>
) : (
<div className="max-h-[280px] divide-y divide-hairline overflow-y-auto rounded-xl border border-hairline">
{hasBuiltIns &&
SYSTEM_TOOL_OPTIONS.map((option) => {
const checked = draftSystemTools.includes(option.value);
return (
<label
key={`built-in-${option.value}`}
className="flex h-14 cursor-pointer items-center gap-3 px-4 transition-colors hover:bg-surface-strong/40"
>
<input
type="checkbox"
checked={checked}
onChange={() => toggleBuiltInTool(option.value)}
className="size-4 accent-primary"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-medium text-foreground">
{option.label}
</span>
<Badge variant="secondary"></Badge>
</div>
<div className="mt-0.5 truncate text-xs text-muted-foreground">
{option.description}
</div>
</div>
</label>
);
})}
{resources.map((tool) => {
const checked = draftIds.includes(tool.id);
return (
@@ -686,13 +608,8 @@ export function ToolPicker({
className="size-4 accent-primary"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-medium text-foreground">
{tool.name}
</span>
{tool.type === "system" && (
<Badge variant="secondary"></Badge>
)}
<div className="truncate font-medium text-foreground">
{tool.name}
</div>
<div className="mt-0.5 truncate font-mono text-xs text-muted-foreground">
{tool.functionName}
@@ -715,7 +632,6 @@ export function ToolPicker({
<Button
onClick={() => {
onChange(draftIds);
onSystemToolsChange(draftSystemTools);
setOpen(false);
}}
>

View File

@@ -506,8 +506,15 @@ export function PromptEditor({
openingMessage: null,
});
}
if (runtimeMode === "realtime" && form.systemTools.length) {
updateForm("systemTools", []);
if (runtimeMode === "realtime") {
updateForm(
"toolIds",
form.toolIds.filter(
(id) =>
tools.find((tool) => tool.id === id)?.type !==
"system",
),
);
}
}}
/>
@@ -602,14 +609,13 @@ export function PromptEditor({
description="配置该提示词助手可以调用的工具"
>
<ToolPicker
tools={tools.filter((tool) => tool.status === "active")}
tools={tools.filter(
(tool) =>
tool.status === "active" &&
(form.runtimeMode === "pipeline" || tool.type !== "system"),
)}
selectedIds={form.toolIds}
onChange={(toolIds) => updateForm("toolIds", toolIds)}
selectedSystemTools={form.systemTools}
onSystemToolsChange={(systemTools) =>
updateForm("systemTools", systemTools)
}
showBuiltInSystemTools={form.runtimeMode === "pipeline"}
/>
</SectionCard>
</section>

View File

@@ -2,7 +2,6 @@ import type {
DynamicVariableDefinition,
KnowledgeRetrievalConfig,
StartupConfig,
SystemToolKind,
TurnConfig,
} from "@/lib/api";
@@ -23,7 +22,6 @@ export type AssistantForm = {
enableInterrupt: boolean;
turnConfig: TurnConfig;
startup: StartupConfig;
systemTools: SystemToolKind[];
visionEnabled: boolean;
visionModelResourceId: string;
toolIds: string[];

View File

@@ -184,7 +184,6 @@ function blankPromptForm(name: string): AssistantForm {
actions: [],
openingMessage: null,
},
systemTools: [],
visionEnabled: false,
visionModelResourceId: "",
toolIds: [],
@@ -480,7 +479,6 @@ export function AssistantPage(props: AssistantPageProps) {
actions: a.startup?.actions ?? [],
openingMessage: a.startup?.openingMessage ?? null,
},
systemTools: a.systemTools ?? [],
visionEnabled: a.visionEnabled,
visionModelResourceId: a.visionModelResourceId ?? "",
toolIds: a.toolIds ?? [],
@@ -557,7 +555,6 @@ export function AssistantPage(props: AssistantPageProps) {
actions: [],
openingMessage: null,
},
systemTools: [],
visionEnabled: false,
visionModelResourceId: null,
modelResourceIds: {},
@@ -617,7 +614,6 @@ export function AssistantPage(props: AssistantPageProps) {
enableInterrupt: form.enableInterrupt,
turnConfig: form.turnConfig,
startup: form.startup,
systemTools: form.runtimeMode === "pipeline" ? form.systemTools : [],
visionEnabled: form.visionEnabled,
visionModelResourceId: form.visionModelResourceId || null,
modelResourceIds: {
@@ -1304,10 +1300,16 @@ export function AssistantPage(props: AssistantPageProps) {
vision: visionModelOptionsFor(""),
}}
toolOptions={tools
.filter(
(tool) => tool.status === "active" && tool.type !== "system",
)
.map((tool) => ({ value: tool.id, label: tool.name }))}
.filter((tool) => tool.status === "active")
.map((tool) => ({
value: tool.id,
label: tool.name,
toolType: tool.type,
systemKind:
tool.definition.type === "system"
? tool.definition.config.kind
: undefined,
}))}
knowledgeOptions={kbOptions}
onBack={() => router.push("/assistants")}
onSave={() => void handleSaveWorkflow()}

View File

@@ -57,12 +57,14 @@ import {
type ClientToolResponseWaitMode,
type HttpToolDefinition,
type McpServer,
type SystemToolKind,
type Tool,
type ToolParameter,
type ToolExecutionMode,
type ToolStatus,
type ToolUpsert,
} from "@/lib/api";
import { SYSTEM_TOOL_OPTIONS } from "@/lib/system-tools";
type ToolKind = "system" | "http" | "client";
type HttpMethod = HttpToolDefinition["config"]["method"];
@@ -86,6 +88,7 @@ type ToolForm = {
type: ToolKind;
description: string;
status: ToolStatus;
systemKind: SystemToolKind;
messageType: "none" | "custom";
customMessage: string;
captureReason: boolean;
@@ -115,6 +118,7 @@ function blankForm(): ToolForm {
type: "system",
description: "",
status: "active",
systemKind: "end_conversation",
messageType: "none",
customMessage: "",
captureReason: true,
@@ -148,6 +152,7 @@ function formFromTool(tool: Tool): ToolForm {
base.description = tool.description;
base.status = tool.status;
if (tool.definition.type === "system") {
base.systemKind = tool.definition.config.kind;
base.messageType = tool.definition.config.messageType;
base.customMessage = tool.definition.config.customMessage;
base.captureReason = tool.definition.config.captureReason;
@@ -246,9 +251,13 @@ function payloadFromForm(form: ToolForm): ToolUpsert {
schemaVersion: 1,
type: "system",
config: {
kind: "end_conversation",
kind: form.systemKind,
messageType: form.messageType,
customMessage: form.messageType === "custom" ? form.customMessage : "",
customMessage:
form.systemKind === "end_conversation" &&
form.messageType === "custom"
? form.customMessage
: "",
captureReason: form.captureReason,
},
},
@@ -871,52 +880,80 @@ function SystemToolFields({
return (
<div className="space-y-4">
<Field label="系统动作">
<Select value="end_conversation" disabled>
<Select
value={form.systemKind}
onValueChange={(systemKind: SystemToolKind) =>
setForm((current) => ({
...current,
systemKind,
functionName: systemKind,
}))
}
>
<SelectTrigger className="w-full border-hairline-strong bg-background">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="end_conversation"></SelectItem>
{SYSTEM_TOOL_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field label="结束语">
<Select
value={form.messageType}
onValueChange={(messageType: "none" | "custom") =>
setForm((current) => ({ ...current, messageType }))
}
>
<SelectTrigger className="w-full border-hairline-strong bg-background"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
<SelectItem value="custom"></SelectItem>
</SelectContent>
</Select>
</Field>
{form.messageType === "custom" && (
<Field label="自定义结束语">
<Textarea
value={form.customMessage}
onChange={(event) =>
setForm((current) => ({ ...current, customMessage: event.target.value }))
}
rows={3}
/>
</Field>
<p className="text-xs leading-5 text-muted-foreground">
{SYSTEM_TOOL_OPTIONS.find((option) => option.value === form.systemKind)
?.description ?? "由平台执行的会话控制能力。"}
</p>
{form.systemKind === "end_conversation" && (
<>
<Field label="结束语">
<Select
value={form.messageType}
onValueChange={(messageType: "none" | "custom") =>
setForm((current) => ({ ...current, messageType }))
}
>
<SelectTrigger className="w-full border-hairline-strong bg-background">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
<SelectItem value="custom"></SelectItem>
</SelectContent>
</Select>
</Field>
{form.messageType === "custom" && (
<Field label="自定义结束语">
<Textarea
value={form.customMessage}
onChange={(event) =>
setForm((current) => ({
...current,
customMessage: event.target.value,
}))
}
rows={3}
/>
</Field>
)}
<div className="flex items-center justify-between gap-4 rounded-lg border border-hairline-strong px-4 py-3">
<div>
<div className="font-medium text-foreground"></div>
<div className="mt-0.5 text-xs text-muted-foreground">
reason
</div>
</div>
<Switch
checked={form.captureReason}
onCheckedChange={(captureReason) =>
setForm((current) => ({ ...current, captureReason }))
}
/>
</div>
</>
)}
<div className="flex items-center justify-between gap-4 rounded-lg border border-hairline-strong px-4 py-3">
<div>
<div className="font-medium text-foreground"></div>
<div className="mt-0.5 text-xs text-muted-foreground"> reason </div>
</div>
<Switch
checked={form.captureReason}
onCheckedChange={(captureReason) =>
setForm((current) => ({ ...current, captureReason }))
}
/>
</div>
</div>
);
}

View File

@@ -72,7 +72,6 @@ function defaultNodeData(spec: RuntimeNodeSpec): WorkflowNodeData {
contextPolicy: "inherit",
inheritGlobalConfig: true,
entryMode: "wait_user",
systemTools: [],
stateVariableNames: [],
});
} else if (spec.type === "action") {

View File

@@ -48,7 +48,7 @@ export function ActionNodePanel({
<NodeSelect
label="执行工具"
value={(draft.toolId as string) || ""}
options={toolOptions}
options={toolOptions.filter((option) => option.toolType !== "system")}
onChange={(value) => set("toolId", value || "")}
noneLabel="请选择工具"
/>

View File

@@ -16,8 +16,7 @@ import { VisionConfigSection } from "@/components/editor/vision-config-section";
import { TurnConfigEditor } from "@/components/turn-config-editor";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import type { KnowledgeRetrievalConfig, SystemToolKind } from "@/lib/api";
import { SYSTEM_TOOL_OPTIONS } from "@/lib/system-tools";
import type { KnowledgeRetrievalConfig } from "@/lib/api";
import { normalizeTurnConfig } from "@/lib/turn-config";
import { NodeSelect, ToolOptionPicker } from "./controls";
@@ -104,18 +103,14 @@ export function AgentNodePanel({
turnConfig: agentTurnConfig,
});
};
const toggleSystemTool = (kind: SystemToolKind, enabled: boolean) => {
const current = draft.systemTools ?? [];
const systemTools = enabled
? [...new Set([...current, kind])]
: current.filter((item) => item !== kind);
setPatch({
systemTools,
...(!enabled && kind === "update_state"
? { stateVariableNames: [] }
: {}),
});
};
const selectedToolIds = inheritsGlobal
? workflowSettings.toolIds
: draft.toolIds ?? [];
const updateStateEnabled = selectedToolIds.some(
(toolId) =>
toolOptions.find((option) => option.value === toolId)?.systemKind ===
"update_state",
);
const toggleStateVariable = (name: string, enabled: boolean) => {
const current = draft.stateVariableNames ?? [];
set(
@@ -133,7 +128,9 @@ export function AgentNodePanel({
{ id: "scope", label: "配置范围" },
{ id: "prompt", label: inheritsGlobal ? "任务" : "提示词" },
{ id: "entry", label: "进入行为" },
{ id: "system-tools", label: "系统工具" },
...(updateStateEnabled
? [{ id: "state-scope", label: "状态更新权限" }]
: []),
...(!inheritsGlobal
? [
{ id: "models", label: "模型与语音" },
@@ -211,77 +208,43 @@ export function AgentNodePanel({
</SectionCard>
</PanelAnchor>
<PanelAnchor id="system-tools">
<SectionCard
icon={<Sparkles size={15} />}
title="系统工具"
description="只对当前 Agent 生效的内置会话控制能力"
>
<div className="space-y-3">
{SYSTEM_TOOL_OPTIONS.map((option) => {
const enabled = (draft.systemTools ?? []).includes(option.value);
return (
<div
key={option.value}
className="rounded-xl border border-hairline bg-canvas-soft p-3.5"
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">
{option.label}
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
{option.description}
</p>
</div>
{updateStateEnabled && (
<PanelAnchor id="state-scope">
<SectionCard
icon={<Sparkles size={15} />}
title="状态更新权限"
description="更新状态工具只允许写入这里授权的动态变量"
>
{dynamicVariableOptions.length ? (
<div className="space-y-2">
{dynamicVariableOptions.map((variable) => (
<div
key={variable.value}
className="flex items-center justify-between gap-3 rounded-lg border border-hairline bg-background px-3 py-2"
>
<span className="truncate text-xs text-foreground">
{variable.label}
</span>
<Switch
checked={enabled}
checked={(draft.stateVariableNames ?? []).includes(
variable.value,
)}
onCheckedChange={(checked) =>
toggleSystemTool(option.value, checked)
toggleStateVariable(variable.value, checked)
}
aria-label={`启用${option.label}`}
aria-label={`允许更新${variable.value}`}
/>
</div>
{option.value === "update_state" && enabled && (
<div className="mt-3 border-t border-hairline pt-3">
<div className="mb-2 text-xs font-medium text-foreground">
</div>
{dynamicVariableOptions.length ? (
<div className="space-y-2">
{dynamicVariableOptions.map((variable) => (
<div
key={variable.value}
className="flex items-center justify-between gap-3 rounded-lg border border-hairline bg-background px-3 py-2"
>
<span className="truncate text-xs text-foreground">
{variable.label}
</span>
<Switch
checked={(draft.stateVariableNames ?? []).includes(
variable.value,
)}
onCheckedChange={(checked) =>
toggleStateVariable(variable.value, checked)
}
aria-label={`允许更新${variable.value}`}
/>
</div>
))}
</div>
) : (
<p className="text-xs leading-5 text-muted-foreground">
</p>
)}
</div>
)}
</div>
);
})}
</div>
</SectionCard>
</PanelAnchor>
))}
</div>
) : (
<p className="text-xs leading-5 text-muted-foreground">
</p>
)}
</SectionCard>
</PanelAnchor>
)}
{!inheritsGlobal && (
<>

View File

@@ -1,6 +1,6 @@
"use client";
import { Plus, Wrench, X } from "lucide-react";
import { Plus, ServerCog, Sparkles, Wrench, X } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
@@ -19,6 +19,13 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs";
import type { Tool } from "@/lib/api";
import type { ModelOption } from "../types";
@@ -70,10 +77,17 @@ export function ToolOptionPicker({
onChange: (ids: string[]) => void;
}) {
const [open, setOpen] = useState(false);
const [activeTab, setActiveTab] = useState<Tool["type"]>("system");
const [draftIds, setDraftIds] = useState<string[]>(selectedIds);
const selected = selectedIds
.map((id) => options.find((option) => option.value === id))
.filter((option): option is ModelOption => Boolean(option));
const tabs: Array<{ value: Tool["type"]; label: string }> = [
{ value: "system", label: "System" },
{ value: "http", label: "HTTP" },
{ value: "client", label: "Client" },
{ value: "mcp", label: "MCP" },
];
return (
<>
@@ -83,7 +97,13 @@ export function ToolOptionPicker({
key={option.value}
className="flex h-8 items-center gap-2 rounded-lg border border-hairline-strong bg-background px-2.5 text-sm"
>
<Wrench size={14} />
{option.toolType === "system" ? (
<Sparkles size={14} />
) : option.toolType === "mcp" ? (
<ServerCog size={14} />
) : (
<Wrench size={14} />
)}
<span className="max-w-48 truncate">{option.label}</span>
<button
type="button"
@@ -119,39 +139,69 @@ export function ToolOptionPicker({
<DialogTitle></DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
{options.length === 0 ? (
<div className="rounded-xl border border-dashed border-hairline-strong px-4 py-10 text-center text-sm text-muted-foreground">
</div>
) : (
<div className="max-h-80 divide-y divide-hairline overflow-y-auto rounded-xl border border-hairline">
{options.map((option) => {
const checked = draftIds.includes(option.value);
return (
<label
key={option.value}
className="flex cursor-pointer items-center gap-3 px-4 py-3 transition-colors hover:bg-surface-strong/40"
>
<input
type="checkbox"
checked={checked}
onChange={() =>
setDraftIds((current) =>
checked
? current.filter((id) => id !== option.value)
: [...current, option.value],
)
}
className="size-4 accent-primary"
/>
<span className="truncate font-medium text-foreground">
{option.label}
</span>
</label>
);
})}
</div>
)}
<Tabs
value={activeTab}
onValueChange={(value) => setActiveTab(value as Tool["type"])}
>
<TabsList
variant="line"
className="w-full justify-start border-b border-hairline px-1"
>
{tabs.map((tab) => (
<TabsTrigger
key={tab.value}
value={tab.value}
className="flex-none px-4"
>
{tab.label}
</TabsTrigger>
))}
</TabsList>
{tabs.map((tab) => {
const rows = options.filter(
(option) => (option.toolType ?? "http") === tab.value,
);
return (
<TabsContent key={tab.value} value={tab.value} className="pt-3">
{rows.length === 0 ? (
<div className="rounded-xl border border-dashed border-hairline-strong px-4 py-10 text-center text-sm text-muted-foreground">
{tab.label}
</div>
) : (
<div className="max-h-[280px] divide-y divide-hairline overflow-y-auto rounded-xl border border-hairline">
{rows.map((option) => {
const checked = draftIds.includes(option.value);
return (
<label
key={option.value}
className="flex h-14 cursor-pointer items-center gap-3 px-4 transition-colors hover:bg-surface-strong/40"
>
<input
type="checkbox"
checked={checked}
onChange={() =>
setDraftIds((current) =>
checked
? current.filter(
(id) => id !== option.value,
)
: [...current, option.value],
)
}
className="size-4 accent-primary"
/>
<span className="truncate font-medium text-foreground">
{option.label}
</span>
</label>
);
})}
</div>
)}
</TabsContent>
);
})}
</Tabs>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
@@ -214,4 +264,3 @@ export function NodeSelect({
</div>
);
}

View File

@@ -3,7 +3,7 @@
import * as LucideIcons from "lucide-react";
import { Circle, type LucideIcon } from "lucide-react";
import type { NodeSpecDto, SystemToolKind, TurnConfig } from "@/lib/api";
import type { NodeSpecDto, TurnConfig } from "@/lib/api";
import { defaultTurnConfig } from "@/lib/turn-config";
export type WorkflowNodeType =
@@ -41,7 +41,6 @@ export type WorkflowNodeData = {
contextPolicy?: ContextPolicy;
inheritGlobalConfig?: boolean;
entryMode?: AgentEntryMode;
systemTools?: SystemToolKind[];
stateVariableNames?: string[];
toolIds?: string[];
knowledgeBaseId?: string;
@@ -283,7 +282,6 @@ export function defaultGraph(): WorkflowGraph {
contextPolicy: "inherit",
inheritGlobalConfig: true,
entryMode: "wait_user",
systemTools: [],
stateVariableNames: [],
},
},

View File

@@ -1,6 +1,11 @@
import type { ReactNode } from "react";
import type { KnowledgeRetrievalConfig, TurnConfig } from "@/lib/api";
import type {
KnowledgeRetrievalConfig,
SystemToolKind,
Tool,
TurnConfig,
} from "@/lib/api";
import type { WorkflowGraph } from "./specs";
@@ -18,7 +23,13 @@ export type WorkflowSettings = {
turnConfig: TurnConfig;
};
export type ModelOption = { value: string; label: string; disabled?: boolean };
export type ModelOption = {
value: string;
label: string;
disabled?: boolean;
toolType?: Tool["type"];
systemKind?: SystemToolKind;
};
export type WorkflowEditorProps = {
value?: WorkflowGraph;

View File

@@ -232,7 +232,6 @@ export type Assistant = {
enableInterrupt: boolean;
turnConfig: TurnConfig;
startup: StartupConfig;
systemTools: SystemToolKind[];
visionEnabled: boolean;
visionModelResourceId: string | null;
modelResourceIds: Partial<Record<ModelType, string>>;
@@ -365,7 +364,7 @@ export type SystemToolDefinition = {
schemaVersion: number;
type: "system";
config: {
kind: "end_conversation";
kind: SystemToolKind;
messageType: "none" | "custom";
customMessage: string;
captureReason: boolean;