"""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.call_lifecycle import playback_marker_for 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.vision import ( VISION_SYSTEM_HINT, VISION_TOOL_NAME, analyze_image_with_vision_model, config_with_vision_resource, ) 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 AGENT_ENTRY_TRIGGER = ( "[工作流内部事件,不是用户消息] 当前 Agent 节点已激活。" "请严格依据当前 instructions 立即生成进入该节点时的回复," "不要提及或复述本事件。" ) 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 {}), } ) track_speech = getattr(self._runtime.call_end, "track_speech", None) playback_completion: Awaitable[None] | None = None if callable(track_speech): playback_completion = track_speech() provider_completion = await self._realtime.speak_fixed( content, suppress_transcript=True, ) playback_marker = playback_marker_for(playback_completion) if playback_marker is not None: async def finish_playback_tracking() -> None: # The provider boundary is emitted after its final audio frame. # Queueing the marker then places it behind that audio at output. if provider_completion is not None: await provider_completion await self._runtime.queue_frame(playback_marker) playback_marker.completion.mark_queued() await playback_completion return asyncio.create_task(finish_playback_tracking()) # Message playback policy and deterministic continuation must use the # transport boundary. Provider response.done only means generation # has finished; audio may still be buffered at the output transport. return playback_completion or provider_completion 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._request_agent_entry_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._request_agent_entry_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._request_agent_entry_response() async def _request_agent_entry_response(self) -> None: """Give provider inference a temporary turn for Agent entry speech.""" await self._runtime.realtime.request_response( trigger_text=AGENT_ENTRY_TRIGGER, ) 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: prompt = ( f"{self._engine.prompt_for(node_id, self._store)}\n\n" f"[工作流执行规则]\n{EDGE_TOOL_STAGE_INSTRUCTION}" ) if self._engine.agent_stage_config(node_id).vision_enabled: prompt = f"{prompt}\n{VISION_SYSTEM_HINT}" return prompt 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) vision = self._vision_tool(node_id, transition_id) if vision: add(*vision) 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 _vision_tool( self, node_id: str, transition_id: int, ) -> tuple[RealtimeTool, ToolHandler] | None: """Build the on-demand camera tool for the active Realtime Agent.""" stage = self._engine.agent_stage_config(node_id) if not stage.vision_enabled: return None vision_cfg: AssistantConfig | None = None if stage.vision_model_resource_id: resource = self._cfg.workflow_model_resources.get( stage.vision_model_resource_id ) if resource is not None: vision_cfg = config_with_vision_resource(self._cfg, resource) 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, ) question = str(arguments.get("question") or "").strip() if not question: return RealtimeToolResult( {"status": "error", "message": "视觉问题为空"} ) if self._runtime.capture_image is None: return RealtimeToolResult( {"status": "no_video", "message": "当前会话未启用视频输入"} ) try: frame = await self._runtime.capture_image(question) except TimeoutError: return RealtimeToolResult( {"status": "timeout", "message": "等待摄像头画面超时"} ) except Exception as exc: # noqa: BLE001 - expose a stable tool error logger.warning(f"Realtime Workflow 获取摄像头画面失败:{exc}") return RealtimeToolResult( {"status": "error", "message": "暂时无法获取摄像头画面"} ) if frame is None: return RealtimeToolResult( {"status": "no_video", "message": "当前没有可用的视频客户端"} ) if not self._is_current(node_id, transition_id): return RealtimeToolResult( {"status": "stale", "message": "获取画面时 Agent 已经切换。"}, continue_response=False, ) try: if vision_cfg is not None: observation = await analyze_image_with_vision_model( vision_cfg, frame, question, ) else: analyze = getattr(self._runtime.realtime, "analyze_image", None) if not callable(analyze): raise ValueError("当前 Realtime 适配器未实现原生视觉理解") observation = await analyze(frame, question) except Exception as exc: # noqa: BLE001 - provider errors become tool output logger.warning(f"Realtime Workflow 视觉模型调用失败:{exc}") return RealtimeToolResult( {"status": "error", "message": "视觉模型暂时不可用"} ) if not self._is_current(node_id, transition_id): return RealtimeToolResult( {"status": "stale", "message": "分析画面时 Agent 已经切换。"}, continue_response=False, ) return RealtimeToolResult( { "status": "ok", "question": question, "observation": observation, } ) return ( RealtimeTool( name=VISION_TOOL_NAME, description="获取用户摄像头的当前画面,并回答一个视觉问题。", properties={ "question": { "type": "string", "description": "需要根据当前画面判断的具体问题", } }, required=("question",), ), 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: completion = await self._output.speak( message, source="workflow-end-speech", node_id=node_id, ) arm_tracked = getattr( self._runtime.call_end, "arm_after_tracked_speech", None, ) if callable(arm_tracked): await arm_tracked() else: self._runtime.call_end.arm_after_speech() 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, )