feat(workflow): add edge-tool routing and realtime runtime

This commit is contained in:
Xin Wang
2026-08-04 22:29:04 +08:00
parent 1902ffc240
commit 59588ba88d
25 changed files with 1952 additions and 107 deletions

View File

@@ -101,6 +101,18 @@ class BrainRuntime:
flow_global_functions: list[Any] = field(default_factory=list)
@dataclass(frozen=True)
class RealtimeBrainRuntime:
"""Pipeline-owned capabilities for a speech-to-speech brain session."""
realtime: Any
queue_frame: Callable[[Frame], Awaitable[None]]
call_end: CallEndPort
session_id: str = ""
client_tools: ClientToolPort | None = None
set_input_enabled: Callable[[bool], None] | None = None
class BaseBrain:
"""No-op lifecycle defaults for brains without local orchestration."""
@@ -118,6 +130,16 @@ class BaseBrain:
async def setup(self, cfg: AssistantConfig, runtime: BrainRuntime) -> None:
"""Register tools and initialize per-call orchestration."""
async def setup_realtime(
self,
cfg: AssistantConfig,
runtime: RealtimeBrainRuntime,
) -> None:
"""Initialize optional speech-to-speech orchestration."""
async def on_realtime_user_speech_started(self) -> None:
"""Allow a workflow to move past an interruptible fixed message."""
async def run_preflight(self) -> None:
"""Run deterministic server-side startup work before media starts."""
@@ -211,6 +233,14 @@ class Brain(Protocol):
async def setup(self, cfg: AssistantConfig, runtime: BrainRuntime) -> None: ...
async def setup_realtime(
self,
cfg: AssistantConfig,
runtime: RealtimeBrainRuntime,
) -> None: ...
async def on_realtime_user_speech_started(self) -> None: ...
async def run_preflight(self) -> None: ...
async def on_connected(self, *, greeting_pending: bool = False) -> None: ...

View File

