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:
19
backend/services/workflow/__init__.py
Normal file
19
backend/services/workflow/__init__.py
Normal 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",
|
||||
]
|
||||
134
backend/services/workflow/agent.py
Normal file
134
backend/services/workflow/agent.py
Normal 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,
|
||||
}
|
||||
92
backend/services/workflow/models.py
Normal file
92
backend/services/workflow/models.py
Normal 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
|
||||
|
||||
119
backend/services/workflow/output.py
Normal file
119
backend/services/workflow/output.py
Normal 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)
|
||||
)
|
||||
97
backend/services/workflow/routing.py
Normal file
97
backend/services/workflow/routing.py
Normal 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)
|
||||
|
||||
Reference in New Issue
Block a user