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

@@ -43,14 +43,14 @@ from services.message_stage import (
MessageStageRunner,
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_policy import policy_for_tool
PREFLIGHT_TIMEOUT_SECONDS = 30
class PromptBrain(BaseBrain):
spec = BrainSpec(
type="prompt",
@@ -123,22 +123,37 @@ class PromptBrain(BaseBrain):
set(cfg.llm_tool_ids) if cfg.llm_tool_ids is not None else None
)
schemas: list[FunctionSchema] = []
registered_names: set[str] = set()
for tool in cfg.tools:
if llm_tool_ids is not None and tool.id not in llm_tool_ids:
continue
if tool.type == "end_call":
if tool.type == "system":
schema, handler = self._make_end_call_tool(tool, runtime)
elif tool.type in {"http", "mcp", "client"}:
schema, handler = self._make_remote_tool(tool, runtime)
else:
continue
schemas.append(schema)
registered_names.add(schema.name)
policy = policy_for_tool(tool)
runtime.llm.register_function(
tool.function_name,
handler,
cancel_on_interruption=policy.cancel_on_interruption,
)
for kind in cfg.system_tools or []:
if kind not in SYSTEM_TOOL_KINDS:
logger.warning(f"忽略未知系统工具: {kind}")
continue
schema, handler = self._make_system_tool(kind, runtime)
if schema.name in registered_names:
logger.warning(
f"跳过系统工具 {schema.name}: 与已绑定工具函数名冲突"
)
continue
registered_names.add(schema.name)
schemas.append(schema)
runtime.llm.register_function(schema.name, handler)
runtime.set_tools(schemas)
async def run_preflight(self) -> None:
@@ -582,3 +597,162 @@ class PromptBrain(BaseBrain):
required=["reason"] if capture_reason else [],
)
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,
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_policy import policy_for_tool
from services.workflow.agent import WorkflowAgentStage
@@ -184,6 +185,7 @@ class WorkflowBrain(BaseBrain):
self._output: WorkflowOutput | None = None
self._agent_stage: WorkflowAgentStage | None = None
self._ended = False
self._waiting_for_generated_end_speech = False
self._next_message_token = 1
self._pending_message: _MessageContinuation | None = None
@@ -229,6 +231,7 @@ class WorkflowBrain(BaseBrain):
runtime=runtime,
)
self._ended = False
self._waiting_for_generated_end_speech = False
self._next_message_token = 1
self._pending_message = None
self._manager = ConfiguredFlowManager(
@@ -494,18 +497,30 @@ class WorkflowBrain(BaseBrain):
return None
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(
self,
_turn_id: str,
content: str,
interrupted: bool,
) -> None:
if not content or interrupted or self._ended:
return
self._store.record("agent", content, completed_agent_turn=True)
self._state.consume_user_turn()
if self._engine.node_type(self._state.current_node_id) == "agent":
self._state.status = WorkflowStatus.WAITING_USER
if content and not interrupted and not self._ended:
self._store.record("agent", content, completed_agent_turn=True)
self._state.consume_user_turn()
if self._engine.node_type(self._state.current_node_id) == "agent":
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:
await self._require_agent_stage().refresh_prompt(node_id)
@@ -528,15 +543,43 @@ class WorkflowBrain(BaseBrain):
) -> NodeConfig:
stage = self._engine.agent_stage_config(node_id)
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:
tool = self._tool_by_id.get(str(tool_id))
if tool and tool.type in {"http", "mcp", "client"}:
functions.append(self._flow_tool(tool, node_id))
knowledge_function = self._knowledge_function(node_id)
if knowledge_function:
functions.append(knowledge_function)
append_function(self._flow_tool(tool, node_id))
append_function(self._knowledge_function(node_id))
if stage.vision_enabled and self._require_runtime().vision_function:
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(
node_id,
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(
self,
node_config: NodeConfig,
@@ -838,6 +1034,8 @@ class WorkflowBrain(BaseBrain):
outcome = await self._enter_action(node_id)
if not outcome.should_route:
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":
self._prepare_message_continuation(
node_id,
@@ -862,6 +1060,22 @@ class WorkflowBrain(BaseBrain):
node_id = str(edge.get("target") or "")
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(
self,
node_id: str,

View File

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

View File

@@ -11,15 +11,30 @@ from services.message_policy import (
MESSAGE_CONFIRMATION,
MESSAGE_PLAYBACK,
)
from services.system_tools import SYSTEM_TOOL_KINDS, normalize_system_tools
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"}
AGENT_ENTRY_MODES = {"wait_user", "generate"}
ACTION_RESULT_ASSIGNMENT_MODES = {"inherit", "override", "none"}
ACTION_USER_INPUT_POLICIES = {"queue", "block"}
AUTOMATIC_NODE_TYPES = {"start", "message", "action", "handoff"}
AUTOMATIC_NODE_TYPES = {
"start",
"message",
"action",
"update_state",
"handoff",
}
EXPRESSION_OPERATORS = {
"eq",
"neq",
@@ -98,6 +113,24 @@ NODE_SPECS: list[dict[str, Any]] = [
{"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",
"displayName": "Handoff",
@@ -160,6 +193,11 @@ def _normalize_agent_data(data: dict[str, Any]) -> None:
if data.get("entryMode") not in AGENT_ENTRY_MODES:
data["entryMode"] = "wait_user"
data.pop("entrySpeech", None)
data["systemTools"] = list(normalize_system_tools(data.get("systemTools")))
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:
has_node_overrides = any(
(
@@ -194,6 +232,12 @@ def _normalize_action_data(data: dict[str, Any]) -> 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:
"""Fill the small built-in Message contract used by runtime and editor."""
data.setdefault("speech", "")
@@ -246,6 +290,8 @@ def normalize_graph(graph: dict[str, Any] | None) -> dict[str, Any]:
_normalize_message_data(data)
elif node.get("type") == "action":
_normalize_action_data(data)
elif node.get("type") == "update_state":
_normalize_update_state_data(data)
return source
nodes = source.get("nodes") or []
@@ -262,6 +308,7 @@ def normalize_graph(graph: dict[str, Any] | None) -> dict[str, Any]:
"agent": "agent",
"message": "message",
"action": "action",
"update_state": "update_state",
"handoff": "handoff",
"end": "end",
}
@@ -283,6 +330,8 @@ def normalize_graph(graph: dict[str, Any] | None) -> dict[str, Any]:
_normalize_message_data(data)
elif new_type == "action":
_normalize_action_data(data)
elif new_type == "update_state":
_normalize_update_state_data(data)
elif new_type == "start":
prompt = str(data.pop("prompt", "") or "").strip()
data.pop("greeting", None)
@@ -390,6 +439,16 @@ def validate_graph(graph: dict[str, Any]) -> list[str]:
entry_mode = data.get("entryMode", "wait_user")
if entry_mode not in AGENT_ENTRY_MODES:
errors.append(f"Agent 节点 {node_id} 的进入模式无效:{entry_mode}")
system_tools = data.get("systemTools", [])
if not isinstance(system_tools, list) or any(
tool not in SYSTEM_TOOL_KINDS for tool in system_tools
):
errors.append(f"Agent 节点 {node_id} 的系统工具配置无效")
state_names = data.get("stateVariableNames", [])
if not isinstance(state_names, list) or any(
not isinstance(name, str) for name in state_names
):
errors.append(f"Agent 节点 {node_id} 的状态变量授权必须是列表")
elif node_type == "message":
data = node.get("data") or {}
speech = data.get("speech")
@@ -447,6 +506,13 @@ def validate_graph(graph: dict[str, Any]) -> list[str]:
errors.append(
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:
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.runtime_variables import DynamicVariableStore
from services.system_tools import normalize_system_tools
@dataclass(frozen=True)
@@ -24,6 +25,8 @@ class AgentStageConfig:
vision_enabled: bool
vision_model_resource_id: str | None
tool_ids: tuple[str, ...]
system_tools: tuple[str, ...]
state_variable_names: tuple[str, ...]
knowledge_base_id: str | None
knowledge_mode: str
knowledge_top_n: int
@@ -145,6 +148,16 @@ class WorkflowEngine:
str(source.get("visionModelResourceId") or "") or None
),
tool_ids=tuple(str(tool_id) for tool_id in source.get("toolIds") or []),
# System tools and their writable state scope always belong to the
# Agent node. They are permissions, not inheritable model config.
system_tools=normalize_system_tools(data.get("systemTools")),
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_mode=(
str(source.get("knowledgeMode") or "automatic")