Refactor workflow routing and greeting management in Brain classes

- Update WorkflowBrain to handle greeting playback more effectively, ensuring that the initial greeting completes before transitioning to the first node.
- Introduce new methods for managing greeting states and conditions, enhancing the interaction flow for user turns.
- Refactor WorkflowLLMRouter to improve routing logic and ensure proper handling of conditional paths.
- Enhance tests to verify the correct behavior of greeting management and routing under various scenarios, including waiting for audio playback to finish.
- Update frontend components to reflect changes in edge handling and improve user experience in workflow configurations.
This commit is contained in:
Xin Wang
2026-07-17 22:01:42 +08:00
parent 34c0d12d2a
commit 162a3d8bec
15 changed files with 826 additions and 147 deletions

View File

@@ -31,7 +31,7 @@ from services.knowledge import search as search_knowledge
from services.runtime_variables import DynamicVariableStore
from services.tool_executor import ToolExecutionError, ToolExecutor
from services.workflow_engine import WorkflowEngine
from services.workflow_router import STAY_ON_CURRENT_AGENT, WorkflowLLMRouter
from services.workflow_router import STAY_ON_CURRENT_NODE, WorkflowLLMRouter
MAX_AUTOMATIC_HOPS = 50
@@ -71,6 +71,7 @@ class WorkflowBrain(BaseBrain):
self._router = WorkflowLLMRouter(cfg or AssistantConfig(type="workflow"))
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]] = []
@@ -95,6 +96,7 @@ class WorkflowBrain(BaseBrain):
self._tool_by_id = {tool.id: tool for tool in cfg.tools}
self._router = WorkflowLLMRouter(cfg)
self._greeting_context_message = None
self._startup_waiting_for_greeting = False
self._client_ready = False
self._pending_visible_speech_events = []
self._manager = FlowManager(
@@ -115,28 +117,58 @@ class WorkflowBrain(BaseBrain):
self._greeting_context_message = deepcopy(message) if message else None
return message
async def on_connected(self) -> None:
async def on_connected(self, *, greeting_pending: bool = False) -> None:
await self._emit_node_active(self._engine.start_id)
await self._emit_variables(
reason="initialized",
node_id=self._engine.start_id,
)
edge = self._engine.deterministic_edge(
if self._manager is None:
raise RuntimeError("Workflow FlowManager 尚未初始化")
self._startup_waiting_for_greeting = greeting_pending
if greeting_pending:
# Keep the Workflow on Start until the transport confirms that the
# shared greeting has finished. This prevents an initial Agent's
# fixed speech (or generated reply) from racing the greeting.
await self._manager.initialize(
self._passive_node_config(self._engine.start_id)
)
logger.info("工作流等待 Start 开场白播放完毕")
return
node_config = await self._initial_node_config()
await self._manager.initialize(node_config)
logger.info(f"工作流模式启用: 当前节点={self._manager.current_node}")
async def on_greeting_finished(self) -> None:
"""Enter the first node only after Start's greeting reaches playback end."""
if not self._startup_waiting_for_greeting or self._ended:
return
self._startup_waiting_for_greeting = False
manager = self._require_manager()
if manager.current_node != self._engine.start_id:
return
node_config = await self._initial_node_config()
if node_config.get("name") == self._engine.start_id:
return
await manager.set_node_from_config(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,
self._store,
include_default=True,
evaluate_llm=False,
)
if not edge and self._engine.has_outgoing(self._engine.start_id):
raise RuntimeError("Start 初始化后没有命中的表达式边或默认边")
node_config = (
return (
await self._follow_edge(edge)
if edge
else self._passive_node_config(self._engine.start_id)
)
if self._manager is None:
raise RuntimeError("Workflow FlowManager 尚未初始化")
await self._manager.initialize(node_config)
logger.info(f"工作流模式启用: 当前节点={self._manager.current_node}")
async def on_client_ready(self) -> None:
"""Replay state that may have been emitted before WebRTC data was ready."""
@@ -163,26 +195,63 @@ class WorkflowBrain(BaseBrain):
self._store.record("user", content)
async def on_user_turn_end(self, content: str) -> bool:
"""Route a complete user turn before any Agent is allowed to reply."""
"""Route a complete user turn before the active stage may reply."""
if not content or self._ended:
return True
self.record_user_message(content)
manager = self._require_manager()
current = manager.current_node
if not current or self._engine.node_type(current) != "agent":
if not current:
return True
edge = self._engine.deterministic_edge(
current,
edge = await self._select_edge(current)
if edge and manager.current_node == current:
next_config = await self._follow_edge(
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())
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 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._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(current)
llm_edges = [
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
@@ -192,45 +261,51 @@ class WorkflowBrain(BaseBrain):
None,
)
if edge is None and llm_edges:
selected = await self._router_for_node(current).select_edge(
node_name=self._engine.name(current),
node_prompt=self._engine.prompt_for(current, 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 and selected != STAY_ON_CURRENT_AGENT:
edge = next(
(
candidate
for candidate in llm_edges
if self._engine.edge_fn_name(candidate) == selected
),
None,
)
elif selected == STAY_ON_CURRENT_AGENT:
edge = default_edge
elif edge is None and not llm_edges:
edge = default_edge
# 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 edge and manager.current_node == current:
next_config = await self._follow_edge(edge)
await manager.set_node_from_config(next_config)
return True
if not evaluate_llm:
if expression_edge and not llm_edges:
return expression_edge
if all_llm_edges:
return None
return default_edge
# 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._require_runtime().queue_frame(LLMRunFrame())
return True
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,
)
async def on_assistant_text_end(
self,
@@ -481,7 +556,12 @@ class WorkflowBrain(BaseBrain):
cancel_on_interruption=True,
)
async def _follow_edge(self, edge: dict) -> NodeConfig:
async def _follow_edge(
self,
edge: dict,
*,
triggering_user_text: str = "",
) -> NodeConfig:
leading_messages: list[dict[str, str]] = []
speech = self._engine.edge_transition_speech(edge)
if speech:
@@ -498,6 +578,7 @@ class WorkflowBrain(BaseBrain):
return await self._resolve_path(
str(edge.get("target") or ""),
leading_messages=leading_messages,
triggering_user_text=triggering_user_text,
)
async def _resolve_path(
@@ -505,13 +586,23 @@ class WorkflowBrain(BaseBrain):
node_id: str,
*,
leading_messages: list[dict[str, str]] | None = None,
triggering_user_text: str = "",
) -> NodeConfig:
context_messages = list(leading_messages or [])
for _ in range(MAX_AUTOMATIC_HOPS):
node_type = self._engine.node_type(node_id)
if node_type == "agent":
await self._apply_agent_stage(node_id)
return self._agent_config(node_id, context_messages)
agent_messages = context_messages
if (
triggering_user_text
and self._engine.data(node_id).get("contextPolicy") == "fresh"
):
agent_messages = [
{"role": "user", "content": triggering_user_text},
*context_messages,
]
return self._agent_config(node_id, agent_messages)
if node_type == "end":
await self._enter_end(node_id)
return self._passive_node_config(node_id, context_messages)
@@ -525,13 +616,9 @@ class WorkflowBrain(BaseBrain):
raise RuntimeError(f"工作流指向未知节点:{node_id}")
if not self._engine.has_outgoing(node_id):
return self._passive_node_config(node_id, context_messages)
edge = self._engine.deterministic_edge(
node_id,
self._store,
include_default=True,
)
edge = await self._select_edge(node_id)
if not edge:
raise RuntimeError(f"自动节点 {node_id} 没有命中的表达式边或默认边")
return self._passive_node_config(node_id, context_messages)
speech = self._engine.edge_transition_speech(edge)
if speech:
content = self._store.render(speech).strip()