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

@@ -0,0 +1,19 @@
"""Small, explicit building blocks for the local Workflow runtime."""
from services.workflow.models import (
EdgeEvaluation,
LLMRouteResult,
RouteStatus,
UserTurn,
WorkflowRuntimeState,
WorkflowStatus,
)
__all__ = [
"EdgeEvaluation",
"LLMRouteResult",
"RouteStatus",
"UserTurn",
"WorkflowRuntimeState",
"WorkflowStatus",
]

View File

@@ -0,0 +1,134 @@
"""Resolve and apply one Workflow Agent's complete stage configuration."""
from __future__ import annotations
from copy import deepcopy
from models import AssistantConfig
from pipecat.flows import ContextStrategy, ContextStrategyConfig, NodeConfig
from pipecat.frames.frames import LLMUpdateSettingsFrame
from pipecat.services.settings import LLMSettings
from services.brains.base import BrainRuntime
from services.runtime_variables import DynamicVariableStore
from services.workflow_engine import WorkflowEngine
from services.workflow_router import WorkflowLLMRouter
AGENT_STAGE_INSTRUCTION = (
"工作流路由已在用户一轮输入结束时完成。只执行当前阶段任务,"
"不要自行解释、模拟或宣布节点切换。"
)
class WorkflowAgentStage:
"""Translate graph-level Agent data into Pipecat Flows configuration."""
def __init__(
self,
*,
cfg: AssistantConfig,
engine: WorkflowEngine,
store: DynamicVariableStore,
runtime: BrainRuntime,
) -> None:
self._cfg = cfg
self._engine = engine
self._store = store
self._runtime = runtime
def role_message(self, node_id: str) -> str:
stage_prompt = self._engine.prompt_for(node_id, self._store)
return (
f"{stage_prompt}\n\n[工作流执行规则]\n"
f"{AGENT_STAGE_INSTRUCTION}"
)
async def refresh_prompt(self, node_id: str) -> None:
await self._runtime.queue_frame(
LLMUpdateSettingsFrame(
delta=LLMSettings(
system_instruction=self.role_message(node_id)
)
)
)
def router_for_node(
self,
node_id: str,
default_router: WorkflowLLMRouter,
) -> WorkflowLLMRouter:
resource_id = self._engine.agent_stage_config(node_id).llm_resource_id
resource = self._cfg.workflow_model_resources.get(resource_id)
if not resource:
return default_router
from services.pipecat.service_factory import config_with_resource
return WorkflowLLMRouter(config_with_resource(self._cfg, resource))
async def apply(self, node_id: str) -> None:
stage = self._engine.agent_stage_config(node_id)
if self._runtime.set_input_enabled:
self._runtime.set_input_enabled(True)
if self._runtime.apply_turn_config:
await self._runtime.apply_turn_config(
stage.enable_interrupt,
stage.turn_config,
)
if self._runtime.switch_services:
await self._runtime.switch_services(
stage.llm_resource_id or None,
stage.asr_resource_id or None,
stage.tts_resource_id or None,
)
if self._runtime.set_knowledge_scope:
self._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,
}
)
def node_config(
self,
node_id: str,
*,
functions: list,
greeting_context_message: dict[str, str] | None,
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 ""))
strategy = (
ContextStrategy.RESET
if data.get("contextPolicy") == "fresh"
else ContextStrategy.APPEND
)
greeting_messages = (
[deepcopy(greeting_context_message)]
if strategy == ContextStrategy.RESET and greeting_context_message
else []
)
fixed_reply_messages = (
[{"role": "assistant", "content": entry_speech}]
if entry_mode == "fixed_speech" and entry_speech
else []
)
return {
"name": node_id,
"role_message": self.role_message(node_id),
"task_messages": [
*greeting_messages,
*(leading_messages or []),
*fixed_reply_messages,
],
"functions": functions,
"context_strategy": ContextStrategyConfig(strategy=strategy),
# Direct node activations let WorkflowRuntime decide whether to run
# the LLM. A Pipecat function transition may explicitly override
# this because FlowManager must coordinate the tool result first.
"respond_immediately": False,
}

View File

@@ -0,0 +1,92 @@
"""Readable runtime values shared by Workflow orchestration modules."""
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
from typing import Any
class WorkflowStatus(StrEnum):
"""The small set of states useful to operators and future debug tooling."""
STARTING = "starting"
WAITING_USER = "waiting_user"
ROUTING = "routing"
RUNNING_AGENT = "running_agent"
RUNNING_ACTION = "running_action"
HANDOFF = "handoff"
ENDED = "ended"
class RouteStatus(StrEnum):
"""A routing error is deliberately different from a valid no-match."""
MATCHED = "matched"
NO_MATCH = "no_match"
ERROR = "error"
@dataclass(frozen=True)
class UserTurn:
"""One committed user turn that may cross automatic Workflow nodes."""
id: int
text: str
@dataclass
class WorkflowRuntimeState:
"""Mutable per-call state; graph definitions remain immutable."""
current_node_id: str
status: WorkflowStatus = WorkflowStatus.STARTING
pending_user_turn: UserTurn | None = None
transition_id: int = 0
automatic_hops: int = 0
ended: bool = False
_next_turn_id: int = 1
def begin_user_turn(self, text: str) -> UserTurn:
turn = UserTurn(id=self._next_turn_id, text=text)
self._next_turn_id += 1
self.pending_user_turn = turn
self.automatic_hops = 0
return turn
def enter(self, node_id: str, status: WorkflowStatus) -> None:
self.current_node_id = node_id
self.status = status
def begin_transition(self) -> int:
self.transition_id += 1
return self.transition_id
def consume_user_turn(self) -> UserTurn | None:
turn = self.pending_user_turn
self.pending_user_turn = None
return turn
def finish(self) -> None:
self.ended = True
self.status = WorkflowStatus.ENDED
self.pending_user_turn = None
@dataclass(frozen=True)
class LLMRouteResult:
"""Control-plane result returned by the small routing LLM."""
status: RouteStatus
function_name: str | None = None
error: str | None = None
@dataclass(frozen=True)
class EdgeEvaluation:
"""Final graph-level decision after expression, LLM and default handling."""
status: RouteStatus
edge: dict[str, Any] | None = None
error: str | None = None

