refactor: unify system tools as resources
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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": "显示确认消息",
|
||||
|
||||
@@ -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'
|
||||
"""
|
||||
)
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 无法"
|
||||
"解决时调用。该工具只提交请求,不代表人工已经接通;调用后继续"
|
||||
"回复用户并说明正在等待人工响应。"
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,7 +72,6 @@ function defaultNodeData(spec: RuntimeNodeSpec): WorkflowNodeData {
|
||||
contextPolicy: "inherit",
|
||||
inheritGlobalConfig: true,
|
||||
entryMode: "wait_user",
|
||||
systemTools: [],
|
||||
stateVariableNames: [],
|
||||
});
|
||||
} else if (spec.type === "action") {
|
||||
|
||||
@@ -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="请选择工具"
|
||||
/>
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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: [],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user