@@ -33,6 +33,7 @@ from services.brains.base import (
BaseBrain,
BrainRuntime,
BrainSpec,
RealtimeBrainRuntime,
SessionVariableUpdate,
)
from services.action_runtime import (
@@ -64,6 +65,7 @@ from services.workflow.agent import WorkflowAgentStage
from services.workflow.models import RouteStatus, WorkflowRuntimeState, WorkflowStatus
from services.workflow.output import WorkflowOutput
from services.workflow.routing import WorkflowEdgeEvaluator
from services.workflow.realtime import WorkflowRealtimeController
from services.workflow_engine import WorkflowEngine
from services.workflow_router import WorkflowLLMRouter
@@ -147,7 +149,7 @@ class ConfiguredFlowManager(FlowManager):
class WorkflowBrain(BaseBrain):
spec = BrainSpec(
type="workflow",
supported_runtime_modes=frozenset({"pipeline"}),
supported_runtime_modes=frozenset({"pipeline", "realtime"}),
owns_context=True,
)
@@ -188,12 +190,15 @@ class WorkflowBrain(BaseBrain):
self._waiting_for_generated_end_speech = False
self._next_message_token = 1
self._pending_message: _MessageContinuation | None = None
self._realtime_controller: WorkflowRealtimeController | None = None
async def greeting(self, _cfg: AssistantConfig) -> str:
"""Workflow opening speech belongs to an explicit Message or Agent."""
return ""
def system_prompt(self, cfg: AssistantConfig) -> str:
if self._realtime_controller is not None:
return self._realtime_controller.system_prompt()
return self._store.render(self._engine.global_prompt())
def build_llm(self, cfg: AssistantConfig, context: LLMContext) -> FrameProcessor:
@@ -234,6 +239,7 @@ class WorkflowBrain(BaseBrain):
self._waiting_for_generated_end_speech = False
self._next_message_token = 1
self._pending_message = None
self._realtime_controller = None
self._manager = ConfiguredFlowManager(
worker=runtime.worker,
llm=runtime.llm,
@@ -243,7 +249,30 @@ class WorkflowBrain(BaseBrain):
)
self._manager.state["variables"] = self._store.values
async def setup_realtime(
self,
cfg: AssistantConfig,
runtime: RealtimeBrainRuntime,
) -> None:
self._cfg = cfg
self._store = DynamicVariableStore.from_config(cfg)
self._realtime_controller = WorkflowRealtimeController(
cfg=cfg,
engine=self._engine,
store=self._store,
runtime=runtime,
)
runtime.realtime.set_tool_dispatcher(
self._realtime_controller.dispatch_tool
)
runtime.realtime.set_speech_started_handler(
self.on_realtime_user_speech_started
)
async def on_connected(self, *, greeting_pending: bool = False) -> None:
if self._realtime_controller is not None:
await self._realtime_controller.start()
return
self._state.enter(self._engine.start_id, WorkflowStatus.STARTING)
await self._emit_node_active(self._engine.start_id)
await self._emit_variables(
@@ -282,6 +311,9 @@ class WorkflowBrain(BaseBrain):
async def on_client_ready(self) -> None:
"""Replay state that may have been emitted before WebRTC data was ready."""
if self._realtime_controller is not None:
await self._realtime_controller.on_client_ready()
return
await self._require_output().mark_client_ready()
current_node = (
str(self._manager.current_node)
@@ -300,24 +332,41 @@ class WorkflowBrain(BaseBrain):
self,
dynamic_variables: dict[str, Any],
) -> SessionVariableUpdate:
if self._realtime_controller is not None:
return await self._realtime_controller.on_session_update(
dynamic_variables
)
if self._ended:
raise ValueError("工作流会话已经结束")
changed = self._store.assign_declared_many(dynamic_variables)
current = self._state.current_node_id
if changed and current and self._engine.node_type(current) == "agent":
await self._refresh_agent_prompt(current)
if changed:
await self._emit_variables(
reason="session_update",
node_id=current or None,
changed=changed,
async with self._turn_lock:
changed = self._store.assign_declared_many(dynamic_variables)
current = self._state.current_node_id
next_config = (
await self._after_variables_changed(
current,
changed,
reason="session_update",
)
if current
else None
)
if changed and not current:
await self._emit_variables(
reason="session_update",
node_id=None,
changed=changed,
)
if next_config:
await self._activate_node_config(next_config)
return SessionVariableUpdate(
changed=changed,
dynamic_variables=self._store.public_values(),
)
def record_user_message(self, content: str) -> None:
if self._realtime_controller is not None:
self._realtime_controller.record_user_message(content)
return
if content and not self._ended:
self._store.record("user", content)
@@ -327,6 +376,8 @@ class WorkflowBrain(BaseBrain):
user_message: dict[str, Any] | None = None,
) -> bool:
"""Route a complete user turn before the active stage may reply."""
if self._realtime_controller is not None:
return await self._realtime_controller.handle_text_input(content)
if not content or self._ended:
return True
async with self._turn_lock:
@@ -369,6 +420,26 @@ class WorkflowBrain(BaseBrain):
self.record_user_message(content)
self._state.begin_user_turn(content)
if self._engine.llm_routing_mode() == "edge_tool":
self._state.status = WorkflowStatus.ROUTING
edge = self._engine.deterministic_edge(
current,
self._store,
include_default=False,
)
if edge and manager.current_node == current:
next_config = await self._follow_edge(
edge,
triggering_user_text=content,
triggering_user_message=user_message,
)
await self._activate_node_config(
next_config,
triggering_user_text=content,
)
return True
return await self._continue_current_node_after_no_transition(current)
self._state.status = WorkflowStatus.ROUTING
decision = await self._edge_evaluator.evaluate(
current,
@@ -487,6 +558,12 @@ class WorkflowBrain(BaseBrain):
node_id: str,
) -> dict | None:
"""Compatibility helper used by automatic-node traversal and tests."""
if self._engine.llm_routing_mode() == "edge_tool":
return self._engine.deterministic_edge(
node_id,
self._store,
include_default=True,
)
decision = await self._edge_evaluator.evaluate(node_id)
if decision.status == RouteStatus.ERROR:
await self._require_output().emit_error(
@@ -507,6 +584,12 @@ class WorkflowBrain(BaseBrain):
content: str,
interrupted: bool,
) -> None:
if self._realtime_controller is not None:
await self._realtime_controller.on_assistant_text_end(
content,
interrupted,
)
return
if content and not interrupted and not self._ended:
self._store.record("agent", content, completed_agent_turn=True)
self._state.consume_user_turn()
@@ -522,6 +605,10 @@ class WorkflowBrain(BaseBrain):
has_text=bool(content.strip()) and not interrupted
)
async def on_realtime_user_speech_started(self) -> None:
if self._realtime_controller is not None:
await self._realtime_controller.on_user_speech_started()
async def _refresh_agent_prompt(self, node_id: str) -> None:
await self._require_agent_stage().refresh_prompt(node_id)
@@ -579,6 +666,9 @@ class WorkflowBrain(BaseBrain):
append_function(self._knowledge_function(node_id))
if stage.vision_enabled and self._require_runtime().vision_function:
append_function(self._require_runtime().vision_function)
if self._engine.llm_routing_mode() == "edge_tool":
for edge in self._engine.edge_tool_edges(node_id):
append_function(self._edge_tool(edge, node_id))
return self._require_agent_stage().node_config(
node_id,
functions=functions,
@@ -688,26 +778,13 @@ class WorkflowBrain(BaseBrain):
}
updated_variables = list(result.get("updated_variables") or [])
if updated_variables:
await self._emit_variables(
reason="tool",
node_id=node_id,
changed=updated_variables,
)
await self._refresh_agent_prompt(node_id)
edge = self._engine.deterministic_edge(
node_id,
self._store,
include_default=False,
)
if edge:
next_config = await self._follow_edge(
edge,
triggering_user_text=(
self._state.pending_user_turn.text
if self._state.pending_user_turn
else ""
),
async with self._turn_lock:
next_config = await self._after_variables_changed(
node_id,
updated_variables,
reason="tool",
)
if next_config:
return result, self._flow_managed_transition_config(
next_config,
triggering_user_text=(
@@ -735,6 +812,80 @@ class WorkflowBrain(BaseBrain):
),
)
def _edge_tool(self, edge: dict, node_id: str) -> FlowsFunctionSchema:
"""Expose one natural-language edge as an Agent-owned transition tool."""
registered_transition_id = self._state.transition_id
async def handler(_args, _flow_manager):
async with self._turn_lock:
if (
self._state.current_node_id != node_id
or self._state.transition_id != registered_transition_id
):
return {
"status": "stale",
"message": "当前 Agent 已经切换,本次跳转不再执行。",
}
triggering_user_text = (
self._state.pending_user_turn.text
if self._state.pending_user_turn
else ""
)
next_config = await self._follow_edge(
edge,
triggering_user_text=triggering_user_text,
)
return (
{
"status": "success",
"targetNodeId": str(edge.get("target") or ""),
},
self._flow_managed_transition_config(
next_config,
triggering_user_text=triggering_user_text,
),
)
return FlowsFunctionSchema(
name=self._engine.edge_fn_name(edge),
description=self._engine.edge_description(edge),
properties={},
required=[],
handler=handler,
cancel_on_interruption=True,
)
async def _after_variables_changed(
self,
node_id: str,
changed: list[str],
*,
reason: str,
) -> NodeConfig | None:
"""Refresh the Agent and take a matching expression without an LLM."""
if not changed:
return None
await self._emit_variables(reason=reason, node_id=node_id, changed=changed)
if self._state.current_node_id != node_id:
return None
if self._engine.node_type(node_id) == "agent":
await self._refresh_agent_prompt(node_id)
edge = self._engine.deterministic_edge(
node_id,
self._store,
include_default=False,
)
if not edge:
return None
return await self._follow_edge(
edge,
triggering_user_text=(
self._state.pending_user_turn.text
if self._state.pending_user_turn
else ""
),
)
def _workflow_system_tool(
self,
tool: RuntimeTool,
@@ -779,18 +930,27 @@ class WorkflowBrain(BaseBrain):
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 {
result = {
"status": "success",
"changed": changed,
"variables": self._store.public_values(),
}
async with self._turn_lock:
next_config = await self._after_variables_changed(
node_id,
changed,
reason="update_state",
)
if next_config:
return result, self._flow_managed_transition_config(
next_config,
triggering_user_text=(
self._state.pending_user_turn.text
if self._state.pending_user_turn
else ""
),
)
return result
return FlowsFunctionSchema(
name=tool.function_name,