View File

@@ -0,0 +1,119 @@
"""Client-visible Workflow output and fixed speech in one place."""
from __future__ import annotations
from typing import Any
from pipecat.frames.frames import OutputTransportMessageUrgentFrame, TTSSpeakFrame
from pipecat.utils.time import time_now_iso8601
from services.brains.base import BrainRuntime
from services.runtime_variables import DynamicVariableStore
class WorkflowOutput:
"""Publish debug events and fixed speech without duplicating persistence."""
def __init__(
self,
store: DynamicVariableStore,
runtime: BrainRuntime,
) -> None:
self._store = store
self._runtime = runtime
self._client_ready = False
self._pending_transcripts: list[dict[str, Any]] = []
async def mark_client_ready(self) -> None:
self._client_ready = True
pending = self._pending_transcripts
self._pending_transcripts = []
for message in pending:
await self.emit(message)
async def speak(
self,
text: str,
*,
source: str,
node_id: str | None = None,
) -> None:
"""Record, display and synthesize one Workflow-owned utterance."""
content = text.strip()
if not content:
return
self._store.record("agent", content)
transcript = {
"type": "transcript",
"role": "assistant",
"content": content,
"timestamp": time_now_iso8601(),
"source": source,
**({"nodeId": node_id} if node_id else {}),
}
if self._client_ready:
await self.emit(transcript)
else:
self._pending_transcripts.append(transcript)
track_speech = getattr(self._runtime.call_end, "track_speech", None)
if callable(track_speech):
track_speech()
await self._runtime.queue_frame(
TTSSpeakFrame(content, append_to_context=False)
)
async def emit_node_active(self, node_id: str | None) -> None:
if node_id:
await self.emit({"type": "node-active", "nodeId": node_id})
async def emit_variables(
self,
*,
reason: str,
node_id: str | None,
changed: list[str] | None = None,
) -> None:
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.emit(message)
async def emit_error(
self,
message: str,
*,
node_id: str | None,
code: str = "workflow_runtime_error",
) -> None:
payload: dict[str, Any] = {
"type": "workflow-error",
"code": code,
"message": message,
}
if node_id:
payload["nodeId"] = node_id
await self.emit(payload)
def public_variables(self) -> dict[str, str | int | float | bool]:
return {
name: value
for name, value in self._store.values.items()
if not name.startswith(("system__", "secret__"))
and isinstance(value, (str, int, float, bool))
}
async def emit(self, message: dict[str, Any]) -> None:
await self._runtime.queue_frame(
OutputTransportMessageUrgentFrame(message=message)
)

View File

@@ -0,0 +1,97 @@
"""Priority-aware Workflow edge evaluation."""
from __future__ import annotations
from collections.abc import Callable
from services.runtime_variables import DynamicVariableStore
from services.workflow.models import EdgeEvaluation, RouteStatus
from services.workflow_engine import WorkflowEngine
from services.workflow_router import WorkflowLLMRouter
class WorkflowEdgeEvaluator:
"""Evaluate one node's paths without changing runtime state."""
def __init__(
self,
engine: WorkflowEngine,
store: DynamicVariableStore,
router_for_node: Callable[[str], WorkflowLLMRouter],
) -> None:
self._engine = engine
self._store = store
self._router_for_node = router_for_node
async def evaluate(self, node_id: str) -> EdgeEvaluation:
"""Select the first matching conditional path, then the default path."""
outgoing = self._engine.outgoing(node_id)
expression_edge = self._engine.deterministic_edge(
node_id,
self._store,
include_default=False,
)
default_edge = next(
(
edge
for edge in outgoing
if self._engine.edge_mode(edge) == "always"
),
None,
)
llm_edges = [
edge for edge in outgoing if self._engine.edge_mode(edge) == "llm"
]
# A matching expression is a priority boundary. A later LLM condition
# cannot bypass it, while an earlier LLM condition still gets one chance.
if expression_edge:
expression_index = outgoing.index(expression_edge)
llm_edges = [
edge
for edge in llm_edges
if outgoing.index(edge) < expression_index
]
if not llm_edges:
return self._matched_or_no_match(expression_edge or default_edge)
result = 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__", "secret__"))
},
edge_name=self._engine.edge_fn_name,
edge_description=self._engine.edge_description,
)
if result.status == RouteStatus.ERROR:
return EdgeEvaluation(status=RouteStatus.ERROR, error=result.error)
if result.status == RouteStatus.NO_MATCH:
return self._matched_or_no_match(expression_edge or default_edge)
selected = next(
(
edge
for edge in llm_edges
if self._engine.edge_fn_name(edge) == result.function_name
),
None,
)
if selected is None:
return EdgeEvaluation(
status=RouteStatus.ERROR,
error="路由模型返回了不属于当前节点的连接",
)
return EdgeEvaluation(status=RouteStatus.MATCHED, edge=selected)
@staticmethod
def _matched_or_no_match(edge: dict | None) -> EdgeEvaluation:
if edge is None:
return EdgeEvaluation(status=RouteStatus.NO_MATCH)
return EdgeEvaluation(status=RouteStatus.MATCHED, edge=edge)