Refactor workflow agent and routing components for improved functionality

- Introduce WorkflowAgentStage to manage agent stage configurations and enhance interaction with the workflow engine.
- Implement WorkflowEdgeEvaluator for priority-aware edge evaluation, improving routing decisions based on conditions and user turns.
- Update WorkflowBrain to handle user turns and routing more effectively, ensuring agents cannot have only one default path.
- Enhance CallEndCoordinator to track speech events and manage call termination based on queued speech.
- Add new models and output handling for workflow interactions, improving clarity and maintainability.
- Update tests to validate the new routing logic and agent behavior under various scenarios.
This commit is contained in:
Xin Wang
2026-07-17 22:37:15 +08:00
parent 162a3d8bec
commit bdf3d3dd9c
23 changed files with 1374 additions and 475 deletions

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
from copy import deepcopy
from typing import Any
@@ -19,26 +20,26 @@ from pipecat.frames.frames import (
LLMRunFrame,
LLMUpdateSettingsFrame,
OutputTransportMessageUrgentFrame,
TTSSpeakFrame,
)
from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.processors.frame_processor import FrameProcessor
from pipecat.services.settings import LLMSettings
from pipecat.utils.time import time_now_iso8601
from services.brains.base import BaseBrain, BrainRuntime, BrainSpec
from services.knowledge import search as search_knowledge
from services.runtime_variables import DynamicVariableStore
from services.tool_executor import ToolExecutionError, ToolExecutor
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_engine import WorkflowEngine
from services.workflow_router import STAY_ON_CURRENT_NODE, WorkflowLLMRouter
from services.workflow_router import WorkflowLLMRouter
MAX_AUTOMATIC_HOPS = 50
AGENT_STAGE_INSTRUCTION = (
"工作流路由已在用户一轮输入结束时完成。只执行当前阶段任务,"
"不要自行解释、模拟或宣布节点切换。"
)
class WorkflowBrain(BaseBrain):
@@ -69,11 +70,18 @@ class WorkflowBrain(BaseBrain):
self._runtime: BrainRuntime | None = None
self._manager: FlowManager | None = None
self._router = WorkflowLLMRouter(cfg or AssistantConfig(type="workflow"))
self._edge_evaluator = WorkflowEdgeEvaluator(
self._engine,
self._store,
self._router_for_node,
)
self._state = WorkflowRuntimeState(current_node_id=self._engine.start_id)
self._turn_lock = asyncio.Lock()
self._output: WorkflowOutput | None = None
self._agent_stage: WorkflowAgentStage | None = None
self._ended = False
self._greeting_context_message: dict[str, str] | None = None
self._startup_waiting_for_greeting = False
self._client_ready = False
self._pending_visible_speech_events: list[dict[str, Any]] = []
async def greeting(self, cfg: AssistantConfig) -> str:
return self._engine.greeting(self._store) or cfg.greeting
@@ -95,10 +103,23 @@ class WorkflowBrain(BaseBrain):
self._tools = ToolExecutor(self._store)
self._tool_by_id = {tool.id: tool for tool in cfg.tools}
self._router = WorkflowLLMRouter(cfg)
self._edge_evaluator = WorkflowEdgeEvaluator(
self._engine,
self._store,
self._router_for_node,
)
self._state = WorkflowRuntimeState(current_node_id=self._engine.start_id)
self._turn_lock = asyncio.Lock()
self._output = WorkflowOutput(self._store, runtime)
self._agent_stage = WorkflowAgentStage(
cfg=cfg,
engine=self._engine,
store=self._store,
runtime=runtime,
)
self._ended = False
self._greeting_context_message = None
self._startup_waiting_for_greeting = False
self._client_ready = False
self._pending_visible_speech_events = []
self._manager = FlowManager(
worker=runtime.worker,
llm=runtime.llm,
@@ -118,6 +139,7 @@ class WorkflowBrain(BaseBrain):
return message
async def on_connected(self, *, greeting_pending: bool = False) -> None:
self._state.enter(self._engine.start_id, WorkflowStatus.STARTING)
await self._emit_node_active(self._engine.start_id)
await self._emit_variables(
reason="initialized",
@@ -139,6 +161,7 @@ class WorkflowBrain(BaseBrain):
node_config = await self._initial_node_config()
await self._manager.initialize(node_config)
await self._after_node_activated(node_config)
logger.info(f"工作流模式启用: 当前节点={self._manager.current_node}")
async def on_greeting_finished(self) -> None:
@@ -152,17 +175,28 @@ class WorkflowBrain(BaseBrain):
node_config = await self._initial_node_config()
if node_config.get("name") == self._engine.start_id:
self._state.enter(self._engine.start_id, WorkflowStatus.WAITING_USER)
return
await manager.set_node_from_config(node_config)
await self._after_node_activated(node_config)
logger.info(f"Start 开场白结束,进入节点: {manager.current_node}")
async def _initial_node_config(self) -> NodeConfig:
"""Resolve the immediate path from Start without evaluating LLM edges."""
# Start LLM conditions need an actual user turn. Expression-only and
# default-only starts may still advance immediately at connection time.
edge = await self._select_edge(
self._engine.start_id,
evaluate_llm=False,
"""Only a default-only Start advances before the first user turn."""
outgoing = self._engine.outgoing(self._engine.start_id)
has_condition = any(
self._engine.edge_mode(edge) != "always" for edge in outgoing
)
if has_condition:
self._state.enter(self._engine.start_id, WorkflowStatus.WAITING_USER)
return self._passive_node_config(self._engine.start_id)
edge = next(
(
candidate
for candidate in outgoing
if self._engine.edge_mode(candidate) == "always"
),
None,
)
return (
await self._follow_edge(edge)
@@ -172,18 +206,14 @@ class WorkflowBrain(BaseBrain):
async def on_client_ready(self) -> None:
"""Replay state that may have been emitted before WebRTC data was ready."""
self._client_ready = True
pending_speech_events = self._pending_visible_speech_events
self._pending_visible_speech_events = []
for message in pending_speech_events:
await self._require_runtime().queue_frame(
OutputTransportMessageUrgentFrame(message=message)
)
await self._require_output().mark_client_ready()
current_node = (
str(self._manager.current_node)
if self._manager and self._manager.current_node
else self._engine.start_id
else self._state.current_node_id
)
if current_node != self._state.current_node_id:
self._state.current_node_id = current_node
await self._emit_node_active(current_node)
await self._emit_variables(
reason="client_ready",
@@ -198,114 +228,69 @@ class WorkflowBrain(BaseBrain):
"""Route a complete user turn before the active stage may reply."""
if not content or self._ended:
return True
async with self._turn_lock:
return await self._handle_user_turn_end(content)
async def _handle_user_turn_end(self, content: str) -> bool:
"""Serialized implementation so one user turn cannot transition twice."""
self.record_user_message(content)
self._state.begin_user_turn(content)
manager = self._require_manager()
current = manager.current_node
current = self._state.current_node_id
if not current:
return True
edge = await self._select_edge(current)
self._state.status = WorkflowStatus.ROUTING
decision = await self._edge_evaluator.evaluate(current)
if decision.status == RouteStatus.ERROR:
await self._require_output().emit_error(
decision.error or "工作流路由失败",
node_id=current,
code="workflow_routing_error",
)
return await self._continue_current_node_after_no_transition(current)
if edge and manager.current_node == current:
if decision.edge and manager.current_node == current:
next_config = await self._follow_edge(
edge,
decision.edge,
triggering_user_text=content,
)
await manager.set_node_from_config(next_config)
next_node = str(next_config.get("name") or "")
if (
self._engine.node_type(next_node) == "agent"
and self._engine.data(next_node).get("entryMode", "wait_user")
== "wait_user"
):
await self._require_runtime().queue_frame(LLMRunFrame())
await self._after_node_activated(
next_config,
triggering_user_text=content,
)
return True
if self._engine.node_type(current) != "agent":
# Start/Action/Handoff have no conversational LLM of their own.
# Keep waiting so a later user turn may satisfy another condition.
return await self._continue_current_node_after_no_transition(current)
async def _continue_current_node_after_no_transition(
self,
node_id: str,
) -> bool:
if self._engine.node_type(node_id) != "agent":
self._state.enter(node_id, WorkflowStatus.WAITING_USER)
return True
# The incoming LLMContextFrame is intentionally suppressed by the
# pipeline router. Queue prompt refresh + inference in this order so
# this user turn is answered with the current Agent's latest variables.
await self._refresh_agent_prompt(current)
await self._refresh_agent_prompt(node_id)
self._state.enter(node_id, WorkflowStatus.RUNNING_AGENT)
await self._require_runtime().queue_frame(LLMRunFrame())
return True
async def _select_edge(
self,
node_id: str,
*,
evaluate_llm: bool = True,
) -> dict | None:
"""Resolve conditional paths by priority, then use the default path."""
expression_edge = self._engine.deterministic_edge(
node_id,
self._store,
include_default=False,
)
outgoing = self._engine.outgoing(node_id)
all_llm_edges = [
candidate
for candidate in outgoing
if self._engine.edge_mode(candidate) == "llm"
]
llm_edges = all_llm_edges
default_edge = next(
(
candidate
for candidate in outgoing
if self._engine.edge_mode(candidate) == "always"
),
None,
)
# A matching expression is a deterministic priority boundary. Only LLM
# conditions before it may win; later conditions must not bypass it.
if expression_edge:
expression_index = outgoing.index(expression_edge)
llm_edges = [
candidate
for candidate in llm_edges
if outgoing.index(candidate) < expression_index
]
if not evaluate_llm:
if expression_edge and not llm_edges:
return expression_edge
if all_llm_edges:
return None
return default_edge
if not llm_edges:
return expression_edge or default_edge
selected = await self._router_for_node(node_id).select_edge(
node_name=self._engine.name(node_id),
node_prompt=self._engine.routing_prompt(node_id, self._store),
edges=llm_edges,
history=self._store.history,
variables={
key: value
for key, value in self._store.values.items()
if not key.startswith("system__")
},
edge_name=self._engine.edge_fn_name,
edge_description=self._engine.edge_description,
)
if selected == STAY_ON_CURRENT_NODE:
return expression_edge or default_edge
if not selected:
return expression_edge
return next(
(
candidate
for candidate in llm_edges
if self._engine.edge_fn_name(candidate) == selected
),
None,
)
"""Compatibility helper used by automatic-node traversal and tests."""
decision = await self._edge_evaluator.evaluate(node_id)
if decision.status == RouteStatus.ERROR:
await self._require_output().emit_error(
decision.error or "工作流路由失败",
node_id=node_id,
code="workflow_routing_error",
)
return None
return decision.edge
async def on_assistant_text_end(
self,
@@ -316,88 +301,29 @@ class WorkflowBrain(BaseBrain):
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
async def _refresh_agent_prompt(self, node_id: str) -> None:
runtime = self._require_runtime()
await runtime.queue_frame(
LLMUpdateSettingsFrame(
delta=LLMSettings(
system_instruction=self._agent_role_message(node_id)
)
)
)
await self._require_agent_stage().refresh_prompt(node_id)
def _agent_role_message(self, node_id: str) -> str:
"""Build one provider-compatible system instruction for an Agent stage."""
stage_prompt = self._engine.prompt_for(node_id, self._store)
return f"{stage_prompt}\n\n[工作流执行规则]\n{AGENT_STAGE_INSTRUCTION}"
return self._require_agent_stage().role_message(node_id)
def _router_for_node(self, node_id: str) -> WorkflowLLMRouter:
stage = self._engine.agent_stage_config(node_id)
resource_id = stage.llm_resource_id
cfg = self._cfg
resource = cfg.workflow_model_resources.get(resource_id) if cfg else None
if not cfg or not resource:
if self._agent_stage is None:
return self._router
from services.pipecat.service_factory import config_with_resource
return WorkflowLLMRouter(config_with_resource(cfg, resource))
return self._agent_stage.router_for_node(node_id, self._router)
async def _apply_agent_stage(self, node_id: str) -> None:
stage = self._engine.agent_stage_config(node_id)
await self._emit_node_active(node_id)
if self._runtime and self._runtime.set_input_enabled:
self._runtime.set_input_enabled(True)
runtime = self._require_runtime()
if runtime.apply_turn_config:
await runtime.apply_turn_config(
stage.enable_interrupt,
stage.turn_config,
)
if runtime.switch_services:
await runtime.switch_services(
stage.llm_resource_id or None,
stage.asr_resource_id or None,
stage.tts_resource_id or None,
)
if runtime.set_knowledge_scope:
runtime.set_knowledge_scope(
{
"knowledge_base_id": stage.knowledge_base_id,
"mode": stage.knowledge_mode,
"top_n": stage.knowledge_top_n,
"score_threshold": stage.knowledge_score_threshold,
}
)
await self._require_agent_stage().apply(node_id)
def _agent_config(
self,
node_id: str,
leading_messages: list[dict[str, str]] | None = None,
) -> NodeConfig:
data = self._engine.data(node_id)
entry_mode = str(data.get("entryMode") or "wait_user")
entry_speech = self._store.render(str(data.get("entrySpeech") or ""))
fixed_reply_messages: list[dict[str, str]] = (
[{"role": "assistant", "content": entry_speech}]
if entry_mode == "fixed_speech" and entry_speech
else []
)
strategy = (
ContextStrategy.RESET
if data.get("contextPolicy") == "fresh"
else ContextStrategy.APPEND
)
greeting_messages = (
[deepcopy(self._greeting_context_message)]
if strategy == ContextStrategy.RESET and self._greeting_context_message
else []
)
task_messages = [
*greeting_messages,
*(leading_messages or []),
*fixed_reply_messages,
]
stage = self._engine.agent_stage_config(node_id)
functions: list[FlowsFunctionSchema] = []
for tool_id in stage.tool_ids:
@@ -407,36 +333,49 @@ class WorkflowBrain(BaseBrain):
knowledge_function = self._knowledge_function(node_id)
if knowledge_function:
functions.append(knowledge_function)
config: NodeConfig = {
"name": node_id,
"role_message": self._agent_role_message(node_id),
# Flows writes task_messages into the Pipecat LLM context. The
# pre-action below is responsible only for display, persistence,
# dynamic conversation history, and TTS playback.
"task_messages": task_messages,
"functions": functions,
"context_strategy": ContextStrategyConfig(strategy=strategy),
"respond_immediately": entry_mode == "generate",
}
if entry_mode == "fixed_speech":
config["pre_actions"] = [
{
"type": "workflow_fixed_speech",
"text": entry_speech,
"node_id": node_id,
"handler": self._play_fixed_speech,
}
]
return config
async def _play_fixed_speech(self, action: dict, _flow_manager: FlowManager) -> None:
"""Play and persist Agent entry speech without creating an LLM turn."""
await self._queue_visible_speech(
str(action.get("text") or ""),
source="workflow-fixed-reply",
node_id=str(action.get("node_id") or "") or None,
return self._require_agent_stage().node_config(
node_id,
functions=functions,
greeting_context_message=self._greeting_context_message,
leading_messages=leading_messages,
)
async def _after_node_activated(
self,
node_config: NodeConfig,
*,
triggering_user_text: str = "",
) -> None:
"""Publish activation and perform exactly one Agent entry behavior."""
node_id = str(node_config.get("name") or "")
node_type = self._engine.node_type(node_id)
if node_type != "agent":
if node_type == "start":
self._state.enter(node_id, WorkflowStatus.WAITING_USER)
return
await self._emit_node_active(node_id)
data = self._engine.data(node_id)
entry_mode = str(data.get("entryMode") or "wait_user")
if entry_mode == "fixed_speech":
entry_speech = self._store.render(str(data.get("entrySpeech") or ""))
await self._queue_visible_speech(
entry_speech,
source="workflow-fixed-reply",
node_id=node_id,
)
self._state.enter(node_id, WorkflowStatus.WAITING_USER)
self._state.consume_user_turn()
return
should_run = entry_mode == "generate" or bool(triggering_user_text)
if should_run:
self._state.enter(node_id, WorkflowStatus.RUNNING_AGENT)
await self._require_runtime().queue_frame(LLMRunFrame())
return
self._state.enter(node_id, WorkflowStatus.WAITING_USER)
async def _queue_visible_speech(
self,
text: str,
@@ -444,27 +383,11 @@ class WorkflowBrain(BaseBrain):
source: str = "workflow-speech",
node_id: str | None = None,
) -> None:
"""Show and persist fixed workflow speech before sending it to TTS."""
content = text.strip()
if not content:
return
self._store.record("agent", content)
runtime = self._require_runtime()
transcript_message = {
"type": "transcript",
"role": "assistant",
"content": content,
"timestamp": time_now_iso8601(),
"source": source,
**({"nodeId": node_id} if node_id else {}),
}
if self._client_ready:
await runtime.queue_frame(
OutputTransportMessageUrgentFrame(message=transcript_message)
)
else:
self._pending_visible_speech_events.append(transcript_message)
await runtime.queue_frame(TTSSpeakFrame(content, append_to_context=False))
await self._require_output().speak(
text,
source=source,
node_id=node_id,
)
def _passive_node_config(
self,
@@ -486,10 +409,19 @@ class WorkflowBrain(BaseBrain):
self._tools.register_secrets(tool)
async def handler(args, _flow_manager):
transition_id = self._state.transition_id
try:
result = await self._tools.execute(tool, dict(args or {}))
except ToolExecutionError as exc:
return {"status": "error", "message": str(exc)}
if (
self._state.current_node_id != node_id
or self._state.transition_id != transition_id
):
return {
"status": "stale",
"message": "工具完成时当前 Agent 已经切换,结果不再触发路由。",
}
updated_variables = list(result.get("updated_variables") or [])
if updated_variables:
await self._emit_variables(
@@ -504,7 +436,22 @@ class WorkflowBrain(BaseBrain):
include_default=False,
)
if edge:
return result, await self._follow_edge(edge)
next_config = await self._follow_edge(
edge,
triggering_user_text=(
self._state.pending_user_turn.text
if self._state.pending_user_turn
else ""
),
)
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(
@@ -516,6 +463,65 @@ class WorkflowBrain(BaseBrain):
cancel_on_interruption=True,
)
def _flow_managed_transition_config(
self,
node_config: NodeConfig,
*,
triggering_user_text: str,
) -> NodeConfig:
"""Let FlowManager finish a tool result before activating its target.
Function-returned transitions are the one place where FlowManager must
schedule the LLM run. Its pre-action only updates our explicit runtime
state; it never performs a second LLM run.
"""
node_id = str(node_config.get("name") or "")
if self._engine.node_type(node_id) != "agent":
return node_config
entry_mode = str(
self._engine.data(node_id).get("entryMode") or "wait_user"
)
should_run = entry_mode == "generate" or bool(triggering_user_text)
configured = dict(node_config)
configured["respond_immediately"] = (
should_run and entry_mode != "fixed_speech"
)
configured["pre_actions"] = [
{
"type": "workflow_function_transition_entry",
"node_id": node_id,
"entry_mode": entry_mode,
"should_run": should_run,
"handler": self._activate_from_flow_transition,
}
]
return configured
async def _activate_from_flow_transition(
self,
action: dict,
_flow_manager: FlowManager,
) -> None:
"""Apply visible entry state without manually queueing an LLM run."""
node_id = str(action.get("node_id") or "")
await self._emit_node_active(node_id)
entry_mode = str(action.get("entry_mode") or "wait_user")
if entry_mode == "fixed_speech":
entry_speech = self._store.render(
str(self._engine.data(node_id).get("entrySpeech") or "")
)
await self._queue_visible_speech(
entry_speech,
source="workflow-fixed-reply",
node_id=node_id,
)
self._state.enter(node_id, WorkflowStatus.WAITING_USER)
self._state.consume_user_turn()
elif action.get("should_run"):
self._state.enter(node_id, WorkflowStatus.RUNNING_AGENT)
else:
self._state.enter(node_id, WorkflowStatus.WAITING_USER)
def _knowledge_function(self, node_id: str) -> FlowsFunctionSchema | None:
stage = self._engine.agent_stage_config(node_id)
knowledge_id = str(stage.knowledge_base_id or "")
@@ -562,6 +568,7 @@ class WorkflowBrain(BaseBrain):
*,
triggering_user_text: str = "",
) -> NodeConfig:
self._state.begin_transition()
leading_messages: list[dict[str, str]] = []
speech = self._engine.edge_transition_speech(edge)
if speech:
@@ -589,7 +596,8 @@ class WorkflowBrain(BaseBrain):
triggering_user_text: str = "",
) -> NodeConfig:
context_messages = list(leading_messages or [])
for _ in range(MAX_AUTOMATIC_HOPS):
for hop in range(MAX_AUTOMATIC_HOPS):
self._state.automatic_hops = hop
node_type = self._engine.node_type(node_id)
if node_type == "agent":
await self._apply_agent_stage(node_id)
@@ -611,6 +619,7 @@ class WorkflowBrain(BaseBrain):
elif node_type == "handoff":
await self._enter_handoff(node_id)
elif node_type == "start":
self._state.enter(node_id, WorkflowStatus.WAITING_USER)
await self._emit_node_active(node_id)
else:
raise RuntimeError(f"工作流指向未知节点:{node_id}")
@@ -619,6 +628,7 @@ class WorkflowBrain(BaseBrain):
edge = await self._select_edge(node_id)
if not edge:
return self._passive_node_config(node_id, context_messages)
self._state.begin_transition()
speech = self._engine.edge_transition_speech(edge)
if speech:
content = self._store.render(speech).strip()
@@ -636,6 +646,7 @@ class WorkflowBrain(BaseBrain):
raise RuntimeError("工作流连续自动跳转超过安全上限")
async def _enter_action(self, node_id: str) -> None:
self._state.enter(node_id, WorkflowStatus.RUNNING_ACTION)
await self._emit_node_active(node_id)
data = self._engine.data(node_id)
tool_id = str(data.get("toolId") or "")
@@ -665,6 +676,7 @@ class WorkflowBrain(BaseBrain):
self._store.values["system__last_action_error"] = str(exc)[:2048]
async def _enter_handoff(self, node_id: str) -> None:
self._state.enter(node_id, WorkflowStatus.HANDOFF)
await self._emit_node_active(node_id)
data = self._engine.data(node_id)
message = self._store.render(str(data.get("message") or ""))
@@ -685,6 +697,8 @@ class WorkflowBrain(BaseBrain):
async def _enter_end(self, node_id: str) -> None:
self._ended = True
self._state.enter(node_id, WorkflowStatus.ENDED)
self._state.finish()
await self._emit_node_active(node_id)
runtime = self._require_runtime()
if runtime.set_knowledge_scope:
@@ -705,27 +719,17 @@ class WorkflowBrain(BaseBrain):
return
runtime.call_end.begin("workflow_completed")
if message:
runtime.call_end.arm_after_speech()
await self._queue_visible_speech(message)
arm_tracked = getattr(runtime.call_end, "arm_after_tracked_speech", None)
if callable(arm_tracked):
await arm_tracked()
elif message:
runtime.call_end.arm_after_speech()
else:
await runtime.call_end.finish()
async def _emit_node_active(self, node_id: str | None) -> None:
if node_id:
await self._require_runtime().queue_frame(
OutputTransportMessageUrgentFrame(
message={"type": "node-active", "nodeId": node_id}
)
)
def _public_variables(self) -> dict[str, str | int | float | bool]:
"""Return the browser-safe part of this session's variable state."""
return {
name: value
for name, value in self._store.values.items()
if not name.startswith(("system__", "secret__"))
and isinstance(value, (str, int, float, bool))
}
await self._require_output().emit_node_active(node_id)
async def _emit_variables(
self,
@@ -735,21 +739,10 @@ class WorkflowBrain(BaseBrain):
changed: list[str] | None = None,
) -> None:
"""Publish a safe snapshot so Workflow debug mirrors runtime state."""
message: dict[str, Any] = {
"type": "workflow-variables",
"reason": reason,
"variables": self._public_variables(),
}
if node_id:
message["nodeId"] = node_id
if changed:
message["changed"] = [
name
for name in changed
if not name.startswith(("system__", "secret__"))
]
await self._require_runtime().queue_frame(
OutputTransportMessageUrgentFrame(message=message)
await self._require_output().emit_variables(
reason=reason,
node_id=node_id,
changed=changed,
)
def _require_runtime(self) -> BrainRuntime:
@@ -761,3 +754,20 @@ class WorkflowBrain(BaseBrain):
if self._manager is None:
raise RuntimeError("Workflow FlowManager 尚未初始化")
return self._manager
def _require_output(self) -> WorkflowOutput:
if self._output is None:
# A few focused unit tests bind BrainRuntime directly. Lazily create
# the output adapter so those tests exercise the same production path.
self._output = WorkflowOutput(self._store, self._require_runtime())
return self._output
def _require_agent_stage(self) -> WorkflowAgentStage:
if self._agent_stage is None:
self._agent_stage = WorkflowAgentStage(
cfg=self._cfg or AssistantConfig(type="workflow"),
engine=self._engine,
store=self._store,
runtime=self._require_runtime(),
)
return self._agent_stage