feat(workflow): add edge-tool routing and realtime runtime
This commit is contained in:
@@ -20,6 +20,11 @@ AGENT_STAGE_INSTRUCTION = (
|
||||
"工作流路由已在用户一轮输入结束时完成。只执行当前阶段任务,"
|
||||
"不要自行解释、模拟或宣布节点切换。"
|
||||
)
|
||||
EDGE_TOOL_STAGE_INSTRUCTION = (
|
||||
"只执行当前阶段任务。若某个工作流跳转工具的条件已经明确满足,"
|
||||
"必须只调用一个对应的 goto 工具,不要口头宣布、解释或模拟节点跳转。"
|
||||
"若没有条件满足,则正常回答用户并停留在当前阶段。"
|
||||
)
|
||||
|
||||
|
||||
class WorkflowAgentStage:
|
||||
@@ -43,9 +48,14 @@ class WorkflowAgentStage:
|
||||
stage = self._engine.agent_stage_config(node_id)
|
||||
if stage.vision_enabled:
|
||||
stage_prompt = f"{stage_prompt}\n\n[视觉能力]\n{VISION_SYSTEM_HINT}"
|
||||
instruction = (
|
||||
EDGE_TOOL_STAGE_INSTRUCTION
|
||||
if self._engine.llm_routing_mode() == "edge_tool"
|
||||
else AGENT_STAGE_INSTRUCTION
|
||||
)
|
||||
return (
|
||||
f"{stage_prompt}\n\n[工作流执行规则]\n"
|
||||
f"{AGENT_STAGE_INSTRUCTION}"
|
||||
f"{instruction}"
|
||||
)
|
||||
|
||||
async def refresh_prompt(self, node_id: str) -> None:
|
||||
|
||||
918
backend/services/workflow/realtime.py
Normal file
918
backend/services/workflow/realtime.py
Normal file
@@ -0,0 +1,918 @@
|
||||
"""Realtime Workflow orchestration without a Pipecat FlowManager."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
from models import AssistantConfig, RuntimeTool
|
||||
from pipecat.frames.frames import OutputTransportMessageUrgentFrame
|
||||
from pipecat.utils.time import time_now_iso8601
|
||||
|
||||
from db.session import SessionLocal
|
||||
from services.action_runtime import ActionRunner, ActionStatus
|
||||
from services.action_stage import ActionStageRunner, ActionStageSpec, StageAction
|
||||
from services.brains.base import RealtimeBrainRuntime, SessionVariableUpdate
|
||||
from services.knowledge import search as search_knowledge
|
||||
from services.message_policy import MESSAGE_CONFIRMATION, MESSAGE_INTERRUPTIBLE
|
||||
from services.message_stage import (
|
||||
MessageDisplaySpec,
|
||||
MessageStageRunner,
|
||||
MessageStageSpec,
|
||||
)
|
||||
from services.pipecat.realtime_tools import RealtimeTool, RealtimeToolResult
|
||||
from services.runtime_variables import DynamicVariableError, DynamicVariableStore
|
||||
from services.system_tools import state_update_properties, system_tool_kind
|
||||
from services.tool_executor import ToolExecutionError, ToolExecutor
|
||||
from services.tool_policy import policy_for_tool
|
||||
from services.workflow.agent import EDGE_TOOL_STAGE_INSTRUCTION
|
||||
from services.workflow.models import WorkflowRuntimeState, WorkflowStatus
|
||||
from services.workflow.output import WorkflowOutput
|
||||
from services.workflow_engine import WorkflowEngine
|
||||
|
||||
|
||||
MAX_AUTOMATIC_HOPS = 50
|
||||
ToolHandler = Callable[[dict[str, Any]], Awaitable[RealtimeToolResult]]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RealtimeActivation:
|
||||
"""How the provider should continue after a completed function call."""
|
||||
|
||||
continue_response: bool = False
|
||||
after_output: Callable[[], Awaitable[None]] | None = None
|
||||
|
||||
|
||||
class RealtimeWorkflowOutput(WorkflowOutput):
|
||||
"""Emit exact configured text while the realtime model produces its audio."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store: DynamicVariableStore,
|
||||
runtime: RealtimeBrainRuntime,
|
||||
) -> None:
|
||||
super().__init__(store, runtime) # type: ignore[arg-type]
|
||||
self._realtime = runtime.realtime
|
||||
|
||||
async def speak(
|
||||
self,
|
||||
text: str,
|
||||
*,
|
||||
source: str,
|
||||
node_id: str | None = None,
|
||||
record_history: bool = True,
|
||||
) -> Awaitable[None] | None:
|
||||
content = text.strip()
|
||||
if not content:
|
||||
return None
|
||||
if record_history:
|
||||
self._store.record("agent", content)
|
||||
await self.emit(
|
||||
{
|
||||
"type": "transcript",
|
||||
"role": "assistant",
|
||||
"content": content,
|
||||
"timestamp": time_now_iso8601(),
|
||||
"source": source,
|
||||
**({"nodeId": node_id} if node_id else {}),
|
||||
}
|
||||
)
|
||||
return await self._realtime.speak_fixed(
|
||||
content,
|
||||
suppress_transcript=True,
|
||||
)
|
||||
|
||||
|
||||
class WorkflowRealtimeController:
|
||||
"""Run Workflow v3 over one StepFun or Qwen realtime session."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
cfg: AssistantConfig,
|
||||
engine: WorkflowEngine,
|
||||
store: DynamicVariableStore,
|
||||
runtime: RealtimeBrainRuntime,
|
||||
) -> None:
|
||||
self._cfg = cfg
|
||||
self._engine = engine
|
||||
self._store = store
|
||||
self._runtime = runtime
|
||||
self._state = WorkflowRuntimeState(current_node_id=engine.start_id or "")
|
||||
self._output = RealtimeWorkflowOutput(store, runtime)
|
||||
self._tools = ToolExecutor(store, client_tools=runtime.client_tools)
|
||||
self._actions = ActionRunner(
|
||||
self._tools,
|
||||
is_session_ending=lambda: runtime.call_end.ending,
|
||||
)
|
||||
self._action_stages = ActionStageRunner(self._actions)
|
||||
self._message_stages = MessageStageRunner(runtime.client_tools)
|
||||
self._tool_by_id = {tool.id: tool for tool in cfg.tools}
|
||||
self._handlers: dict[str, ToolHandler] = {}
|
||||
self._turn_lock = asyncio.Lock()
|
||||
self._started = False
|
||||
self._ended = False
|
||||
self._waiting_generated_end = False
|
||||
self._message_advanced = asyncio.Event()
|
||||
self._last_recorded_user_text = ""
|
||||
|
||||
def system_prompt(self) -> str:
|
||||
current = self._state.current_node_id
|
||||
if self._engine.node_type(current) == "agent":
|
||||
return self._agent_prompt(current)
|
||||
return self._store.render(self._engine.global_prompt())
|
||||
|
||||
async def start(self) -> None:
|
||||
if self._started:
|
||||
return
|
||||
self._started = True
|
||||
await self._output.mark_client_ready()
|
||||
self._state.enter(self._engine.start_id or "", WorkflowStatus.STARTING)
|
||||
await self._emit_active(self._state.current_node_id)
|
||||
await self._emit_variables("initialized", self._state.current_node_id)
|
||||
activation = await self._resolve_path(
|
||||
self._state.current_node_id,
|
||||
allow_visible_actions=True,
|
||||
)
|
||||
if activation.continue_response:
|
||||
await self._runtime.realtime.request_response()
|
||||
|
||||
async def on_client_ready(self) -> None:
|
||||
await self._output.mark_client_ready()
|
||||
await self._emit_active(self._state.current_node_id)
|
||||
await self._emit_variables("client_ready", self._state.current_node_id)
|
||||
|
||||
async def on_session_update(
|
||||
self,
|
||||
dynamic_variables: dict[str, Any],
|
||||
) -> SessionVariableUpdate:
|
||||
if self._ended:
|
||||
raise ValueError("工作流会话已经结束")
|
||||
async with self._turn_lock:
|
||||
changed = self._store.assign_declared_many(dynamic_variables)
|
||||
activation = await self._after_variables_changed(
|
||||
changed,
|
||||
reason="session_update",
|
||||
)
|
||||
if activation and activation.after_output:
|
||||
await activation.after_output()
|
||||
elif activation and activation.continue_response:
|
||||
await self._runtime.realtime.request_response()
|
||||
return SessionVariableUpdate(
|
||||
changed=changed,
|
||||
dynamic_variables=self._store.public_values(),
|
||||
)
|
||||
|
||||
async def handle_text_input(self, content: str) -> bool:
|
||||
"""Own realtime text input so the active node is updated first."""
|
||||
if not content or self._ended:
|
||||
return True
|
||||
if self._state.status in {
|
||||
WorkflowStatus.RUNNING_ACTION,
|
||||
WorkflowStatus.ENDED,
|
||||
}:
|
||||
return True
|
||||
self._store.record("user", content)
|
||||
self._last_recorded_user_text = content
|
||||
self._state.begin_user_turn(content)
|
||||
async with self._turn_lock:
|
||||
current = self._state.current_node_id
|
||||
edge = self._engine.deterministic_edge(
|
||||
current,
|
||||
self._store,
|
||||
include_default=False,
|
||||
)
|
||||
if edge:
|
||||
await self._runtime.realtime.send_text(
|
||||
content,
|
||||
run_immediately=False,
|
||||
)
|
||||
activation = await self._follow_edge(
|
||||
edge,
|
||||
allow_visible_actions=True,
|
||||
)
|
||||
if activation.continue_response:
|
||||
await self._runtime.realtime.request_response()
|
||||
return True
|
||||
await self._runtime.realtime.send_text(content, run_immediately=True)
|
||||
return True
|
||||
|
||||
async def on_user_speech_started(self) -> None:
|
||||
"""Let an interruptible Message finish its deterministic continuation."""
|
||||
current = self._state.current_node_id
|
||||
if (
|
||||
self._engine.node_type(current) != "message"
|
||||
or self._message_completion_policy(current) != MESSAGE_INTERRUPTIBLE
|
||||
):
|
||||
return
|
||||
try:
|
||||
await asyncio.wait_for(self._message_advanced.wait(), timeout=1.0)
|
||||
except TimeoutError:
|
||||
logger.warning("Realtime Message 被打断后未能及时进入下一节点")
|
||||
|
||||
def record_user_message(self, content: str) -> None:
|
||||
if content and not self._ended:
|
||||
if content == self._last_recorded_user_text:
|
||||
self._last_recorded_user_text = ""
|
||||
return
|
||||
self._store.record("user", content)
|
||||
|
||||
async def on_assistant_text_end(
|
||||
self,
|
||||
content: str,
|
||||
interrupted: bool,
|
||||
) -> None:
|
||||
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_generated_end and self._runtime.call_end.ending:
|
||||
self._waiting_generated_end = False
|
||||
await self._runtime.call_end.finish_after_current_speech(
|
||||
has_text=bool(content.strip()) and not interrupted
|
||||
)
|
||||
|
||||
async def dispatch_tool(
|
||||
self,
|
||||
name: str,
|
||||
arguments: dict[str, Any],
|
||||
_call_id: str,
|
||||
) -> RealtimeToolResult:
|
||||
handler = self._handlers.get(name)
|
||||
if handler is None:
|
||||
return RealtimeToolResult(
|
||||
{"status": "error", "message": f"当前节点没有工具:{name}"}
|
||||
)
|
||||
return await handler(arguments)
|
||||
|
||||
async def _resolve_path(
|
||||
self,
|
||||
node_id: str,
|
||||
*,
|
||||
allow_visible_actions: bool,
|
||||
) -> RealtimeActivation:
|
||||
for hop in range(MAX_AUTOMATIC_HOPS):
|
||||
self._state.automatic_hops = hop
|
||||
node_type = self._engine.node_type(node_id)
|
||||
if node_type == "agent":
|
||||
return await self._activate_agent(node_id)
|
||||
if not allow_visible_actions and node_type in {
|
||||
"message",
|
||||
"action",
|
||||
"update_state",
|
||||
"handoff",
|
||||
"end",
|
||||
}:
|
||||
return RealtimeActivation(
|
||||
after_output=lambda node_id=node_id: self._run_deferred_path(node_id)
|
||||
)
|
||||
if node_type == "end":
|
||||
await self._enter_end(node_id)
|
||||
return RealtimeActivation()
|
||||
if node_type == "message":
|
||||
succeeded = await self._enter_message(node_id)
|
||||
if not succeeded:
|
||||
return RealtimeActivation()
|
||||
elif node_type == "action":
|
||||
should_route = await self._enter_action(node_id)
|
||||
if not should_route:
|
||||
return RealtimeActivation()
|
||||
elif node_type == "update_state":
|
||||
await self._enter_update_state(node_id)
|
||||
elif node_type == "handoff":
|
||||
await self._enter_handoff(node_id)
|
||||
elif node_type == "start":
|
||||
self._state.enter(node_id, WorkflowStatus.STARTING)
|
||||
await self._emit_active(node_id)
|
||||
else:
|
||||
await self._output.emit_error(
|
||||
f"工作流指向未知节点:{node_id}",
|
||||
node_id=node_id,
|
||||
)
|
||||
return RealtimeActivation()
|
||||
|
||||
edge = self._engine.deterministic_edge(
|
||||
node_id,
|
||||
self._store,
|
||||
include_default=True,
|
||||
)
|
||||
if not edge:
|
||||
self._state.enter(node_id, WorkflowStatus.WAITING_USER)
|
||||
if (
|
||||
node_type != "agent"
|
||||
and self._runtime.set_input_enabled is not None
|
||||
):
|
||||
self._runtime.set_input_enabled(False)
|
||||
return RealtimeActivation()
|
||||
await self._begin_edge(edge)
|
||||
node_id = str(edge.get("target") or "")
|
||||
await self._output.emit_error(
|
||||
"工作流连续自动跳转超过安全上限",
|
||||
node_id=node_id,
|
||||
)
|
||||
return RealtimeActivation()
|
||||
|
||||
async def _run_deferred_path(self, node_id: str) -> None:
|
||||
activation = await self._resolve_path(node_id, allow_visible_actions=True)
|
||||
if activation.after_output:
|
||||
await activation.after_output()
|
||||
elif activation.continue_response:
|
||||
await self._runtime.realtime.request_response()
|
||||
|
||||
async def _follow_edge(
|
||||
self,
|
||||
edge: dict,
|
||||
*,
|
||||
allow_visible_actions: bool,
|
||||
) -> RealtimeActivation:
|
||||
await self._begin_edge(edge)
|
||||
return await self._resolve_path(
|
||||
str(edge.get("target") or ""),
|
||||
allow_visible_actions=allow_visible_actions,
|
||||
)
|
||||
|
||||
async def _begin_edge(self, edge: dict) -> None:
|
||||
transition_id = self._state.begin_transition()
|
||||
await self._output.emit_trace(
|
||||
"edge_selected",
|
||||
revision=self._engine.revision,
|
||||
transition_id=transition_id,
|
||||
edgeId=str(edge.get("id") or ""),
|
||||
sourceNodeId=str(edge.get("source") or ""),
|
||||
targetNodeId=str(edge.get("target") or ""),
|
||||
mode=self._engine.edge_mode(edge),
|
||||
)
|
||||
|
||||
async def _activate_agent(self, node_id: str) -> RealtimeActivation:
|
||||
self._state.enter(node_id, WorkflowStatus.WAITING_USER)
|
||||
self._message_advanced.set()
|
||||
if self._runtime.set_input_enabled:
|
||||
self._runtime.set_input_enabled(True)
|
||||
await self._emit_active(node_id)
|
||||
tools = self._build_agent_tools(node_id)
|
||||
prompt = self._agent_prompt(node_id)
|
||||
await self._runtime.realtime.update_session(prompt, tools)
|
||||
generate = str(
|
||||
self._engine.data(node_id).get("entryMode") or "wait_user"
|
||||
) == "generate"
|
||||
if generate:
|
||||
self._state.status = WorkflowStatus.RUNNING_AGENT
|
||||
else:
|
||||
self._state.consume_user_turn()
|
||||
return RealtimeActivation(continue_response=generate)
|
||||
|
||||
def _agent_prompt(self, node_id: str) -> str:
|
||||
return (
|
||||
f"{self._engine.prompt_for(node_id, self._store)}\n\n"
|
||||
f"[工作流执行规则]\n{EDGE_TOOL_STAGE_INSTRUCTION}"
|
||||
)
|
||||
|
||||
def _build_agent_tools(self, node_id: str) -> list[RealtimeTool]:
|
||||
stage = self._engine.agent_stage_config(node_id)
|
||||
transition_id = self._state.transition_id
|
||||
tools: list[RealtimeTool] = []
|
||||
handlers: dict[str, ToolHandler] = {}
|
||||
|
||||
def add(tool: RealtimeTool, handler: ToolHandler) -> None:
|
||||
if tool.name in handlers:
|
||||
logger.warning(f"跳过 Realtime Agent 的重复工具名:{tool.name}")
|
||||
return
|
||||
tools.append(tool)
|
||||
handlers[tool.name] = handler
|
||||
|
||||
for tool_id in stage.tool_ids:
|
||||
runtime_tool = self._tool_by_id.get(str(tool_id))
|
||||
if not runtime_tool:
|
||||
continue
|
||||
if runtime_tool.type == "system":
|
||||
built = self._system_tool(
|
||||
runtime_tool,
|
||||
node_id=node_id,
|
||||
transition_id=transition_id,
|
||||
state_variable_names=stage.state_variable_names,
|
||||
)
|
||||
elif runtime_tool.type in {"http", "mcp", "client"}:
|
||||
built = self._business_tool(
|
||||
runtime_tool,
|
||||
node_id=node_id,
|
||||
transition_id=transition_id,
|
||||
)
|
||||
else:
|
||||
built = None
|
||||
if built:
|
||||
add(*built)
|
||||
|
||||
knowledge = self._knowledge_tool(node_id, transition_id)
|
||||
if knowledge:
|
||||
add(*knowledge)
|
||||
for edge in self._engine.edge_tool_edges(node_id):
|
||||
add(*self._transition_tool(edge, node_id, transition_id))
|
||||
self._handlers = handlers
|
||||
return tools
|
||||
|
||||
def _transition_tool(
|
||||
self,
|
||||
edge: dict,
|
||||
node_id: str,
|
||||
transition_id: int,
|
||||
) -> tuple[RealtimeTool, ToolHandler]:
|
||||
async def handler(_arguments: dict[str, Any]) -> RealtimeToolResult:
|
||||
async with self._turn_lock:
|
||||
if not self._is_current(node_id, transition_id):
|
||||
return RealtimeToolResult(
|
||||
{"status": "stale", "message": "当前 Agent 已经切换。"},
|
||||
continue_response=False,
|
||||
)
|
||||
activation = await self._follow_edge(
|
||||
edge,
|
||||
allow_visible_actions=False,
|
||||
)
|
||||
return RealtimeToolResult(
|
||||
{
|
||||
"status": "success",
|
||||
"targetNodeId": str(edge.get("target") or ""),
|
||||
},
|
||||
continue_response=activation.continue_response,
|
||||
after_output=activation.after_output,
|
||||
)
|
||||
|
||||
return (
|
||||
RealtimeTool(
|
||||
name=self._engine.edge_fn_name(edge),
|
||||
description=self._engine.edge_description(edge),
|
||||
),
|
||||
handler,
|
||||
)
|
||||
|
||||
def _business_tool(
|
||||
self,
|
||||
tool: RuntimeTool,
|
||||
*,
|
||||
node_id: str,
|
||||
transition_id: int,
|
||||
) -> tuple[RealtimeTool, ToolHandler]:
|
||||
properties, required = self._tools.schema_parts(tool)
|
||||
self._tools.register_secrets(tool)
|
||||
policy = policy_for_tool(tool)
|
||||
|
||||
async def handler(arguments: dict[str, Any]) -> RealtimeToolResult:
|
||||
try:
|
||||
result = await self._tools.execute(tool, arguments)
|
||||
except ToolExecutionError as exc:
|
||||
return RealtimeToolResult(
|
||||
{"status": "error", "message": str(exc)}
|
||||
)
|
||||
async with self._turn_lock:
|
||||
if not self._is_current(node_id, transition_id):
|
||||
return RealtimeToolResult(
|
||||
{
|
||||
**result,
|
||||
"status": "stale",
|
||||
"message": "工具完成时当前 Agent 已经切换。",
|
||||
},
|
||||
continue_response=False,
|
||||
)
|
||||
activation = await self._after_variables_changed(
|
||||
list(result.get("updated_variables") or []),
|
||||
reason="tool",
|
||||
)
|
||||
return RealtimeToolResult(
|
||||
result,
|
||||
continue_response=(
|
||||
activation.continue_response
|
||||
if activation
|
||||
else policy.runs_llm_after_result
|
||||
),
|
||||
after_output=activation.after_output if activation else None,
|
||||
)
|
||||
|
||||
return (
|
||||
RealtimeTool(
|
||||
name=tool.function_name,
|
||||
description=tool.description or f"调用 {tool.name}",
|
||||
properties=properties,
|
||||
required=tuple(required),
|
||||
),
|
||||
handler,
|
||||
)
|
||||
|
||||
def _system_tool(
|
||||
self,
|
||||
tool: RuntimeTool,
|
||||
*,
|
||||
node_id: str,
|
||||
transition_id: int,
|
||||
state_variable_names: tuple[str, ...],
|
||||
) -> tuple[RealtimeTool, ToolHandler] | None:
|
||||
kind = system_tool_kind(tool.definition or {})
|
||||
if kind == "update_state":
|
||||
allowed = frozenset(state_variable_names)
|
||||
|
||||
async def update_state(arguments: dict[str, Any]) -> RealtimeToolResult:
|
||||
unauthorized = sorted(set(arguments) - allowed)
|
||||
if unauthorized:
|
||||
return RealtimeToolResult(
|
||||
{
|
||||
"status": "error",
|
||||
"message": "状态变量未获当前节点授权:"
|
||||
+ ",".join(unauthorized),
|
||||
}
|
||||
)
|
||||
try:
|
||||
changed = self._store.assign_declared_many(arguments)
|
||||
except DynamicVariableError as exc:
|
||||
return RealtimeToolResult(
|
||||
{"status": "error", "message": f"状态更新失败:{exc}"}
|
||||
)
|
||||
async with self._turn_lock:
|
||||
if not self._is_current(node_id, transition_id):
|
||||
return RealtimeToolResult(
|
||||
{"status": "stale", "message": "当前 Agent 已经切换。"},
|
||||
continue_response=False,
|
||||
)
|
||||
activation = await self._after_variables_changed(
|
||||
changed,
|
||||
reason="update_state",
|
||||
)
|
||||
return RealtimeToolResult(
|
||||
{
|
||||
"status": "success",
|
||||
"changed": changed,
|
||||
"variables": self._store.public_values(),
|
||||
},
|
||||
continue_response=(
|
||||
activation.continue_response if activation else True
|
||||
),
|
||||
after_output=activation.after_output if activation else None,
|
||||
)
|
||||
|
||||
return (
|
||||
RealtimeTool(
|
||||
name=tool.function_name,
|
||||
description=tool.description or "更新已授权的动态变量。",
|
||||
properties=state_update_properties(
|
||||
self._cfg.dynamic_variable_definitions,
|
||||
allowed_names=state_variable_names,
|
||||
),
|
||||
),
|
||||
update_state,
|
||||
)
|
||||
if kind == "skip_turn":
|
||||
async def skip_turn(arguments: dict[str, Any]) -> RealtimeToolResult:
|
||||
return RealtimeToolResult(
|
||||
{
|
||||
"status": "success",
|
||||
"action": "skip_turn",
|
||||
"reason": str(arguments.get("reason") or ""),
|
||||
},
|
||||
continue_response=False,
|
||||
)
|
||||
|
||||
return (
|
||||
RealtimeTool(
|
||||
name=tool.function_name,
|
||||
description=tool.description or "跳过当前轮次,不生成语音回复。",
|
||||
properties={
|
||||
"reason": {"type": "string", "description": "跳过原因"}
|
||||
},
|
||||
),
|
||||
skip_turn,
|
||||
)
|
||||
if kind == "request_human_handoff":
|
||||
async def request_handoff(arguments: dict[str, Any]) -> RealtimeToolResult:
|
||||
reason = str(arguments.get("reason") or "human_handoff")
|
||||
await self._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 RealtimeToolResult(
|
||||
{
|
||||
"status": "requested",
|
||||
"action": "human_handoff_requested",
|
||||
}
|
||||
)
|
||||
|
||||
return (
|
||||
RealtimeTool(
|
||||
name=tool.function_name,
|
||||
description=tool.description or "提交人工接管请求。",
|
||||
properties={
|
||||
"reason": {"type": "string", "description": "转接原因"}
|
||||
},
|
||||
),
|
||||
request_handoff,
|
||||
)
|
||||
if kind == "end_conversation":
|
||||
async def end_conversation(arguments: dict[str, Any]) -> RealtimeToolResult:
|
||||
reason = str(arguments.get("reason") or "end_conversation")
|
||||
self._waiting_generated_end = True
|
||||
self._runtime.call_end.begin(reason)
|
||||
return RealtimeToolResult(
|
||||
{"status": "success", "action": "ending_call"}
|
||||
)
|
||||
|
||||
return (
|
||||
RealtimeTool(
|
||||
name=tool.function_name,
|
||||
description=tool.description or "礼貌结束本次对话。",
|
||||
properties={
|
||||
"reason": {"type": "string", "description": "结束原因"}
|
||||
},
|
||||
),
|
||||
end_conversation,
|
||||
)
|
||||
return None
|
||||
|
||||
def _knowledge_tool(
|
||||
self,
|
||||
node_id: str,
|
||||
transition_id: int,
|
||||
) -> tuple[RealtimeTool, ToolHandler] | None:
|
||||
stage = self._engine.agent_stage_config(node_id)
|
||||
knowledge_id = str(stage.knowledge_base_id or "")
|
||||
if not knowledge_id or stage.knowledge_mode != "on_demand":
|
||||
return None
|
||||
|
||||
async def handler(arguments: dict[str, Any]) -> RealtimeToolResult:
|
||||
if not self._is_current(node_id, transition_id):
|
||||
return RealtimeToolResult(
|
||||
{"status": "stale", "message": "当前 Agent 已经切换。"},
|
||||
continue_response=False,
|
||||
)
|
||||
query = str(arguments.get("query") or "").strip()
|
||||
if not query:
|
||||
return RealtimeToolResult(
|
||||
{"status": "error", "message": "检索问题为空"}
|
||||
)
|
||||
try:
|
||||
async with SessionLocal() as session:
|
||||
results = await search_knowledge(
|
||||
session,
|
||||
knowledge_id,
|
||||
query,
|
||||
top_k=stage.knowledge_top_n,
|
||||
score_threshold=stage.knowledge_score_threshold,
|
||||
)
|
||||
return RealtimeToolResult({"status": "ok", "results": results})
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning(f"Realtime Workflow 知识库检索失败:{exc}")
|
||||
return RealtimeToolResult(
|
||||
{"status": "error", "message": "知识库检索暂时不可用"}
|
||||
)
|
||||
|
||||
return (
|
||||
RealtimeTool(
|
||||
name="search_knowledge_base",
|
||||
description="在当前 Agent 绑定的知识库中检索资料。",
|
||||
properties={
|
||||
"query": {"type": "string", "description": "完整问题或关键词"}
|
||||
},
|
||||
required=("query",),
|
||||
),
|
||||
handler,
|
||||
)
|
||||
|
||||
async def _after_variables_changed(
|
||||
self,
|
||||
changed: list[str],
|
||||
*,
|
||||
reason: str,
|
||||
) -> RealtimeActivation | None:
|
||||
if not changed:
|
||||
return None
|
||||
current = self._state.current_node_id
|
||||
await self._emit_variables(reason, current, changed)
|
||||
edge = self._engine.deterministic_edge(
|
||||
current,
|
||||
self._store,
|
||||
include_default=False,
|
||||
)
|
||||
if edge:
|
||||
return await self._follow_edge(edge, allow_visible_actions=False)
|
||||
if self._engine.node_type(current) == "agent":
|
||||
await self._runtime.realtime.update_session(
|
||||
self._agent_prompt(current),
|
||||
self._build_agent_tools(current),
|
||||
)
|
||||
return None
|
||||
|
||||
async def _enter_message(self, node_id: str) -> bool:
|
||||
self._state.enter(node_id, WorkflowStatus.RUNNING_MESSAGE)
|
||||
self._message_advanced.clear()
|
||||
await self._emit_active(node_id)
|
||||
data = self._engine.data(node_id)
|
||||
speech = self._store.render(str(data.get("speech") or "")).strip()
|
||||
policy = self._message_completion_policy(node_id)
|
||||
confirmation = policy == MESSAGE_CONFIRMATION
|
||||
if self._runtime.set_input_enabled:
|
||||
self._runtime.set_input_enabled(policy == MESSAGE_INTERRUPTIBLE)
|
||||
result = await self._message_stages.run(
|
||||
MessageStageSpec(
|
||||
speech=speech,
|
||||
display=(
|
||||
MessageDisplaySpec(
|
||||
title=self._store.render(
|
||||
str(data.get("title") or "重要提示")
|
||||
),
|
||||
message=self._store.render(str(data.get("message") or "")),
|
||||
confirm_label=self._store.render(
|
||||
str(data.get("confirmLabel") or "确认")
|
||||
),
|
||||
)
|
||||
if confirmation
|
||||
else None
|
||||
),
|
||||
completion_policy=policy,
|
||||
),
|
||||
speak=lambda text: self._output.speak(
|
||||
text,
|
||||
source="workflow-message-speech",
|
||||
node_id=node_id,
|
||||
),
|
||||
set_input_enabled=self._runtime.set_input_enabled,
|
||||
input_already_blocked=policy != MESSAGE_INTERRUPTIBLE,
|
||||
)
|
||||
if not result.succeeded:
|
||||
await self._output.emit_error(
|
||||
result.error or "Message 节点执行失败",
|
||||
node_id=node_id,
|
||||
code="workflow_message_error",
|
||||
)
|
||||
return result.succeeded
|
||||
|
||||
async def _enter_action(self, node_id: str) -> bool:
|
||||
self._state.enter(node_id, WorkflowStatus.RUNNING_ACTION)
|
||||
await self._emit_active(node_id)
|
||||
if self._runtime.set_input_enabled:
|
||||
self._runtime.set_input_enabled(False)
|
||||
data = self._engine.data(node_id)
|
||||
tool = self._tool_by_id.get(str(data.get("toolId") or ""))
|
||||
result = await self._action_stages.run(
|
||||
ActionStageSpec(
|
||||
actions=(
|
||||
StageAction(
|
||||
id=node_id,
|
||||
tool=tool,
|
||||
arguments=data.get("arguments") or {},
|
||||
result_assignments=self._action_result_assignments(data),
|
||||
),
|
||||
),
|
||||
input_policy="block",
|
||||
),
|
||||
set_input_enabled=self._runtime.set_input_enabled,
|
||||
input_already_blocked=True,
|
||||
)
|
||||
outcome = result.outcomes[0]
|
||||
self._set_last_action(outcome)
|
||||
if outcome.updated_variables:
|
||||
await self._emit_variables(
|
||||
"action",
|
||||
node_id,
|
||||
list(outcome.updated_variables),
|
||||
)
|
||||
return outcome.status != ActionStatus.CANCELLED
|
||||
|
||||
async def _enter_update_state(self, node_id: str) -> None:
|
||||
self._state.enter(node_id, WorkflowStatus.RUNNING_ACTION)
|
||||
await self._emit_active(node_id)
|
||||
rendered = self._store.render_data(
|
||||
self._engine.data(node_id).get("assignments") or {}
|
||||
)
|
||||
if not isinstance(rendered, dict):
|
||||
raise DynamicVariableError("Update State 节点赋值必须是对象")
|
||||
changed = self._store.assign_declared_many(rendered)
|
||||
if changed:
|
||||
await self._emit_variables("update_state", node_id, changed)
|
||||
|
||||
async def _enter_handoff(self, node_id: str) -> None:
|
||||
self._state.enter(node_id, WorkflowStatus.HANDOFF)
|
||||
if self._runtime.set_input_enabled:
|
||||
self._runtime.set_input_enabled(False)
|
||||
await self._emit_active(node_id)
|
||||
data = self._engine.data(node_id)
|
||||
message = self._store.render(str(data.get("message") or ""))
|
||||
await self._runtime.queue_frame(
|
||||
OutputTransportMessageUrgentFrame(
|
||||
message={
|
||||
"type": "handoff-requested",
|
||||
"nodeId": node_id,
|
||||
"targetType": data.get("targetType", "human"),
|
||||
"target": data.get("target", ""),
|
||||
"message": message,
|
||||
}
|
||||
)
|
||||
)
|
||||
if message:
|
||||
completion = await self._output.speak(
|
||||
message,
|
||||
source="workflow-handoff-speech",
|
||||
node_id=node_id,
|
||||
)
|
||||
if completion:
|
||||
await completion
|
||||
self._store.values["system__handoff_status"] = "requested"
|
||||
|
||||
async def _enter_end(self, node_id: str) -> None:
|
||||
self._ended = True
|
||||
self._state.enter(node_id, WorkflowStatus.ENDED)
|
||||
self._state.finish()
|
||||
self._handlers = {}
|
||||
await self._runtime.realtime.update_session("会话已经结束。", [])
|
||||
if self._runtime.set_input_enabled:
|
||||
self._runtime.set_input_enabled(False)
|
||||
await self._emit_active(node_id)
|
||||
data = self._engine.data(node_id)
|
||||
message = self._store.render(str(data.get("message") or ""))
|
||||
scope = str(data.get("scope") or "session")
|
||||
if scope == "flow":
|
||||
await self._runtime.queue_frame(
|
||||
OutputTransportMessageUrgentFrame(
|
||||
message={"type": "flow-ended", "nodeId": node_id}
|
||||
)
|
||||
)
|
||||
if message:
|
||||
completion = await self._output.speak(
|
||||
message,
|
||||
source="workflow-end-speech",
|
||||
node_id=node_id,
|
||||
)
|
||||
if completion:
|
||||
await completion
|
||||
return
|
||||
self._runtime.call_end.begin("workflow_completed")
|
||||
if message:
|
||||
self._runtime.call_end.arm_after_speech()
|
||||
completion = await self._output.speak(
|
||||
message,
|
||||
source="workflow-end-speech",
|
||||
node_id=node_id,
|
||||
)
|
||||
if completion:
|
||||
await completion
|
||||
else:
|
||||
await self._runtime.call_end.finish()
|
||||
|
||||
def _is_current(self, node_id: str, transition_id: int) -> bool:
|
||||
return (
|
||||
not self._ended
|
||||
and self._state.current_node_id == node_id
|
||||
and self._state.transition_id == transition_id
|
||||
)
|
||||
|
||||
def _message_completion_policy(self, node_id: str) -> str:
|
||||
return str(
|
||||
self._engine.data(node_id).get("completionPolicy") or "playback"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _action_result_assignments(
|
||||
data: dict[str, Any],
|
||||
) -> dict[str, str] | None:
|
||||
mode = str(data.get("resultAssignmentMode") or "none")
|
||||
if mode == "inherit":
|
||||
return None
|
||||
if mode == "override":
|
||||
value = data.get("resultAssignments")
|
||||
return dict(value) if isinstance(value, dict) else {}
|
||||
return {}
|
||||
|
||||
def _set_last_action(self, outcome) -> None:
|
||||
self._store.values.update(
|
||||
{
|
||||
"system__last_action_status": (
|
||||
"ok" if outcome.status == ActionStatus.SUCCESS else "error"
|
||||
),
|
||||
"system__last_action_invocation_id": outcome.invocation_id,
|
||||
"system__last_action_duration_ms": outcome.duration_ms,
|
||||
"system__last_action_error": (
|
||||
outcome.error.message if outcome.error else ""
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
async def _emit_active(self, node_id: str | None) -> None:
|
||||
await self._output.emit_node_active(node_id)
|
||||
|
||||
async def _emit_variables(
|
||||
self,
|
||||
reason: str,
|
||||
node_id: str | None,
|
||||
changed: list[str] | None = None,
|
||||
) -> None:
|
||||
await self._output.emit_variables(
|
||||
reason=reason,
|
||||
node_id=node_id,
|
||||
changed=changed,
|
||||
)
|
||||
Reference in New Issue
Block a user