feat: add system tools and state updates

This commit is contained in:
Xin Wang
2026-08-04 15:27:39 +08:00
parent 5bf5987fe4
commit 731a372df9
33 changed files with 1787 additions and 124 deletions

View File

@@ -154,6 +154,8 @@ class Assistant(Base):
enable_interrupt: Mapped[bool] = mapped_column(Boolean, default=True) enable_interrupt: Mapped[bool] = mapped_column(Boolean, default=True)
turn_config: Mapped[dict] = mapped_column(JSON, default=dict) turn_config: Mapped[dict] = mapped_column(JSON, default=dict)
startup: 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_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
vision_model_resource_id: Mapped[str | None] = mapped_column( vision_model_resource_id: Mapped[str | None] = mapped_column(
String(40), String(40),

View File

@@ -54,12 +54,13 @@ async def sync_default_tools() -> None:
"id": "tool_end_call_default", "id": "tool_end_call_default",
"name": "结束对话", "name": "结束对话",
"function_name": "end_call", "function_name": "end_call",
"type": "end_call", "type": "system",
"description": "当用户明确要求结束对话,或任务已完成时调用。", "description": "当用户明确要求结束对话,或任务已完成时调用。",
"definition": { "definition": {
"schema_version": 1, "schema_version": 1,
"type": "end_call", "type": "system",
"config": { "config": {
"kind": "end_conversation",
"message_type": "none", "message_type": "none",
"custom_message": "", "custom_message": "",
"capture_reason": True, "capture_reason": True,

View File

@@ -0,0 +1,32 @@
"""add assistant system tools
Revision ID: 20260804_0010
Revises: 20260801_0009
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
revision: str = "20260804_0010"
down_revision: str | Sequence[str] | None = "20260801_0009"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"assistants",
sa.Column(
"system_tools",
sa.JSON(),
server_default=sa.text("'[]'"),
nullable=False,
),
)
def downgrade() -> None:
op.drop_column("assistants", "system_tools")

View File

@@ -0,0 +1,44 @@
"""move end conversation tools into the system category
Revision ID: 20260804_0011
Revises: 20260804_0010
"""
from collections.abc import Sequence
from alembic import op
revision: str = "20260804_0011"
down_revision: str | Sequence[str] | None = "20260804_0010"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute(
"""
UPDATE tools
SET type = 'system',
definition = jsonb_set(
jsonb_set(definition, '{type}', '"system"'::jsonb),
'{config,kind}',
'"end_conversation"'::jsonb,
true
)
WHERE type = 'end_call'
"""
)
def downgrade() -> None:
op.execute(
"""
UPDATE tools
SET type = 'end_call',
definition = (definition #- '{config,kind}')
|| '{"type":"end_call"}'::jsonb
WHERE type = 'system'
AND definition #>> '{config,kind}' = 'end_conversation'
"""
)

View File

@@ -114,6 +114,8 @@ class AssistantConfig(BaseModel):
# every item in ``tools``. # every item in ``tools``.
tools: list[RuntimeTool] = Field(default_factory=list) tools: list[RuntimeTool] = Field(default_factory=list)
llm_tool_ids: list[str] | None = None 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_id: str | None = None
knowledge_base_name: str = "" knowledge_base_name: str = ""
knowledge_base_description: str = "" knowledge_base_description: str = ""

View File

@@ -36,6 +36,35 @@ def _validate_workflow(body: AssistantUpsert) -> None:
return return
body.graph = normalize_graph(body.graph or {}) body.graph = normalize_graph(body.graph or {})
errors = validate_graph(body.graph) errors = validate_graph(body.graph)
declared_variables = set(body.dynamic_variable_definitions)
for node in body.graph.get("nodes") or []:
node_id = str(node.get("id") or "")
node_type = node.get("type")
data = node.get("data") or {}
if node_type == "update_state":
unknown = sorted(
set((data.get("assignments") or {}).keys()) - declared_variables
)
if unknown:
errors.append(
f"Update State 节点 {node_id} 引用了未声明变量:"
+ ",".join(unknown)
)
elif node_type == "agent":
authorized = set(data.get("stateVariableNames") or [])
unknown = sorted(authorized - declared_variables)
if unknown:
errors.append(
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: if errors:
raise HTTPException(400, "工作流校验失败:" + ";".join(errors)) raise HTTPException(400, "工作流校验失败:" + ";".join(errors))
# Graph settings are the source of truth. The flat flag is only a session # Graph settings are the source of truth. The flat flag is only a session
@@ -283,6 +312,7 @@ async def _to_out(session: AsyncSession, assistant: Assistant) -> AssistantOut:
enable_interrupt=assistant.enable_interrupt, enable_interrupt=assistant.enable_interrupt,
turn_config=assistant.turn_config or {}, turn_config=assistant.turn_config or {},
startup=assistant.startup or {}, startup=assistant.startup or {},
system_tools=assistant.system_tools or [],
vision_enabled=assistant.vision_enabled, vision_enabled=assistant.vision_enabled,
vision_model_resource_id=assistant.vision_model_resource_id, vision_model_resource_id=assistant.vision_model_resource_id,
model_resource_ids=await _resource_ids(session, assistant.id), model_resource_ids=await _resource_ids(session, assistant.id),
@@ -359,6 +389,7 @@ async def duplicate_assistant(
enable_interrupt=source.enable_interrupt, enable_interrupt=source.enable_interrupt,
turn_config=dict(source.turn_config or {}), turn_config=dict(source.turn_config or {}),
startup=dict(source.startup or {}), startup=dict(source.startup or {}),
system_tools=list(source.system_tools or []),
vision_enabled=source.vision_enabled, vision_enabled=source.vision_enabled,
vision_model_resource_id=source.vision_model_resource_id, vision_model_resource_id=source.vision_model_resource_id,
knowledge_base_id=source.knowledge_base_id, knowledge_base_id=source.knowledge_base_id,

View File

@@ -19,7 +19,7 @@ ModelType = Literal["LLM", "ASR", "TTS", "Realtime", "Embedding", "Agent"]
AssistantType = Literal["prompt", "workflow", "dify", "fastgpt", "opencode"] AssistantType = Literal["prompt", "workflow", "dify", "fastgpt", "opencode"]
TurnEndStrategy = Literal["silence", "smart_turn"] TurnEndStrategy = Literal["silence", "smart_turn"]
KnowledgeRetrievalMode = Literal["automatic", "on_demand"] KnowledgeRetrievalMode = Literal["automatic", "on_demand"]
ToolType = Literal["end_call", "http", "mcp", "client"] ToolType = Literal["system", "http", "mcp", "client"]
ToolStatus = Literal["active", "archived", "draft"] ToolStatus = Literal["active", "archived", "draft"]
McpServerStatus = Literal["active", "archived", "draft"] McpServerStatus = Literal["active", "archived", "draft"]
McpTransport = Literal["streamable_http", "sse"] McpTransport = Literal["streamable_http", "sse"]
@@ -30,6 +30,12 @@ ClientToolResponseWaitMode = Literal["timeout", "session"]
DynamicVariableType = Literal["string", "number", "boolean"] DynamicVariableType = Literal["string", "number", "boolean"]
PromptEntryMode = Literal["wait_user", "generate"] PromptEntryMode = Literal["wait_user", "generate"]
PromptOpeningMode = Literal["interruptible", "playback", "confirmation"] PromptOpeningMode = Literal["interruptible", "playback", "confirmation"]
SystemToolKind = Literal[
"end_conversation",
"update_state",
"skip_turn",
"request_human_handoff",
]
# 外部应用类型:其 config.apiKey 是该助手私有密钥,读时打码 / 写时哨兵 # 外部应用类型:其 config.apiKey 是该助手私有密钥,读时打码 / 写时哨兵
EXTERNAL_TYPES = {"dify", "fastgpt", "opencode"} EXTERNAL_TYPES = {"dify", "fastgpt", "opencode"}
@@ -153,6 +159,8 @@ class AssistantUpsert(CamelModel):
startup: StartupConfig = Field(default_factory=StartupConfig) startup: StartupConfig = Field(default_factory=StartupConfig)
vision_enabled: bool = False vision_enabled: bool = False
vision_model_resource_id: str | None = None 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) model_resource_ids: dict[ModelType, str] = Field(default_factory=dict)
knowledge_base_id: str | None = None knowledge_base_id: str | None = None
@@ -191,6 +199,8 @@ class AssistantUpsert(CamelModel):
for field in ("prompt", "api_url", "api_key", "app_id"): for field in ("prompt", "api_url", "api_key", "app_id"):
if field not in allowed: if field not in allowed:
setattr(self, field, "") setattr(self, field, "")
if self.type != "prompt":
self.system_tools = []
if "graph" not in allowed: if "graph" not in allowed:
self.graph = {} self.graph = {}
if self.type == "workflow": if self.type == "workflow":
@@ -210,6 +220,15 @@ class AssistantUpsert(CamelModel):
# 外部托管大脑只能 cascade,拦住不兼容的 realtime # 外部托管大脑只能 cascade,拦住不兼容的 realtime
if self.runtime_mode == "realtime" and self.type not in REALTIME_CAPABLE_TYPES: if self.runtime_mode == "realtime" and self.type not in REALTIME_CAPABLE_TYPES:
raise ValueError(f"类型 {self.type} 不支持 realtime 运行模式") 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 return self
@@ -250,7 +269,8 @@ class ToolParameter(CamelModel):
required: bool = True required: bool = True
class EndCallToolConfig(CamelModel): class SystemToolConfig(CamelModel):
kind: Literal["end_conversation"] = "end_conversation"
message_type: Literal["none", "custom"] = "none" message_type: Literal["none", "custom"] = "none"
custom_message: str = "" custom_message: str = ""
capture_reason: bool = True capture_reason: bool = True
@@ -275,10 +295,10 @@ class HttpToolConfig(CamelModel):
return value return value
class EndCallToolDefinition(CamelModel): class SystemToolDefinition(CamelModel):
schema_version: int = 1 schema_version: int = 1
type: Literal["end_call"] = "end_call" type: Literal["system"] = "system"
config: EndCallToolConfig = Field(default_factory=EndCallToolConfig) config: SystemToolConfig = Field(default_factory=SystemToolConfig)
class HttpToolDefinition(CamelModel): class HttpToolDefinition(CamelModel):
@@ -326,7 +346,7 @@ class McpToolDefinition(CamelModel):
ToolDefinition = Annotated[ ToolDefinition = Annotated[
Union[ Union[
EndCallToolDefinition, SystemToolDefinition,
HttpToolDefinition, HttpToolDefinition,
ClientToolDefinition, ClientToolDefinition,
McpToolDefinition, McpToolDefinition,

View File

@@ -43,14 +43,14 @@ from services.message_stage import (
MessageStageRunner, MessageStageRunner,
MessageStageSpec, MessageStageSpec,
) )
from services.runtime_variables import DynamicVariableStore from services.runtime_variables import DynamicVariableError, DynamicVariableStore
from services.system_tools import SYSTEM_TOOL_KINDS, state_update_properties
from services.tool_executor import ToolExecutionError, ToolExecutor from services.tool_executor import ToolExecutionError, ToolExecutor
from services.tool_policy import policy_for_tool from services.tool_policy import policy_for_tool
PREFLIGHT_TIMEOUT_SECONDS = 30 PREFLIGHT_TIMEOUT_SECONDS = 30
class PromptBrain(BaseBrain): class PromptBrain(BaseBrain):
spec = BrainSpec( spec = BrainSpec(
type="prompt", type="prompt",
@@ -123,22 +123,37 @@ class PromptBrain(BaseBrain):
set(cfg.llm_tool_ids) if cfg.llm_tool_ids is not None else None set(cfg.llm_tool_ids) if cfg.llm_tool_ids is not None else None
) )
schemas: list[FunctionSchema] = [] schemas: list[FunctionSchema] = []
registered_names: set[str] = set()
for tool in cfg.tools: for tool in cfg.tools:
if llm_tool_ids is not None and tool.id not in llm_tool_ids: if llm_tool_ids is not None and tool.id not in llm_tool_ids:
continue continue
if tool.type == "end_call": if tool.type == "system":
schema, handler = self._make_end_call_tool(tool, runtime) schema, handler = self._make_end_call_tool(tool, runtime)
elif tool.type in {"http", "mcp", "client"}: elif tool.type in {"http", "mcp", "client"}:
schema, handler = self._make_remote_tool(tool, runtime) schema, handler = self._make_remote_tool(tool, runtime)
else: else:
continue continue
schemas.append(schema) schemas.append(schema)
registered_names.add(schema.name)
policy = policy_for_tool(tool) policy = policy_for_tool(tool)
runtime.llm.register_function( runtime.llm.register_function(
tool.function_name, tool.function_name,
handler, handler,
cancel_on_interruption=policy.cancel_on_interruption, 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) runtime.set_tools(schemas)
async def run_preflight(self) -> None: async def run_preflight(self) -> None:
@@ -582,3 +597,162 @@ class PromptBrain(BaseBrain):
required=["reason"] if capture_reason else [], required=["reason"] if capture_reason else [],
) )
return schema, end_call return schema, end_call
# ---------- 内置系统工具(助手配置 system_tools 开关) ----------
def _make_system_tool(self, kind: str, runtime: BrainRuntime):
if kind == "end_conversation":
return self._make_end_conversation_tool(runtime)
if kind == "update_state":
return self._make_update_state_tool(runtime)
if kind == "skip_turn":
return self._make_skip_turn_tool()
if kind == "request_human_handoff":
return self._make_handoff_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):
"""更新已声明的动态变量(会话状态),并让模型继续当前回答。"""
writable = state_update_properties(self._cfg.dynamic_variable_definitions)
async def update_state(params: FunctionCallParams) -> None:
state = dict(params.arguments or {})
try:
changed = self._store.assign_declared_many(state)
except DynamicVariableError as exc:
await params.result_callback(
{"status": "error", "message": f"状态更新失败: {exc}"}
)
return
if changed:
self._refresh_prompt()
await runtime.queue_frame(
OutputTransportMessageUrgentFrame(
message={
"type": "session-variables",
"reason": "update_state",
"variables": self._store.public_values(),
"changed": changed,
}
)
)
await params.result_callback(
{
"status": "success",
"changed": changed,
"variables": self._store.public_values(),
}
)
schema = FunctionSchema(
name="update_state",
description=(
"静默更新本次对话中已经声明并明确列出的动态变量。"
"只提交本轮获得或确认的信息,更新后继续当前回答。"
),
properties=writable,
required=[],
)
return schema, update_state
def _make_skip_turn_tool(self):
"""跳过当前轮次,不生成任何语音回复。"""
async def skip_turn(params: FunctionCallParams) -> None:
reason = str(params.arguments.get("reason") or "").strip()
result = {"status": "success", "action": "skip_turn"}
if reason:
result["reason"] = reason
await params.result_callback(
result,
properties=FunctionCallResultProperties(run_llm=False),
)
schema = FunctionSchema(
name="skip_turn",
description=(
"跳过当前轮次,不生成任何语音回复。"
"仅当用户明确要求稍等、话还没说完,或输入可确认只是噪音时调用。"
),
properties={
"reason": {
"type": "string",
"description": "跳过本轮的原因(可选)。",
}
},
required=[],
)
return schema, skip_turn
def _make_handoff_tool(self, runtime: BrainRuntime):
"""提交人工接管请求;请求完成前保持当前 AI 会话可用。"""
async def request_human_handoff(params: FunctionCallParams) -> None:
reason = str(
params.arguments.get("reason") or "human_handoff"
).strip()
await runtime.queue_frame(
OutputTransportMessageUrgentFrame(
message={
"type": "handoff-requested",
"source": "prompt-system-tool",
"reason": reason,
"message": "用户请求转接人工服务。",
}
)
)
await params.result_callback(
{
"status": "requested",
"action": "human_handoff_requested",
"message": "人工接管请求已提交,请告知用户正在等待人工响应。",
}
)
schema = FunctionSchema(
name="request_human_handoff",
description=(
"提交人工接管请求。当用户明确要求人工服务、投诉升级或 AI 无法"
"解决时调用。该工具只提交请求,不代表人工已经接通;调用后继续"
"回复用户并说明正在等待人工响应。"
),
properties={
"reason": {
"type": "string",
"description": "转接人工的原因。",
}
},
required=[],
)
return schema, request_human_handoff

View File

@@ -56,7 +56,8 @@ from services.message_stage import (
MessageStageRunner, MessageStageRunner,
MessageStageSpec, MessageStageSpec,
) )
from services.runtime_variables import DynamicVariableStore from services.runtime_variables import DynamicVariableError, DynamicVariableStore
from services.system_tools import SYSTEM_TOOL_KINDS, state_update_properties
from services.tool_executor import ToolExecutionError, ToolExecutor from services.tool_executor import ToolExecutionError, ToolExecutor
from services.tool_policy import policy_for_tool from services.tool_policy import policy_for_tool
from services.workflow.agent import WorkflowAgentStage from services.workflow.agent import WorkflowAgentStage
@@ -184,6 +185,7 @@ class WorkflowBrain(BaseBrain):
self._output: WorkflowOutput | None = None self._output: WorkflowOutput | None = None
self._agent_stage: WorkflowAgentStage | None = None self._agent_stage: WorkflowAgentStage | None = None
self._ended = False self._ended = False
self._waiting_for_generated_end_speech = False
self._next_message_token = 1 self._next_message_token = 1
self._pending_message: _MessageContinuation | None = None self._pending_message: _MessageContinuation | None = None
@@ -229,6 +231,7 @@ class WorkflowBrain(BaseBrain):
runtime=runtime, runtime=runtime,
) )
self._ended = False self._ended = False
self._waiting_for_generated_end_speech = False
self._next_message_token = 1 self._next_message_token = 1
self._pending_message = None self._pending_message = None
self._manager = ConfiguredFlowManager( self._manager = ConfiguredFlowManager(
@@ -494,18 +497,30 @@ class WorkflowBrain(BaseBrain):
return None return None
return decision.edge return decision.edge
async def on_assistant_text_start(self, _turn_id: str) -> None:
if self._runtime is not None:
self._runtime.call_end.begin_response()
async def on_assistant_text_end( async def on_assistant_text_end(
self, self,
_turn_id: str, _turn_id: str,
content: str, content: str,
interrupted: bool, interrupted: bool,
) -> None: ) -> None:
if not content or interrupted or self._ended: if content and not interrupted and not self._ended:
return self._store.record("agent", content, completed_agent_turn=True)
self._store.record("agent", content, completed_agent_turn=True) self._state.consume_user_turn()
self._state.consume_user_turn() if self._engine.node_type(self._state.current_node_id) == "agent":
if self._engine.node_type(self._state.current_node_id) == "agent": self._state.status = WorkflowStatus.WAITING_USER
self._state.status = WorkflowStatus.WAITING_USER if (
self._waiting_for_generated_end_speech
and self._runtime is not None
and self._runtime.call_end.ending
):
self._waiting_for_generated_end_speech = False
await self._runtime.call_end.finish_after_current_speech(
has_text=bool(content.strip()) and not interrupted
)
async def _refresh_agent_prompt(self, node_id: str) -> None: async def _refresh_agent_prompt(self, node_id: str) -> None:
await self._require_agent_stage().refresh_prompt(node_id) await self._require_agent_stage().refresh_prompt(node_id)
@@ -528,15 +543,43 @@ class WorkflowBrain(BaseBrain):
) -> NodeConfig: ) -> NodeConfig:
stage = self._engine.agent_stage_config(node_id) stage = self._engine.agent_stage_config(node_id)
functions: list[FlowsFunctionSchema] = [] functions: list[FlowsFunctionSchema] = []
registered_names: set[str] = set()
def append_function(function: FlowsFunctionSchema | None) -> None:
if function is None:
return
function_name = getattr(function, "name", "")
if not function_name:
# Runtime-provided global functions are opaque in a few
# adapters; FlowManager remains responsible for those names.
functions.append(function)
return
if function_name in registered_names:
logger.warning(
f"跳过 Agent {node_id} 的函数 {function_name}: 函数名冲突"
)
return
registered_names.add(function_name)
functions.append(function)
for tool_id in stage.tool_ids: for tool_id in stage.tool_ids:
tool = self._tool_by_id.get(str(tool_id)) tool = self._tool_by_id.get(str(tool_id))
if tool and tool.type in {"http", "mcp", "client"}: if tool and tool.type in {"http", "mcp", "client"}:
functions.append(self._flow_tool(tool, node_id)) append_function(self._flow_tool(tool, node_id))
knowledge_function = self._knowledge_function(node_id) append_function(self._knowledge_function(node_id))
if knowledge_function:
functions.append(knowledge_function)
if stage.vision_enabled and self._require_runtime().vision_function: if stage.vision_enabled and self._require_runtime().vision_function:
functions.append(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( return self._require_agent_stage().node_config(
node_id, node_id,
functions=functions, functions=functions,
@@ -693,6 +736,159 @@ class WorkflowBrain(BaseBrain):
), ),
) )
def _workflow_system_tool(
self,
kind: str,
*,
node_id: str,
state_variable_names: tuple[str, ...],
) -> FlowsFunctionSchema:
"""Build one platform-owned tool scoped to the active Agent node."""
if kind == "update_state":
return self._workflow_update_state_tool(
node_id,
state_variable_names=state_variable_names,
)
if kind == "skip_turn":
return self._workflow_skip_turn_tool()
if kind == "request_human_handoff":
return self._workflow_handoff_tool(node_id)
if kind == "end_conversation":
return self._workflow_end_conversation_tool()
raise ValueError(f"未知系统工具: {kind}")
def _workflow_update_state_tool(
self,
node_id: str,
*,
state_variable_names: tuple[str, ...],
) -> FlowsFunctionSchema:
allowed = frozenset(state_variable_names)
async def handler(args, _flow_manager):
values = dict(args or {})
unauthorized = sorted(set(values) - allowed)
if unauthorized:
return {
"status": "error",
"message": "状态变量未获当前节点授权: " + ",".join(unauthorized),
}
try:
changed = self._store.assign_declared_many(values)
except DynamicVariableError as exc:
return {"status": "error", "message": f"状态更新失败: {exc}"}
if changed:
await self._emit_variables(
reason="update_state",
node_id=node_id,
changed=changed,
)
await self._refresh_agent_prompt(node_id)
return {
"status": "success",
"changed": changed,
"variables": self._store.public_values(),
}
return FlowsFunctionSchema(
name="update_state",
description=(
"静默更新当前阶段明确授权的动态变量。"
"只提交本轮获得或确认的信息,更新后继续当前回答。"
),
properties=state_update_properties(
self._cfg.dynamic_variable_definitions if self._cfg else {},
allowed_names=state_variable_names,
),
required=[],
handler=handler,
)
@staticmethod
def _workflow_skip_turn_tool() -> FlowsFunctionSchema:
async def handler(args, _flow_manager):
reason = str((args or {}).get("reason") or "").strip()
result = {"status": "success", "action": "skip_turn"}
if reason:
result["reason"] = reason
return result
setattr(handler, "_suppress_followup_llm", True)
return FlowsFunctionSchema(
name="skip_turn",
description=(
"跳过当前轮次,不生成任何语音回复。"
"仅当用户明确要求稍等、话还没说完,或输入可确认只是噪音时调用。"
),
properties={
"reason": {
"type": "string",
"description": "跳过本轮的原因(可选)。",
}
},
required=[],
handler=handler,
)
def _workflow_handoff_tool(self, 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(
OutputTransportMessageUrgentFrame(
message={
"type": "handoff-requested",
"source": "workflow-system-tool",
"nodeId": node_id,
"reason": reason,
"message": "用户请求转接人工服务。",
}
)
)
self._store.values["system__handoff_status"] = "requested"
return {
"status": "requested",
"action": "human_handoff_requested",
"message": "人工接管请求已提交,请告知用户正在等待人工响应。",
}
return FlowsFunctionSchema(
name="request_human_handoff",
description=(
"提交人工接管请求。当用户明确要求人工服务、投诉升级或 AI 无法"
"解决时调用。该工具只提交请求,不代表人工已经接通;调用后继续"
"回复用户并说明正在等待人工响应。"
),
properties={
"reason": {"type": "string", "description": "转接人工的原因。"}
},
required=[],
handler=handler,
)
def _workflow_end_conversation_tool(self) -> FlowsFunctionSchema:
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)
return {"status": "success", "action": "ending_call"}
setattr(handler, "_suppress_followup_llm", True)
return FlowsFunctionSchema(
name="end_conversation",
description=(
"礼貌地结束本次对话。当用户明确告别、表示任务已完成"
"或要求挂断时调用。"
),
properties={
"reason": {
"type": "string",
"description": "结束对话的简短原因。",
}
},
required=[],
handler=handler,
)
def _flow_managed_transition_config( def _flow_managed_transition_config(
self, self,
node_config: NodeConfig, node_config: NodeConfig,
@@ -838,6 +1034,8 @@ class WorkflowBrain(BaseBrain):
outcome = await self._enter_action(node_id) outcome = await self._enter_action(node_id)
if not outcome.should_route: if not outcome.should_route:
return self._passive_node_config(node_id, context_messages) return self._passive_node_config(node_id, context_messages)
elif node_type == "update_state":
await self._enter_update_state(node_id)
elif node_type == "message": elif node_type == "message":
self._prepare_message_continuation( self._prepare_message_continuation(
node_id, node_id,
@@ -862,6 +1060,22 @@ class WorkflowBrain(BaseBrain):
node_id = str(edge.get("target") or "") node_id = str(edge.get("target") or "")
raise RuntimeError("工作流连续自动跳转超过安全上限") raise RuntimeError("工作流连续自动跳转超过安全上限")
async def _enter_update_state(self, node_id: str) -> None:
"""Apply one deterministic, atomic dynamic-variable update."""
self._state.enter(node_id, WorkflowStatus.RUNNING_ACTION)
await self._emit_node_active(node_id)
raw_assignments = self._engine.data(node_id).get("assignments") or {}
rendered = self._store.render_data(raw_assignments)
if not isinstance(rendered, dict):
raise DynamicVariableError("Update State 节点赋值必须是对象")
changed = self._store.assign_declared_many(rendered)
if changed:
await self._emit_variables(
reason="update_state",
node_id=node_id,
changed=changed,
)
def _prepare_message_continuation( def _prepare_message_continuation(
self, self,
node_id: str, node_id: str,

View File

@@ -266,6 +266,7 @@ async def resolve_runtime_config(
enableInterrupt=assistant.enable_interrupt, enableInterrupt=assistant.enable_interrupt,
turnConfig=assistant.turn_config or {}, turnConfig=assistant.turn_config or {},
startup=assistant.startup or {}, startup=assistant.startup or {},
system_tools=assistant.system_tools or [],
tools=runtime_tools, tools=runtime_tools,
llm_tool_ids=llm_tool_ids, llm_tool_ids=llm_tool_ids,
knowledge_base_id=assistant.knowledge_base_id, knowledge_base_id=assistant.knowledge_base_id,

View File

@@ -11,15 +11,30 @@ from services.message_policy import (
MESSAGE_CONFIRMATION, MESSAGE_CONFIRMATION,
MESSAGE_PLAYBACK, MESSAGE_PLAYBACK,
) )
from services.system_tools import SYSTEM_TOOL_KINDS, normalize_system_tools
SPEC_VERSION = "3" SPEC_VERSION = "3"
NODE_TYPES = {"start", "agent", "message", "action", "handoff", "end"} NODE_TYPES = {
"start",
"agent",
"message",
"action",
"update_state",
"handoff",
"end",
}
EDGE_MODES = {"llm", "expression", "always"} EDGE_MODES = {"llm", "expression", "always"}
AGENT_ENTRY_MODES = {"wait_user", "generate"} AGENT_ENTRY_MODES = {"wait_user", "generate"}
ACTION_RESULT_ASSIGNMENT_MODES = {"inherit", "override", "none"} ACTION_RESULT_ASSIGNMENT_MODES = {"inherit", "override", "none"}
ACTION_USER_INPUT_POLICIES = {"queue", "block"} ACTION_USER_INPUT_POLICIES = {"queue", "block"}
AUTOMATIC_NODE_TYPES = {"start", "message", "action", "handoff"} AUTOMATIC_NODE_TYPES = {
"start",
"message",
"action",
"update_state",
"handoff",
}
EXPRESSION_OPERATORS = { EXPRESSION_OPERATORS = {
"eq", "eq",
"neq", "neq",
@@ -98,6 +113,24 @@ NODE_SPECS: list[dict[str, Any]] = [
{"key": "name", "label": "节点名称", "type": "text", "default": "Action"}, {"key": "name", "label": "节点名称", "type": "text", "default": "Action"},
], ],
}, },
{
"name": "update_state",
"displayName": "Update State",
"category": "execution_node",
"description": "原子更新已声明的动态变量,然后使用新状态继续路由。",
"icon": "Braces",
"accent": "mint",
"addable": True,
"constraints": {"minIncoming": 1, "minOutgoing": 0},
"fields": [
{
"key": "name",
"label": "节点名称",
"type": "text",
"default": "Update State",
},
],
},
{ {
"name": "handoff", "name": "handoff",
"displayName": "Handoff", "displayName": "Handoff",
@@ -160,6 +193,11 @@ def _normalize_agent_data(data: dict[str, Any]) -> None:
if data.get("entryMode") not in AGENT_ENTRY_MODES: if data.get("entryMode") not in AGENT_ENTRY_MODES:
data["entryMode"] = "wait_user" data["entryMode"] = "wait_user"
data.pop("entrySpeech", None) data.pop("entrySpeech", None)
data["systemTools"] = list(normalize_system_tools(data.get("systemTools")))
state_names = data.get("stateVariableNames")
data["stateVariableNames"] = list(
dict.fromkeys(str(name) for name in state_names or [] if str(name))
)
if "inheritGlobalConfig" not in data: if "inheritGlobalConfig" not in data:
has_node_overrides = any( has_node_overrides = any(
( (
@@ -194,6 +232,12 @@ def _normalize_action_data(data: dict[str, Any]) -> None:
data.pop("speech", None) data.pop("speech", None)
def _normalize_update_state_data(data: dict[str, Any]) -> None:
"""Keep deterministic state updates as one explicit assignment object."""
assignments = data.get("assignments")
data["assignments"] = dict(assignments) if isinstance(assignments, dict) else {}
def _normalize_message_data(data: dict[str, Any]) -> None: def _normalize_message_data(data: dict[str, Any]) -> None:
"""Fill the small built-in Message contract used by runtime and editor.""" """Fill the small built-in Message contract used by runtime and editor."""
data.setdefault("speech", "") data.setdefault("speech", "")
@@ -246,6 +290,8 @@ def normalize_graph(graph: dict[str, Any] | None) -> dict[str, Any]:
_normalize_message_data(data) _normalize_message_data(data)
elif node.get("type") == "action": elif node.get("type") == "action":
_normalize_action_data(data) _normalize_action_data(data)
elif node.get("type") == "update_state":
_normalize_update_state_data(data)
return source return source
nodes = source.get("nodes") or [] nodes = source.get("nodes") or []
@@ -262,6 +308,7 @@ def normalize_graph(graph: dict[str, Any] | None) -> dict[str, Any]:
"agent": "agent", "agent": "agent",
"message": "message", "message": "message",
"action": "action", "action": "action",
"update_state": "update_state",
"handoff": "handoff", "handoff": "handoff",
"end": "end", "end": "end",
} }
@@ -283,6 +330,8 @@ def normalize_graph(graph: dict[str, Any] | None) -> dict[str, Any]:
_normalize_message_data(data) _normalize_message_data(data)
elif new_type == "action": elif new_type == "action":
_normalize_action_data(data) _normalize_action_data(data)
elif new_type == "update_state":
_normalize_update_state_data(data)
elif new_type == "start": elif new_type == "start":
prompt = str(data.pop("prompt", "") or "").strip() prompt = str(data.pop("prompt", "") or "").strip()
data.pop("greeting", None) data.pop("greeting", None)
@@ -390,6 +439,16 @@ def validate_graph(graph: dict[str, Any]) -> list[str]:
entry_mode = data.get("entryMode", "wait_user") entry_mode = data.get("entryMode", "wait_user")
if entry_mode not in AGENT_ENTRY_MODES: if entry_mode not in AGENT_ENTRY_MODES:
errors.append(f"Agent 节点 {node_id} 的进入模式无效:{entry_mode}") 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
):
errors.append(f"Agent 节点 {node_id} 的状态变量授权必须是列表")
elif node_type == "message": elif node_type == "message":
data = node.get("data") or {} data = node.get("data") or {}
speech = data.get("speech") speech = data.get("speech")
@@ -447,6 +506,13 @@ def validate_graph(graph: dict[str, Any]) -> list[str]:
errors.append( errors.append(
f"Action 节点 {node_id} 的用户输入策略无效:{input_policy}" f"Action 节点 {node_id} 的用户输入策略无效:{input_policy}"
) )
elif node_type == "update_state":
data = node.get("data") or {}
assignments = data.get("assignments")
if not isinstance(assignments, dict) or not assignments:
errors.append(
f"Update State 节点 {node_id} 必须配置至少一个变量赋值"
)
if counts["start"] != 1: if counts["start"] != 1:
errors.append("工作流必须有且仅有一个 Start 节点") errors.append("工作流必须有且仅有一个 Start 节点")

View File

@@ -0,0 +1,54 @@
"""Shared contracts for platform-owned conversation system tools."""
from __future__ import annotations
from collections.abc import Iterable, Mapping
from typing import Any
SYSTEM_TOOL_KINDS = frozenset(
{
"end_conversation",
"update_state",
"skip_turn",
"request_human_handoff",
}
)
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 state_update_properties(
definitions: Mapping[str, Mapping[str, Any]] | None,
*,
allowed_names: Iterable[str] | None = None,
) -> dict[str, dict[str, Any]]:
"""Build an explicit LLM schema for writable declared variables."""
definitions = definitions or {}
names = (
list(dict.fromkeys(str(name) for name in allowed_names))
if allowed_names is not None
else list(definitions)
)
properties: dict[str, dict[str, Any]] = {}
for name in names:
definition = definitions.get(name)
if not isinstance(definition, Mapping):
continue
variable_type = str(definition.get("type") or "string")
if variable_type not in {"string", "number", "boolean"}:
continue
properties[name] = {
"type": variable_type,
"description": f"更新动态变量 {name}",
}
return properties

View File

@@ -11,6 +11,7 @@ from typing import Any
from services.node_specs import normalize_graph from services.node_specs import normalize_graph
from services.runtime_variables import DynamicVariableStore from services.runtime_variables import DynamicVariableStore
from services.system_tools import normalize_system_tools
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -24,6 +25,8 @@ class AgentStageConfig:
vision_enabled: bool vision_enabled: bool
vision_model_resource_id: str | None vision_model_resource_id: str | None
tool_ids: tuple[str, ...] tool_ids: tuple[str, ...]
system_tools: tuple[str, ...]
state_variable_names: tuple[str, ...]
knowledge_base_id: str | None knowledge_base_id: str | None
knowledge_mode: str knowledge_mode: str
knowledge_top_n: int knowledge_top_n: int
@@ -145,6 +148,16 @@ class WorkflowEngine:
str(source.get("visionModelResourceId") or "") or None str(source.get("visionModelResourceId") or "") or None
), ),
tool_ids=tuple(str(tool_id) for tool_id in source.get("toolIds") or []), 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")),
state_variable_names=tuple(
dict.fromkeys(
str(name)
for name in data.get("stateVariableNames") or []
if str(name)
)
),
knowledge_base_id=knowledge_base_id or None, knowledge_base_id=knowledge_base_id or None,
knowledge_mode=( knowledge_mode=(
str(source.get("knowledgeMode") or "automatic") str(source.get("knowledgeMode") or "automatic")

View File

@@ -154,6 +154,46 @@ class BrainRegistryTests(unittest.TestCase):
) )
self.assertIn("user_name", assistant.dynamic_variable_definitions) 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, [])
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"],
)
def test_workflow_keeps_dynamic_variables_and_tool_bindings(self): def test_workflow_keeps_dynamic_variables_and_tool_bindings(self):
assistant = AssistantUpsert( assistant = AssistantUpsert(
name="workflow", name="workflow",
@@ -757,7 +797,7 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(prompts, ["面板状态true"]) self.assertEqual(prompts, ["面板状态true"])
self.assertEqual(queued_frames, []) self.assertEqual(queued_frames, [])
async def test_end_call_tool_is_owned_by_prompt_brain(self): async def test_end_conversation_system_tool_is_owned_by_prompt_brain(self):
brain = build_brain( brain = build_brain(
AssistantConfig( AssistantConfig(
type="prompt", type="prompt",
@@ -766,9 +806,10 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
id="end-call", id="end-call",
name="结束通话", name="结束通话",
function_name="end_call", function_name="end_call",
type="end_call", type="system",
definition={ definition={
"config": { "config": {
"kind": "end_conversation",
"message_type": "none", "message_type": "none",
"capture_reason": True, "capture_reason": True,
} }
@@ -792,8 +833,13 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
id="end-call", id="end-call",
name="结束通话", name="结束通话",
function_name="end_call", function_name="end_call",
type="end_call", type="system",
definition={"config": {"capture_reason": True}}, definition={
"config": {
"kind": "end_conversation",
"capture_reason": True,
}
},
) )
], ],
), ),
@@ -819,13 +865,18 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
self.assertTrue(call_end.response_started) self.assertTrue(call_end.response_started)
self.assertEqual(params.result["action"], "ending_call") self.assertEqual(params.result["action"], "ending_call")
async def test_end_call_waits_for_prompt_generated_closing_speech(self): async def test_system_end_conversation_waits_for_generated_closing_speech(self):
tool = RuntimeTool( tool = RuntimeTool(
id="end-call", id="end-call",
name="结束通话", name="结束通话",
function_name="end_call", function_name="end_call",
type="end_call", type="system",
definition={"config": {"message_type": "none"}}, definition={
"config": {
"kind": "end_conversation",
"message_type": "none",
}
},
) )
cfg = AssistantConfig(type="prompt", tools=[tool]) cfg = AssistantConfig(type="prompt", tools=[tool])
brain = build_brain(cfg) brain = build_brain(cfg)
@@ -855,6 +906,279 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
self.assertTrue(call_end.armed) self.assertTrue(call_end.armed)
self.assertTrue(call_end.waited_for_text) self.assertTrue(call_end.waited_for_text)
async def test_system_tools_register_with_fixed_names(self):
cfg = AssistantConfig(
type="prompt",
dynamic_variable_definitions={
"user_name": {
"type": "string",
"required": False,
"default": None,
}
},
system_tools=[
"end_conversation",
"update_state",
"skip_turn",
"request_human_handoff",
],
)
brain = build_brain(cfg)
llm = FakeLLM()
visible_tools = []
await brain.setup(
cfg,
BrainRuntime(
context=LLMContext(messages=[]),
llm=llm,
queue_frame=lambda _frame: None,
set_system_prompt=lambda _prompt: None,
set_tools=lambda tools: visible_tools.extend(tools or []),
call_end=FakeCallEnd(),
),
)
self.assertEqual(
[tool.name for tool in visible_tools],
[
"end_conversation",
"update_state",
"skip_turn",
"request_human_handoff",
],
)
update_schema = next(
tool for tool in visible_tools if tool.name == "update_state"
)
self.assertEqual(
update_schema.properties,
{
"user_name": {
"type": "string",
"description": "更新动态变量 user_name。",
}
},
)
for name in (
"end_conversation",
"update_state",
"skip_turn",
"request_human_handoff",
):
self.assertIn(name, llm.functions)
async def test_end_conversation_ends_call_after_generated_speech(self):
cfg = AssistantConfig(
type="prompt",
system_tools=["end_conversation"],
)
brain = build_brain(cfg)
llm = FakeLLM()
call_end = FakeCallEnd()
await brain.setup(
cfg,
BrainRuntime(
context=LLMContext(messages=[]),
llm=llm,
queue_frame=lambda _frame: None,
set_system_prompt=lambda _prompt: None,
set_tools=lambda _tools: None,
call_end=call_end,
),
)
await brain.on_assistant_text_start("turn-1")
params = FakeFunctionParams({"reason": "用户已完成咨询"})
await llm.functions["end_conversation"](params)
self.assertEqual(call_end.reason, "用户已完成咨询")
self.assertTrue(call_end.ending)
self.assertFalse(call_end.finished)
self.assertEqual(params.result["action"], "ending_call")
self.assertFalse(params.properties.run_llm)
await brain.on_assistant_text_end(
"turn-1",
"好的,再见。",
False,
)
self.assertFalse(call_end.finished)
self.assertTrue(call_end.armed)
async def test_end_conversation_finishes_when_no_speech(self):
cfg = AssistantConfig(
type="prompt",
system_tools=["end_conversation"],
)
brain = build_brain(cfg)
llm = FakeLLM()
call_end = FakeCallEnd()
await brain.setup(
cfg,
BrainRuntime(
context=LLMContext(messages=[]),
llm=llm,
queue_frame=lambda _frame: None,
set_system_prompt=lambda _prompt: None,
set_tools=lambda _tools: None,
call_end=call_end,
),
)
await brain.on_assistant_text_start("turn-1")
await llm.functions["end_conversation"](FakeFunctionParams({}))
await brain.on_assistant_text_end("turn-1", "", False)
self.assertTrue(call_end.finished)
async def test_update_state_updates_declared_variables(self):
cfg = AssistantConfig(
type="prompt",
prompt="当前用户名: {{user_name}}",
dynamic_variable_definitions={
"user_name": {"type": "string", "required": False, "default": None}
},
system_tools=["update_state"],
)
brain = build_brain(cfg)
llm = FakeLLM()
prompts = []
queued_frames = []
async def collect(frame):
queued_frames.append(frame)
await brain.setup(
cfg,
BrainRuntime(
context=LLMContext(messages=[]),
llm=llm,
queue_frame=collect,
set_system_prompt=prompts.append,
set_tools=lambda _tools: None,
call_end=FakeCallEnd(),
),
)
params = FakeFunctionParams({"user_name": "王小明"})
await llm.functions["update_state"](params)
self.assertEqual(params.result["status"], "success")
self.assertEqual(params.result["changed"], ["user_name"])
self.assertIsNone(params.properties)
self.assertEqual(
[frame.message for frame in queued_frames],
[
{
"type": "session-variables",
"reason": "update_state",
"variables": {"user_name": "王小明"},
"changed": ["user_name"],
}
],
)
self.assertTrue(prompts[-1].endswith("当前用户名: 王小明"))
async def test_update_state_rejects_undeclared_variable(self):
cfg = AssistantConfig(
type="prompt",
system_tools=["update_state"],
)
brain = build_brain(cfg)
llm = FakeLLM()
await brain.setup(
cfg,
BrainRuntime(
context=LLMContext(messages=[]),
llm=llm,
queue_frame=lambda _frame: None,
set_system_prompt=lambda _prompt: None,
set_tools=lambda _tools: None,
call_end=FakeCallEnd(),
),
)
params = FakeFunctionParams({"unknown_key": "x"})
await llm.functions["update_state"](params)
self.assertEqual(params.result["status"], "error")
self.assertIn("未声明", params.result["message"])
async def test_skip_turn_suppresses_response(self):
cfg = AssistantConfig(
type="prompt",
system_tools=["skip_turn"],
)
brain = build_brain(cfg)
llm = FakeLLM()
await brain.setup(
cfg,
BrainRuntime(
context=LLMContext(messages=[]),
llm=llm,
queue_frame=lambda _frame: None,
set_system_prompt=lambda _prompt: None,
set_tools=lambda _tools: None,
call_end=FakeCallEnd(),
),
)
params = FakeFunctionParams({"reason": "无需回复"})
await llm.functions["skip_turn"](params)
self.assertEqual(params.result["action"], "skip_turn")
self.assertEqual(params.result["reason"], "无需回复")
self.assertFalse(params.properties.run_llm)
async def test_request_human_handoff_keeps_call_available(self):
cfg = AssistantConfig(
type="prompt",
system_tools=["request_human_handoff"],
)
brain = build_brain(cfg)
llm = FakeLLM()
call_end = FakeCallEnd()
queued_frames = []
async def collect(frame):
queued_frames.append(frame)
await brain.setup(
cfg,
BrainRuntime(
context=LLMContext(messages=[]),
llm=llm,
queue_frame=collect,
set_system_prompt=lambda _prompt: None,
set_tools=lambda _tools: None,
call_end=call_end,
),
)
await brain.on_assistant_text_start("turn-1")
params = FakeFunctionParams({"reason": "用户要求人工客服"})
await llm.functions["request_human_handoff"](params)
self.assertEqual(
[frame.message for frame in queued_frames],
[
{
"type": "handoff-requested",
"source": "prompt-system-tool",
"reason": "用户要求人工客服",
"message": "用户请求转接人工服务。",
}
],
)
self.assertEqual(call_end.reason, "")
self.assertFalse(call_end.ending)
self.assertEqual(params.result["action"], "human_handoff_requested")
self.assertIsNone(params.properties)
await brain.on_assistant_text_end("turn-1", "", False)
self.assertFalse(call_end.finished)
async def test_http_tool_renders_secrets_and_updates_prompt_variable(self): async def test_http_tool_renders_secrets_and_updates_prompt_variable(self):
requests = [] requests = []
@@ -962,6 +1286,195 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase): class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
async def test_agent_system_tools_are_scoped_and_update_declared_state(self):
cfg = prepare_dynamic_config(
AssistantConfig(
type="workflow",
graph={
"specVersion": 3,
"settings": {},
"nodes": [
{"id": "start", "type": "start", "data": {}},
{
"id": "agent",
"type": "agent",
"data": {
"systemTools": [
"update_state",
"skip_turn",
"request_human_handoff",
],
"stateVariableNames": ["customer_name"],
},
},
],
"edges": [],
},
dynamic_variable_definitions={
"customer_name": {
"type": "string",
"required": False,
"default": None,
},
"internal_note": {
"type": "string",
"required": False,
"default": None,
},
},
),
{},
assistant_id="asst_workflow_system_tools",
)
brain = WorkflowBrain(cfg)
queued = []
async def queue_frame(frame):
queued.append(frame)
brain._runtime = BrainRuntime(
context=LLMContext(messages=[]),
llm=FakeLLM(),
queue_frame=queue_frame,
set_system_prompt=lambda _prompt: None,
set_tools=lambda _tools: None,
call_end=FakeCallEnd(),
)
brain._agent_stage = SimpleNamespace(
node_config=lambda node_id, *, functions, leading_messages: {
"name": node_id,
"functions": functions,
"leading_messages": leading_messages,
}
)
brain._emit_variables = AsyncMock()
brain._refresh_agent_prompt = AsyncMock()
node_config = brain._agent_config("agent")
functions = {tool.name: tool for tool in node_config["functions"]}
self.assertEqual(
list(functions),
["update_state", "skip_turn", "request_human_handoff"],
)
self.assertEqual(
list(functions["update_state"].properties),
["customer_name"],
)
result = await functions["update_state"].handler(
{"customer_name": "李白"},
None,
)
self.assertEqual(result["changed"], ["customer_name"])
self.assertEqual(brain._store.public_values(), {"customer_name": "李白"})
brain._emit_variables.assert_awaited_once_with(
reason="update_state",
node_id="agent",
changed=["customer_name"],
)
brain._refresh_agent_prompt.assert_awaited_once_with("agent")
unauthorized = await functions["update_state"].handler(
{"internal_note": "不可写"},
None,
)
self.assertEqual(unauthorized["status"], "error")
self.assertIn("未获当前节点授权", unauthorized["message"])
handoff = await functions["request_human_handoff"].handler(
{"reason": "用户要求人工"},
None,
)
self.assertEqual(handoff["status"], "requested")
self.assertFalse(brain._runtime.call_end.ending)
event = next(
frame.message
for frame in queued
if isinstance(frame, OutputTransportMessageUrgentFrame)
)
self.assertEqual(event["type"], "handoff-requested")
self.assertEqual(event["source"], "workflow-system-tool")
async def test_update_state_node_applies_assignments_atomically(self):
cfg = prepare_dynamic_config(
AssistantConfig(
type="workflow",
graph={
"specVersion": 3,
"settings": {},
"nodes": [
{"id": "start", "type": "start", "data": {}},
{
"id": "set_state",
"type": "update_state",
"data": {
"assignments": {
"confirmed": True,
"display_name": "{{source_name}}",
}
},
},
],
"edges": [],
},
dynamic_variable_definitions={
"source_name": {"type": "string", "default": "李白"},
"display_name": {
"type": "string",
"required": False,
"default": None,
},
"confirmed": {"type": "boolean", "default": False},
},
),
{},
assistant_id="asst_workflow_update_state_node",
)
brain = WorkflowBrain(cfg)
brain._emit_node_active = AsyncMock()
brain._emit_variables = AsyncMock()
await brain._enter_update_state("set_state")
self.assertEqual(brain._store.public_values()["display_name"], "李白")
self.assertTrue(brain._store.public_values()["confirmed"])
brain._emit_variables.assert_awaited_once_with(
reason="update_state",
node_id="set_state",
changed=["confirmed", "display_name"],
)
async def test_workflow_end_conversation_waits_for_generated_speech(self):
brain = WorkflowBrain(
{
"specVersion": 3,
"settings": {},
"nodes": [{"id": "start", "type": "start", "data": {}}],
"edges": [],
}
)
call_end = FakeCallEnd()
brain._runtime = BrainRuntime(
context=LLMContext(messages=[]),
llm=FakeLLM(),
queue_frame=noop_queue_frame,
set_system_prompt=lambda _prompt: None,
set_tools=lambda _tools: None,
call_end=call_end,
)
tool = brain._workflow_end_conversation_tool()
await brain.on_assistant_text_start("turn-1")
result = await tool.handler({"reason": "用户告别"}, None)
self.assertEqual(result["action"], "ending_call")
self.assertTrue(call_end.ending)
self.assertEqual(call_end.reason, "用户告别")
self.assertTrue(getattr(tool.handler, "_suppress_followup_llm"))
await brain.on_assistant_text_end("turn-1", "再见。", False)
self.assertTrue(call_end.armed)
self.assertTrue(call_end.waited_for_text)
async def test_flow_manager_dispatches_native_vision_without_auxiliary_handler(self): async def test_flow_manager_dispatches_native_vision_without_auxiliary_handler(self):
manager = object.__new__(ConfiguredFlowManager) manager = object.__new__(ConfiguredFlowManager)
fallback_transition = AsyncMock() fallback_transition = AsyncMock()

View File

@@ -360,6 +360,13 @@ class WorkflowGraphTests(unittest.TestCase):
def test_agent_effective_config_inherits_then_switches_to_override(self): def test_agent_effective_config_inherits_then_switches_to_override(self):
graph = valid_graph() graph = valid_graph()
agent = next(node for node in graph["nodes"] if node["id"] == "agent")
agent["data"].update(
{
"systemTools": ["update_state", "skip_turn"],
"stateVariableNames": ["customer", "order_status"],
}
)
graph["settings"].update( graph["settings"].update(
{ {
"defaultLlmResourceId": "llm_global", "defaultLlmResourceId": "llm_global",
@@ -389,6 +396,11 @@ class WorkflowGraphTests(unittest.TestCase):
) )
self.assertTrue(engine.uses_vision()) self.assertTrue(engine.uses_vision())
self.assertEqual(inherited.tool_ids, ("tool_global",)) self.assertEqual(inherited.tool_ids, ("tool_global",))
self.assertEqual(inherited.system_tools, ("update_state", "skip_turn"))
self.assertEqual(
inherited.state_variable_names,
("customer", "order_status"),
)
self.assertEqual(inherited.knowledge_mode, "on_demand") self.assertEqual(inherited.knowledge_mode, "on_demand")
self.assertFalse(inherited.enable_interrupt) self.assertFalse(inherited.enable_interrupt)
self.assertEqual( self.assertEqual(
@@ -417,6 +429,7 @@ class WorkflowGraphTests(unittest.TestCase):
self.assertIsNone(custom.vision_model_resource_id) self.assertIsNone(custom.vision_model_resource_id)
self.assertFalse(engine.uses_vision()) self.assertFalse(engine.uses_vision())
self.assertEqual(custom.tool_ids, ("tool_agent",)) self.assertEqual(custom.tool_ids, ("tool_agent",))
self.assertEqual(custom.system_tools, ("update_state", "skip_turn"))
self.assertEqual(custom.knowledge_mode, "disabled") self.assertEqual(custom.knowledge_mode, "disabled")
self.assertTrue(custom.enable_interrupt) self.assertTrue(custom.enable_interrupt)
self.assertEqual( self.assertEqual(
@@ -424,6 +437,84 @@ class WorkflowGraphTests(unittest.TestCase):
"smart_turn", "smart_turn",
) )
def test_update_state_node_and_agent_state_scope_are_validated(self):
graph = valid_graph()
graph["nodes"].insert(
1,
{
"id": "set_state",
"type": "update_state",
"data": {
"name": "记录状态",
"assignments": {"confirmed": True},
},
},
)
graph["edges"][0]["target"] = "set_state"
graph["edges"].insert(
1,
{
"id": "after_state",
"source": "set_state",
"target": "agent",
"data": {"mode": "always", "priority": 0},
},
)
agent = next(node for node in graph["nodes"] if node["id"] == "agent")
agent["data"].update(
{
"systemTools": ["update_state"],
"stateVariableNames": ["customer"],
}
)
body = AssistantUpsert(
name="状态工作流",
type="workflow",
dynamicVariableDefinitions={
"confirmed": {
"type": "boolean",
"required": False,
"default": False,
},
"customer": {
"type": "string",
"required": False,
"default": None,
},
},
graph=graph,
)
_validate_workflow(body)
normalized_state = next(
node for node in body.graph["nodes"] if node["type"] == "update_state"
)
self.assertEqual(normalized_state["data"]["assignments"], {"confirmed": True})
normalized_state["data"]["assignments"] = {"missing": "x"}
with self.assertRaisesRegex(HTTPException, "未声明变量:missing"):
_validate_workflow(body)
def test_agent_update_state_tool_requires_an_authorized_variable(self):
graph = valid_graph()
agent = next(node for node in graph["nodes"] if node["id"] == "agent")
agent["data"]["systemTools"] = ["update_state"]
body = AssistantUpsert(
name="缺少授权",
type="workflow",
dynamicVariableDefinitions={
"customer": {
"type": "string",
"required": False,
"default": None,
}
},
graph=graph,
)
with self.assertRaisesRegex(HTTPException, "必须授权至少一个变量"):
_validate_workflow(body)
def test_vision_resource_creates_isolated_runtime_config(self): def test_vision_resource_creates_isolated_runtime_config(self):
base = AssistantConfig(type="workflow", model="text-only") base = AssistantConfig(type="workflow", model="text-only")
resource = RuntimeModelResource( resource = RuntimeModelResource(

View File

@@ -26,6 +26,7 @@ icon: bot
## 工具与历史 ## 工具与历史
- 可复用工具支持结束通话和 HTTP 请求两种类型 - 可复用工具分为 System、HTTP、Client 和 MCP结束对话属于 System 类别
- HTTP 工具的 URL、请求头、参数和密钥独立管理。 - HTTP 工具的 URL、请求头、参数和密钥独立管理。
- Prompt 助手和 Workflow Agent 节点可开启内置系统工具结束对话、更新状态、跳过本轮、请求人工接管Workflow 还提供确定性的 Update State 节点。
- 历史记录保留会话、通道、运行模式及消息序列,便于排查对话。 - 历史记录保留会话、通道、运行模式及消息序列,便于排查对话。

View File

@@ -3,7 +3,6 @@
import { Check, Copy } from "lucide-react"; import { Check, Copy } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { import {
Select, Select,
@@ -252,12 +251,18 @@ function DynamicVariablesEditor({
<code className="truncate font-mono text-sm font-medium text-foreground"> <code className="truncate font-mono text-sm font-medium text-foreground">
{`{{${name}}}`} {`{{${name}}}`}
</code> </code>
<Badge <div className="flex shrink-0 items-center gap-2">
variant="secondary" <span className="text-xs text-muted-foreground">
className="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium"
> </span>
<Switch
</Badge> checked={definition.required}
aria-label={`变量 ${name} 必填`}
onCheckedChange={(required) =>
updateDefinition(name, { required })
}
/>
</div>
</div> </div>
<div className="grid grid-cols-[120px_1fr] gap-2"> <div className="grid grid-cols-[120px_1fr] gap-2">
<Select <Select
@@ -328,18 +333,6 @@ function DynamicVariablesEditor({
/> />
)} )}
</div> </div>
<div className="flex items-center justify-between gap-3">
<span className="text-xs text-muted-foreground">
</span>
<Switch
checked={definition.required}
aria-label={`变量 ${name} 必填`}
onCheckedChange={(required) =>
updateDefinition(name, { required })
}
/>
</div>
</div> </div>
))} ))}
</div> </div>

View File

@@ -12,6 +12,7 @@ import {
Plus, Plus,
ServerCog, ServerCog,
Settings2, Settings2,
Sparkles,
Waypoints, Waypoints,
Wrench, Wrench,
X, X,
@@ -37,8 +38,19 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import type { KnowledgeRetrievalConfig, Tool } from "@/lib/api"; import type {
KnowledgeRetrievalConfig,
SystemToolKind,
Tool,
} from "@/lib/api";
import { SYSTEM_TOOL_OPTIONS } from "@/lib/system-tools";
import type { RuntimeMode } from "./types"; import type { RuntimeMode } from "./types";
@@ -475,31 +487,90 @@ export function ToolPicker({
tools, tools,
selectedIds, selectedIds,
onChange, onChange,
selectedSystemTools,
onSystemToolsChange,
showBuiltInSystemTools,
}: { }: {
tools: Tool[]; tools: Tool[];
selectedIds: string[]; selectedIds: string[];
onChange: (ids: string[]) => void; onChange: (ids: string[]) => void;
selectedSystemTools: SystemToolKind[];
onSystemToolsChange: (tools: SystemToolKind[]) => void;
showBuiltInSystemTools: boolean;
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [activeTab, setActiveTab] = useState<Tool["type"]>("system");
const [draftIds, setDraftIds] = useState<string[]>(selectedIds); const [draftIds, setDraftIds] = useState<string[]>(selectedIds);
const [draftSystemTools, setDraftSystemTools] =
useState<SystemToolKind[]>(selectedSystemTools);
const selectedTools = selectedIds const selectedTools = selectedIds
.map((id) => tools.find((tool) => tool.id === id)) .map((id) => tools.find((tool) => tool.id === id))
.filter((tool): tool is Tool => Boolean(tool)); .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() { function openPicker() {
setDraftIds(selectedIds); setDraftIds(selectedIds);
setDraftSystemTools(selectedSystemTools);
setOpen(true); setOpen(true);
} }
function toggleResourceTool(toolId: string) {
setDraftIds((current) =>
current.includes(toolId)
? current.filter((id) => id !== toolId)
: [...current, toolId],
);
}
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" },
{ value: "client", label: "Client" },
{ value: "mcp", label: "MCP" },
];
return ( return (
<> <>
<div className="flex min-h-9 flex-wrap items-center gap-2"> <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) => ( {selectedTools.map((tool) => (
<div <div
key={tool.id} key={tool.id}
className="flex h-8 items-center gap-2 rounded-lg border border-hairline-strong bg-background px-2.5 text-sm" className="flex h-8 items-center gap-2 rounded-lg border border-hairline-strong bg-background px-2.5 text-sm"
> >
{tool.type === "end_call" ? ( {tool.type === "system" ? (
<PhoneOff size={14} /> <PhoneOff size={14} />
) : tool.type === "mcp" ? ( ) : tool.type === "mcp" ? (
<ServerCog size={14} /> <ServerCog size={14} />
@@ -534,58 +605,108 @@ export function ToolPicker({
<DialogContent className="sm:max-w-xl"> <DialogContent className="sm:max-w-xl">
<DialogHeader> <DialogHeader>
<DialogTitle></DialogTitle> <DialogTitle></DialogTitle>
<DialogDescription></DialogDescription> <DialogDescription>
</DialogDescription>
</DialogHeader> </DialogHeader>
{tools.length === 0 ? ( <Tabs
<div className="rounded-xl border border-dashed border-hairline-strong px-4 py-10 text-center text-sm text-muted-foreground"> value={activeTab}
onValueChange={(value) => setActiveTab(value as Tool["type"])}
</div> >
) : ( <TabsList
<div className="max-h-80 divide-y divide-hairline overflow-y-auto rounded-xl border border-hairline"> variant="line"
{tools.map((tool) => { className="w-full justify-start border-b border-hairline px-1"
const checked = draftIds.includes(tool.id); >
return ( {tabs.map((tab) => (
<label <TabsTrigger
key={tool.id} key={tab.value}
className="flex cursor-pointer items-center gap-3 px-4 py-3 transition-colors hover:bg-surface-strong/40" value={tab.value}
> className="flex-none px-4"
<input >
type="checkbox" {tab.label}
checked={checked} </TabsTrigger>
onChange={() => ))}
setDraftIds((current) => </TabsList>
checked
? current.filter((id) => id !== tool.id) {tabs.map((tab) => {
: [...current, tool.id], const resources = tools.filter((tool) => tool.type === tab.value);
) const hasBuiltIns =
} tab.value === "system" && showBuiltInSystemTools;
className="size-4 accent-primary" const isEmpty = resources.length === 0 && !hasBuiltIns;
/>
<div className="min-w-0 flex-1"> return (
<div className="flex items-center gap-2"> <TabsContent key={tab.value} value={tab.value} className="pt-3">
<span className="truncate font-medium text-foreground"> {isEmpty ? (
{tool.name} <div className="rounded-xl border border-dashed border-hairline-strong px-4 py-10 text-center text-sm text-muted-foreground">
</span> {tab.label}
<Badge variant="secondary">
{tool.type === "end_call"
? "End Call"
: tool.type === "mcp"
? "MCP"
: tool.type === "client"
? "Client"
: "HTTP"}
</Badge>
</div>
<div className="mt-0.5 truncate font-mono text-xs text-muted-foreground">
{tool.functionName}
</div>
</div> </div>
</label> ) : (
); <div className="max-h-[280px] divide-y divide-hairline overflow-y-auto rounded-xl border border-hairline">
})} {hasBuiltIns &&
</div> 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 (
<label
key={tool.id}
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={() => toggleResourceTool(tool.id)}
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>
<div className="mt-0.5 truncate font-mono text-xs text-muted-foreground">
{tool.functionName}
</div>
</div>
</label>
);
})}
</div>
)}
</TabsContent>
);
})}
</Tabs>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}> <Button variant="outline" onClick={() => setOpen(false)}>
@@ -594,6 +715,7 @@ export function ToolPicker({
<Button <Button
onClick={() => { onClick={() => {
onChange(draftIds); onChange(draftIds);
onSystemToolsChange(draftSystemTools);
setOpen(false); setOpen(false);
}} }}
> >

View File

@@ -506,6 +506,9 @@ export function PromptEditor({
openingMessage: null, openingMessage: null,
}); });
} }
if (runtimeMode === "realtime" && form.systemTools.length) {
updateForm("systemTools", []);
}
}} }}
/> />
@@ -602,6 +605,11 @@ export function PromptEditor({
tools={tools.filter((tool) => tool.status === "active")} tools={tools.filter((tool) => tool.status === "active")}
selectedIds={form.toolIds} selectedIds={form.toolIds}
onChange={(toolIds) => updateForm("toolIds", toolIds)} onChange={(toolIds) => updateForm("toolIds", toolIds)}
selectedSystemTools={form.systemTools}
onSystemToolsChange={(systemTools) =>
updateForm("systemTools", systemTools)
}
showBuiltInSystemTools={form.runtimeMode === "pipeline"}
/> />
</SectionCard> </SectionCard>
</section> </section>

View File

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

View File

@@ -185,6 +185,12 @@ export function WorkflowPage({
modelOptions={modelOptions} modelOptions={modelOptions}
toolOptions={toolOptions} toolOptions={toolOptions}
knowledgeOptions={kbOptions} knowledgeOptions={kbOptions}
dynamicVariableOptions={Object.entries(
dynamicVariableDefinitions,
).map(([value, definition]) => ({
value,
label: `${value} · ${definition.type}`,
}))}
/> />
</div> </div>
</div> </div>

View File

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

View File

@@ -64,9 +64,9 @@ import {
type ToolUpsert, type ToolUpsert,
} from "@/lib/api"; } from "@/lib/api";
type ToolKind = "end_call" | "http" | "client"; type ToolKind = "system" | "http" | "client";
type HttpMethod = HttpToolDefinition["config"]["method"]; type HttpMethod = HttpToolDefinition["config"]["method"];
type ToolFilter = "全部" | "End Call" | "HTTP" | "Client" | "MCP"; type ToolFilter = "全部" | "System" | "HTTP" | "Client" | "MCP";
type SortOrder = "newest" | "oldest"; type SortOrder = "newest" | "oldest";
type ToolResource = type ToolResource =
| { kind: "tool"; id: string; tool: Tool } | { kind: "tool"; id: string; tool: Tool }
@@ -74,7 +74,7 @@ type ToolResource =
const toolFilters: readonly ToolFilter[] = [ const toolFilters: readonly ToolFilter[] = [
"全部", "全部",
"End Call", "System",
"HTTP", "HTTP",
"Client", "Client",
"MCP", "MCP",
@@ -112,7 +112,7 @@ function blankForm(): ToolForm {
return { return {
name: "", name: "",
functionName: "", functionName: "",
type: "end_call", type: "system",
description: "", description: "",
status: "active", status: "active",
messageType: "none", messageType: "none",
@@ -147,7 +147,7 @@ function formFromTool(tool: Tool): ToolForm {
base.type = tool.type; base.type = tool.type;
base.description = tool.description; base.description = tool.description;
base.status = tool.status; base.status = tool.status;
if (tool.definition.type === "end_call") { if (tool.definition.type === "system") {
base.messageType = tool.definition.config.messageType; base.messageType = tool.definition.config.messageType;
base.customMessage = tool.definition.config.customMessage; base.customMessage = tool.definition.config.customMessage;
base.captureReason = tool.definition.config.captureReason; base.captureReason = tool.definition.config.captureReason;
@@ -235,7 +235,7 @@ function payloadFromForm(form: ToolForm): ToolUpsert {
throw new Error("函数名必须以小写字母开头,且只能包含小写字母、数字和下划线"); throw new Error("函数名必须以小写字母开头,且只能包含小写字母、数字和下划线");
} }
if (form.type === "end_call") { if (form.type === "system") {
return { return {
name: form.name.trim(), name: form.name.trim(),
functionName: form.functionName, functionName: form.functionName,
@@ -244,8 +244,9 @@ function payloadFromForm(form: ToolForm): ToolUpsert {
secrets: {}, secrets: {},
definition: { definition: {
schemaVersion: 1, schemaVersion: 1,
type: "end_call", type: "system",
config: { config: {
kind: "end_conversation",
messageType: form.messageType, messageType: form.messageType,
customMessage: form.messageType === "custom" ? form.customMessage : "", customMessage: form.messageType === "custom" ? form.customMessage : "",
captureReason: form.captureReason, captureReason: form.captureReason,
@@ -418,7 +419,7 @@ export function ComponentsToolsPage() {
const tool = resource.tool; const tool = resource.tool;
return ( return (
(filter === "全部" || (filter === "全部" ||
(filter === "End Call" && tool.type === "end_call") || (filter === "System" && tool.type === "system") ||
(filter === "HTTP" && tool.type === "http") || (filter === "HTTP" && tool.type === "http") ||
(filter === "Client" && tool.type === "client")) && (filter === "Client" && tool.type === "client")) &&
(!query || (!query ||
@@ -569,8 +570,8 @@ export function ComponentsToolsPage() {
> >
{resource.kind === "mcp" {resource.kind === "mcp"
? "MCP" ? "MCP"
: resource.tool.type === "end_call" : resource.tool.type === "system"
? "End Call" ? "System"
: resource.tool.type === "client" : resource.tool.type === "client"
? "Client" ? "Client"
: "HTTP"} : "HTTP"}
@@ -783,7 +784,7 @@ export function ComponentsToolsPage() {
> >
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger> <SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="end_call">End Call</SelectItem> <SelectItem value="system">System</SelectItem>
<SelectItem value="http">HTTP</SelectItem> <SelectItem value="http">HTTP</SelectItem>
<SelectItem value="client">Client Tool</SelectItem> <SelectItem value="client">Client Tool</SelectItem>
</SelectContent> </SelectContent>
@@ -826,8 +827,8 @@ export function ComponentsToolsPage() {
</FieldSection> </FieldSection>
<FieldSection title="参数配置" scrollable tall> <FieldSection title="参数配置" scrollable tall>
{form.type === "end_call" ? ( {form.type === "system" ? (
<EndCallFields form={form} setForm={setForm} /> <SystemToolFields form={form} setForm={setForm} />
) : form.type === "client" ? ( ) : form.type === "client" ? (
<ClientToolFields form={form} setForm={setForm} /> <ClientToolFields form={form} setForm={setForm} />
) : ( ) : (
@@ -860,7 +861,7 @@ export function ComponentsToolsPage() {
); );
} }
function EndCallFields({ function SystemToolFields({
form, form,
setForm, setForm,
}: { }: {
@@ -869,6 +870,16 @@ function EndCallFields({
}) { }) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<Field label="系统动作">
<Select value="end_conversation" disabled>
<SelectTrigger className="w-full border-hairline-strong bg-background">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="end_conversation"></SelectItem>
</SelectContent>
</Select>
</Field>
<Field label="结束语"> <Field label="结束语">
<Select <Select
value={form.messageType} value={form.messageType}

View File

@@ -72,6 +72,8 @@ export function GenericNode({ id, type, data, selected }: NodeProps) {
messagePolicy === "playback" ? "播放完继续" : null, messagePolicy === "playback" ? "播放完继续" : null,
messagePolicy === "confirmation" ? "确认后继续" : null, messagePolicy === "confirmation" ? "确认后继续" : null,
].filter(Boolean) ].filter(Boolean)
: type === "update_state"
? [`${Object.keys(nodeData.assignments ?? {}).length} 个变量`]
: []; : [];
return ( return (

View File

@@ -72,6 +72,8 @@ function defaultNodeData(spec: RuntimeNodeSpec): WorkflowNodeData {
contextPolicy: "inherit", contextPolicy: "inherit",
inheritGlobalConfig: true, inheritGlobalConfig: true,
entryMode: "wait_user", entryMode: "wait_user",
systemTools: [],
stateVariableNames: [],
}); });
} else if (spec.type === "action") { } else if (spec.type === "action") {
Object.assign(data, { Object.assign(data, {
@@ -79,6 +81,8 @@ function defaultNodeData(spec: RuntimeNodeSpec): WorkflowNodeData {
resultAssignmentMode: "inherit", resultAssignmentMode: "inherit",
userInputPolicy: "queue", userInputPolicy: "queue",
}); });
} else if (spec.type === "update_state") {
Object.assign(data, { assignments: {} });
} else if (spec.type === "message") { } else if (spec.type === "message") {
Object.assign(data, { Object.assign(data, {
speech: "", speech: "",
@@ -170,6 +174,7 @@ export function WorkflowCanvas({
specsByType, specsByType,
toolOptions = [], toolOptions = [],
knowledgeOptions = [], knowledgeOptions = [],
dynamicVariableOptions,
}: WorkflowEditorProps & { specsByType: NodeSpecMap }) { }: WorkflowEditorProps & { specsByType: NodeSpecMap }) {
const initial = useMemo(() => toFlow(value ?? defaultGraph()), [value]); const initial = useMemo(() => toFlow(value ?? defaultGraph()), [value]);
const [nodes, setNodes, onNodesChange] = useNodesState(initial.nodes); const [nodes, setNodes, onNodesChange] = useNodesState(initial.nodes);
@@ -780,6 +785,7 @@ export function WorkflowCanvas({
asrOptions={modelOptions.asr} asrOptions={modelOptions.asr}
ttsOptions={modelOptions.tts} ttsOptions={modelOptions.tts}
visionOptions={modelOptions.vision} visionOptions={modelOptions.vision}
dynamicVariableOptions={dynamicVariableOptions}
workflowSettings={settings} workflowSettings={settings}
onChange={(patch) => onChange={(patch) =>
updateNodeData(editingNode.id, patch) updateNodeData(editingNode.id, patch)

View File

@@ -16,7 +16,8 @@ import { VisionConfigSection } from "@/components/editor/vision-config-section";
import { TurnConfigEditor } from "@/components/turn-config-editor"; import { TurnConfigEditor } from "@/components/turn-config-editor";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import type { KnowledgeRetrievalConfig } from "@/lib/api"; import type { KnowledgeRetrievalConfig, SystemToolKind } from "@/lib/api";
import { SYSTEM_TOOL_OPTIONS } from "@/lib/system-tools";
import { normalizeTurnConfig } from "@/lib/turn-config"; import { normalizeTurnConfig } from "@/lib/turn-config";
import { NodeSelect, ToolOptionPicker } from "./controls"; import { NodeSelect, ToolOptionPicker } from "./controls";
@@ -38,6 +39,7 @@ export function AgentNodePanel({
asrOptions, asrOptions,
ttsOptions, ttsOptions,
visionOptions, visionOptions,
dynamicVariableOptions,
}: { }: {
draft: WorkflowNodeData; draft: WorkflowNodeData;
set: (key: string, val: unknown) => void; set: (key: string, val: unknown) => void;
@@ -49,6 +51,7 @@ export function AgentNodePanel({
asrOptions: ModelOption[]; asrOptions: ModelOption[];
ttsOptions: ModelOption[]; ttsOptions: ModelOption[];
visionOptions: ModelOption[]; visionOptions: ModelOption[];
dynamicVariableOptions: ModelOption[];
}) { }) {
const inheritsGlobal = draft.inheritGlobalConfig !== false; const inheritsGlobal = draft.inheritGlobalConfig !== false;
const knowledgeConfig: KnowledgeRetrievalConfig = { const knowledgeConfig: KnowledgeRetrievalConfig = {
@@ -101,6 +104,27 @@ export function AgentNodePanel({
turnConfig: agentTurnConfig, 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 toggleStateVariable = (name: string, enabled: boolean) => {
const current = draft.stateVariableNames ?? [];
set(
"stateVariableNames",
enabled
? [...new Set([...current, name])]
: current.filter((item) => item !== name),
);
};
return ( return (
<PanelAnchorNavigation <PanelAnchorNavigation
@@ -109,6 +133,7 @@ export function AgentNodePanel({
{ id: "scope", label: "配置范围" }, { id: "scope", label: "配置范围" },
{ id: "prompt", label: inheritsGlobal ? "任务" : "提示词" }, { id: "prompt", label: inheritsGlobal ? "任务" : "提示词" },
{ id: "entry", label: "进入行为" }, { id: "entry", label: "进入行为" },
{ id: "system-tools", label: "系统工具" },
...(!inheritsGlobal ...(!inheritsGlobal
? [ ? [
{ id: "models", label: "模型与语音" }, { id: "models", label: "模型与语音" },
@@ -186,6 +211,78 @@ export function AgentNodePanel({
</SectionCard> </SectionCard>
</PanelAnchor> </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>
<Switch
checked={enabled}
onCheckedChange={(checked) =>
toggleSystemTool(option.value, checked)
}
aria-label={`启用${option.label}`}
/>
</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>
{!inheritsGlobal && ( {!inheritsGlobal && (
<> <>
<PanelAnchor id="models"> <PanelAnchor id="models">

View File

@@ -12,6 +12,7 @@ import { Textarea } from "@/components/ui/textarea";
import { ActionNodePanel } from "./ActionNodePanel"; import { ActionNodePanel } from "./ActionNodePanel";
import { AgentNodePanel } from "./AgentNodePanel"; import { AgentNodePanel } from "./AgentNodePanel";
import { MessageNodePanel } from "./MessageNodePanel"; import { MessageNodePanel } from "./MessageNodePanel";
import { UpdateStateNodePanel } from "./UpdateStateNodePanel";
import { NodeSelect, ToolOptionPicker } from "./controls"; import { NodeSelect, ToolOptionPicker } from "./controls";
import { import {
PanelAnchor, PanelAnchor,
@@ -31,6 +32,7 @@ export function NodeSettingsPanel({
asrOptions, asrOptions,
ttsOptions, ttsOptions,
visionOptions, visionOptions,
dynamicVariableOptions,
workflowSettings, workflowSettings,
onChange, onChange,
}: { }: {
@@ -43,6 +45,7 @@ export function NodeSettingsPanel({
asrOptions: ModelOption[]; asrOptions: ModelOption[];
ttsOptions: ModelOption[]; ttsOptions: ModelOption[];
visionOptions: ModelOption[]; visionOptions: ModelOption[];
dynamicVariableOptions: ModelOption[];
workflowSettings: WorkflowSettings; workflowSettings: WorkflowSettings;
onChange: (patch: WorkflowNodeData) => void; onChange: (patch: WorkflowNodeData) => void;
}) { }) {
@@ -91,6 +94,8 @@ export function NodeSettingsPanel({
] ]
: spec.type === "action" : spec.type === "action"
? [{ id: "execution", label: "工具执行" }] ? [{ id: "execution", label: "工具执行" }]
: spec.type === "update_state"
? [{ id: "state", label: "状态更新" }]
: spec.type === "handoff" : spec.type === "handoff"
? [{ id: "handoff", label: "转交配置" }] ? [{ id: "handoff", label: "转交配置" }]
: spec.type === "end" : spec.type === "end"
@@ -114,6 +119,7 @@ export function NodeSettingsPanel({
setArgumentsJson={setArgumentsJson} setArgumentsJson={setArgumentsJson}
setAssignmentsJson={setAssignmentsJson} setAssignmentsJson={setAssignmentsJson}
commitActionJson={commitActionJson} commitActionJson={commitActionJson}
dynamicVariableOptions={dynamicVariableOptions}
/> />
</PanelAnchorNavigation> </PanelAnchorNavigation>
); );
@@ -130,6 +136,7 @@ export function NodeSettingsPanel({
asrOptions={asrOptions} asrOptions={asrOptions}
ttsOptions={ttsOptions} ttsOptions={ttsOptions}
visionOptions={visionOptions} visionOptions={visionOptions}
dynamicVariableOptions={dynamicVariableOptions}
/> />
); );
} }
@@ -262,6 +269,14 @@ export function NodeSettingsPanel({
<MessageNodePanel draft={draft} set={set} setPatch={setPatch} /> <MessageNodePanel draft={draft} set={set} setPatch={setPatch} />
)} )}
{spec.type === "update_state" && (
<UpdateStateNodePanel
draft={draft}
set={set}
dynamicVariableOptions={dynamicVariableOptions}
/>
)}
{spec.type === "handoff" && ( {spec.type === "handoff" && (
<NodeSelect <NodeSelect
label="转交类型" label="转交类型"
@@ -306,6 +321,7 @@ function WorkflowNodePanelForm({
setArgumentsJson, setArgumentsJson,
setAssignmentsJson, setAssignmentsJson,
commitActionJson, commitActionJson,
dynamicVariableOptions,
}: { }: {
spec: RuntimeNodeSpec; spec: RuntimeNodeSpec;
draft: WorkflowNodeData; draft: WorkflowNodeData;
@@ -318,6 +334,7 @@ function WorkflowNodePanelForm({
setArgumentsJson: (value: string) => void; setArgumentsJson: (value: string) => void;
setAssignmentsJson: (value: string) => void; setAssignmentsJson: (value: string) => void;
commitActionJson: (argumentsValue: string, assignmentsValue: string) => void; commitActionJson: (argumentsValue: string, assignmentsValue: string) => void;
dynamicVariableOptions: ModelOption[];
}) { }) {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
@@ -341,6 +358,16 @@ function WorkflowNodePanelForm({
<MessageNodePanel draft={draft} set={set} setPatch={setPatch} /> <MessageNodePanel draft={draft} set={set} setPatch={setPatch} />
)} )}
{spec.type === "update_state" && (
<PanelAnchor id="state">
<UpdateStateNodePanel
draft={draft}
set={set}
dynamicVariableOptions={dynamicVariableOptions}
/>
</PanelAnchor>
)}
{spec.type === "handoff" && ( {spec.type === "handoff" && (
<PanelAnchor id="handoff"> <PanelAnchor id="handoff">
<SectionCard <SectionCard

View File

@@ -0,0 +1,79 @@
"use client";
import { Braces } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { SectionCard } from "@/components/editor/section-card";
import type { WorkflowNodeData } from "../specs";
import type { ModelOption } from "../types";
export function UpdateStateNodePanel({
draft,
set,
dynamicVariableOptions,
}: {
draft: WorkflowNodeData;
set: (key: string, value: unknown) => void;
dynamicVariableOptions: ModelOption[];
}) {
const [value, setValue] = useState(
JSON.stringify(draft.assignments ?? {}, null, 2),
);
const [error, setError] = useState("");
const commit = () => {
try {
const assignments = JSON.parse(value) as Record<string, unknown>;
if (
!assignments ||
Array.isArray(assignments) ||
typeof assignments !== "object"
) {
throw new Error();
}
setError("");
set("assignments", assignments);
} catch {
setError("变量赋值必须是 JSON 对象");
}
};
return (
<SectionCard
icon={<Braces size={15} />}
title="状态更新"
description="一次性更新已声明的动态变量,然后继续执行后续节点"
>
<div>
<div className="mb-1.5 text-sm font-medium text-foreground">
</div>
<Textarea
rows={8}
value={value}
onChange={(event) => setValue(event.target.value)}
onBlur={commit}
placeholder={'{\n "customer_name": "张三",\n "confirmed": true\n}'}
className="field-sizing-fixed min-h-44 resize-y border-hairline-strong bg-background font-mono text-xs text-foreground placeholder:text-muted-soft"
/>
{error && <p className="mt-1.5 text-xs text-destructive">{error}</p>}
</div>
<Button type="button" variant="outline" onClick={commit}>
</Button>
<div className="rounded-xl border border-hairline bg-canvas-soft p-3 text-xs leading-5 text-muted-foreground">
{dynamicVariableOptions.length
? dynamicVariableOptions.map((option) => option.label).join("、")
: "暂无,请先在动态变量面板中声明"}
</div>
<p className="text-xs leading-5 text-muted-foreground">
使
{" {{source_name}}"}
</p>
</SectionCard>
);
}

View File

@@ -3,7 +3,7 @@
import * as LucideIcons from "lucide-react"; import * as LucideIcons from "lucide-react";
import { Circle, type LucideIcon } from "lucide-react"; import { Circle, type LucideIcon } from "lucide-react";
import type { NodeSpecDto, TurnConfig } from "@/lib/api"; import type { NodeSpecDto, SystemToolKind, TurnConfig } from "@/lib/api";
import { defaultTurnConfig } from "@/lib/turn-config"; import { defaultTurnConfig } from "@/lib/turn-config";
export type WorkflowNodeType = export type WorkflowNodeType =
@@ -11,6 +11,7 @@ export type WorkflowNodeType =
| "agent" | "agent"
| "message" | "message"
| "action" | "action"
| "update_state"
| "handoff" | "handoff"
| "end"; | "end";
export type ContextPolicy = "inherit" | "fresh"; export type ContextPolicy = "inherit" | "fresh";
@@ -40,6 +41,8 @@ export type WorkflowNodeData = {
contextPolicy?: ContextPolicy; contextPolicy?: ContextPolicy;
inheritGlobalConfig?: boolean; inheritGlobalConfig?: boolean;
entryMode?: AgentEntryMode; entryMode?: AgentEntryMode;
systemTools?: SystemToolKind[];
stateVariableNames?: string[];
toolIds?: string[]; toolIds?: string[];
knowledgeBaseId?: string; knowledgeBaseId?: string;
knowledgeMode?: KnowledgeMode; knowledgeMode?: KnowledgeMode;
@@ -56,6 +59,7 @@ export type WorkflowNodeData = {
arguments?: Record<string, unknown>; arguments?: Record<string, unknown>;
resultAssignmentMode?: ActionResultAssignmentMode; resultAssignmentMode?: ActionResultAssignmentMode;
resultAssignments?: Record<string, string>; resultAssignments?: Record<string, string>;
assignments?: Record<string, unknown>;
userInputPolicy?: ActionUserInputPolicy; userInputPolicy?: ActionUserInputPolicy;
speech?: string; speech?: string;
/** Legacy field read only while an older graph is open in the editor. */ /** Legacy field read only while an older graph is open in the editor. */
@@ -279,6 +283,8 @@ export function defaultGraph(): WorkflowGraph {
contextPolicy: "inherit", contextPolicy: "inherit",
inheritGlobalConfig: true, inheritGlobalConfig: true,
entryMode: "wait_user", entryMode: "wait_user",
systemTools: [],
stateVariableNames: [],
}, },
}, },
{ {

View File

@@ -33,6 +33,7 @@ export type WorkflowEditorProps = {
}; };
toolOptions?: ModelOption[]; toolOptions?: ModelOption[];
knowledgeOptions?: ModelOption[]; knowledgeOptions?: ModelOption[];
dynamicVariableOptions: ModelOption[];
dynamicVariablesOpen: boolean; dynamicVariablesOpen: boolean;
onDynamicVariablesOpenChange: (open: boolean) => void; onDynamicVariablesOpenChange: (open: boolean) => void;
dynamicVariablesPanel: ReactNode; dynamicVariablesPanel: ReactNode;

View File

@@ -612,6 +612,8 @@ export function useVoicePreview(
onNodeActiveRef.current?.(msg.nodeId); onNodeActiveRef.current?.(msg.nodeId);
} else if (msg.type === "workflow-variables") { } else if (msg.type === "workflow-variables") {
setSessionVariables(publicVariableSnapshot(msg.variables)); setSessionVariables(publicVariableSnapshot(msg.variables));
} else if (msg.type === "session-variables") {
setSessionVariables(publicVariableSnapshot(msg.variables));
} else if (msg.type === "call-ended") { } else if (msg.type === "call-ended") {
endedByServerRef.current = true; endedByServerRef.current = true;
setCallEnded(true); setCallEnded(true);

View File

@@ -216,6 +216,12 @@ export type StartupConfig = {
} | null; } | null;
}; };
export type SystemToolKind =
| "end_conversation"
| "update_state"
| "skip_turn"
| "request_human_handoff";
/** 后端 AssistantOut(宽表 STI:瘦字段平铺,workflow 用 graph)。apiKey 读时打码 */ /** 后端 AssistantOut(宽表 STI:瘦字段平铺,workflow 用 graph)。apiKey 读时打码 */
export type Assistant = { export type Assistant = {
id: string; id: string;
@@ -226,6 +232,7 @@ export type Assistant = {
enableInterrupt: boolean; enableInterrupt: boolean;
turnConfig: TurnConfig; turnConfig: TurnConfig;
startup: StartupConfig; startup: StartupConfig;
systemTools: SystemToolKind[];
visionEnabled: boolean; visionEnabled: boolean;
visionModelResourceId: string | null; visionModelResourceId: string | null;
modelResourceIds: Partial<Record<ModelType, string>>; modelResourceIds: Partial<Record<ModelType, string>>;
@@ -354,10 +361,11 @@ export type ToolParameter = {
required: boolean; required: boolean;
}; };
export type EndCallToolDefinition = { export type SystemToolDefinition = {
schemaVersion: number; schemaVersion: number;
type: "end_call"; type: "system";
config: { config: {
kind: "end_conversation";
messageType: "none" | "custom"; messageType: "none" | "custom";
customMessage: string; customMessage: string;
captureReason: boolean; captureReason: boolean;
@@ -411,10 +419,10 @@ export type Tool = {
id: string; id: string;
name: string; name: string;
functionName: string; functionName: string;
type: "end_call" | "http" | "mcp" | "client"; type: "system" | "http" | "mcp" | "client";
description: string; description: string;
definition: definition:
| EndCallToolDefinition | SystemToolDefinition
| HttpToolDefinition | HttpToolDefinition
| ClientToolDefinition | ClientToolDefinition
| McpToolDefinition; | McpToolDefinition;

View File

@@ -0,0 +1,28 @@
import type { SystemToolKind } from "@/lib/api";
export const SYSTEM_TOOL_OPTIONS: Array<{
value: SystemToolKind;
label: string;
description: string;
}> = [
{
value: "end_conversation",
label: "结束对话",
description: "模型可在用户告别或任务完成时主动结束通话。",
},
{
value: "update_state",
label: "更新状态",
description: "模型可更新明确授权的动态变量,记录本轮获得的信息。",
},
{
value: "skip_turn",
label: "跳过本轮",
description: "用户要求稍等、话未说完或输入只是噪音时保持静默。",
},
{
value: "request_human_handoff",
label: "请求人工接管",
description: "模型可提交人工接管请求;请求成功不代表人工已经接通。",
},
];