Enhance workflow routing and agent configuration management

- Introduce WorkflowLLMRouter for pre-response LLM routing, allowing agents to determine the appropriate function to call based on user input.
- Implement UserTurnRoutingProcessor to manage user turns before reaching the LLM, ensuring proper routing and handling of user messages.
- Refactor WorkflowBrain to integrate new routing logic and enhance agent stage configuration, including entry modes and resource management.
- Update service factory to support dynamic LLM resource configuration based on workflow settings.
- Add tests for new routing functionality and ensure proper handling of user messages in various scenarios.
This commit is contained in:
Xin Wang
2026-07-14 09:36:28 +08:00
parent 32aef14ddb
commit 72856bf3a7
19 changed files with 2611 additions and 552 deletions

View File

@@ -55,7 +55,9 @@ class BrainRuntime:
worker: Any = None
context_aggregator: Any = None
transport: Any = None
switch_services: Callable[[str | None, str | None], Awaitable[None]] | None = None
switch_services: (
Callable[[str | None, str | None, str | None], Awaitable[None]] | None
) = None
set_knowledge_scope: Callable[[dict[str, Any]], None] | None = None
set_input_enabled: Callable[[bool], None] | None = None
flow_global_functions: list[Any] = field(default_factory=list)
@@ -84,6 +86,15 @@ class BaseBrain:
def record_user_message(self, content: str) -> None:
"""Observe a committed user message for brain-owned routing state."""
async def on_user_turn_end(self, content: str) -> bool:
"""Handle a complete user turn before the conversational LLM runs.
Return True when the brain scheduled the next action itself and the
in-flight context frame must not reach the previous Agent's LLM.
"""
self.record_user_message(content)
return False
async def on_assistant_text_start(self, turn_id: str) -> None:
"""Observe the start of a generated assistant turn."""
@@ -114,6 +125,8 @@ class Brain(Protocol):
def record_user_message(self, content: str) -> None: ...
async def on_user_turn_end(self, content: str) -> bool: ...
async def on_assistant_text_start(self, turn_id: str) -> None: ...
async def on_assistant_text_end(

View File

@@ -15,6 +15,7 @@ from pipecat.flows import (
NodeConfig,
)
from pipecat.frames.frames import (
LLMRunFrame,
LLMUpdateSettingsFrame,
OutputTransportMessageUrgentFrame,
TTSSpeakFrame,
@@ -22,18 +23,20 @@ from pipecat.frames.frames import (
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_engine import WorkflowEngine
from services.workflow_router import STAY_ON_CURRENT_AGENT, WorkflowLLMRouter
MAX_AUTOMATIC_HOPS = 50
AGENT_STAGE_INSTRUCTION = (
"完成当前阶段任务。需要流转时必须调用对应的转移函数;"
"不要在调用转移函数后继续生成口头回复"
"工作流路由已在用户一轮输入结束时完成。只执行当前阶段任务,"
"不要自行解释、模拟或宣布节点切换"
)
@@ -58,6 +61,7 @@ class WorkflowBrain(BaseBrain):
}
self._runtime: BrainRuntime | None = None
self._manager: FlowManager | None = None
self._router = WorkflowLLMRouter(cfg or AssistantConfig(type="workflow"))
self._ended = False
async def greeting(self, cfg: AssistantConfig) -> str:
@@ -79,6 +83,7 @@ class WorkflowBrain(BaseBrain):
self._store = DynamicVariableStore.from_config(cfg)
self._tools = ToolExecutor(self._store)
self._tool_by_id = {tool.id: tool for tool in cfg.tools}
self._router = WorkflowLLMRouter(cfg)
self._manager = FlowManager(
worker=runtime.worker,
llm=runtime.llm,
@@ -95,9 +100,13 @@ class WorkflowBrain(BaseBrain):
self._store,
include_default=True,
)
if not edge:
if not edge and self._engine.has_outgoing(self._engine.start_id):
raise RuntimeError("Start 初始化后没有命中的表达式边或默认边")
node_config = await self._follow_edge(edge)
node_config = (
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)
@@ -107,6 +116,76 @@ class WorkflowBrain(BaseBrain):
if content and not self._ended:
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."""
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":
return True
edge = self._engine.deterministic_edge(
current,
self._store,
include_default=False,
)
outgoing = self._engine.outgoing(current)
llm_edges = [
candidate
for candidate in outgoing
if self._engine.edge_mode(candidate) == "llm"
]
default_edge = next(
(
candidate
for candidate in outgoing
if self._engine.edge_mode(candidate) == "always"
),
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
if edge and manager.current_node == current:
next_config = await self._follow_edge(edge)
await manager.set_node_from_config(next_config)
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 on_assistant_text_end(
self,
_turn_id: str,
@@ -116,19 +195,6 @@ class WorkflowBrain(BaseBrain):
if not content or interrupted or self._ended:
return
self._store.record("agent", content, completed_agent_turn=True)
manager = self._require_manager()
current = manager.current_node
if not current or self._engine.node_type(current) != "agent":
return
await self._refresh_agent_prompt(current)
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)
await manager.set_node_from_config(next_config)
async def _refresh_agent_prompt(self, node_id: str) -> None:
runtime = self._require_runtime()
@@ -145,61 +211,104 @@ class WorkflowBrain(BaseBrain):
stage_prompt = self._engine.prompt_for(node_id, self._store)
return f"{stage_prompt}\n\n[工作流执行规则]\n{AGENT_STAGE_INSTRUCTION}"
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:
return self._router
from services.pipecat.service_factory import config_with_resource
return WorkflowLLMRouter(config_with_resource(cfg, resource))
async def _apply_agent_stage(self, node_id: str) -> None:
data = self._engine.data(node_id)
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)
asr_id = str(
data.get("asrResourceId")
or self._engine.settings.get("defaultAsrResourceId")
or ""
)
tts_id = str(
data.get("ttsResourceId")
or self._engine.settings.get("defaultTtsResourceId")
or ""
)
runtime = self._require_runtime()
if runtime.switch_services:
await runtime.switch_services(asr_id or None, tts_id or None)
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": data.get("knowledgeBaseId"),
"mode": data.get("knowledgeMode", "disabled"),
"top_n": int(data.get("knowledgeTopN") or 5),
"score_threshold": float(data.get("knowledgeScoreThreshold") or 0.0),
"knowledge_base_id": stage.knowledge_base_id,
"mode": stage.knowledge_mode,
"top_n": stage.knowledge_top_n,
"score_threshold": stage.knowledge_score_threshold,
}
)
def _agent_config(self, node_id: str) -> 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
)
stage = self._engine.agent_stage_config(node_id)
functions: list[FlowsFunctionSchema] = []
for tool_id in data.get("toolIds") or []:
for tool_id in stage.tool_ids:
tool = self._tool_by_id.get(str(tool_id))
if tool and tool.type == "http":
functions.append(self._flow_tool(tool, node_id))
knowledge_function = self._knowledge_function(node_id)
if knowledge_function:
functions.append(knowledge_function)
for edge in self._engine.llm_edges(node_id):
functions.append(self._flow_edge(edge))
return {
config: NodeConfig = {
"name": node_id,
"role_message": self._agent_role_message(node_id),
"task_messages": [],
"task_messages": (
[{"role": "assistant", "content": entry_speech}]
if entry_mode == "fixed_speech"
else []
),
"functions": functions,
"context_strategy": ContextStrategyConfig(strategy=strategy),
"respond_immediately": True,
"respond_immediately": entry_mode == "generate",
}
if entry_mode == "fixed_speech":
config["pre_actions"] = [
{
"type": "workflow_fixed_speech",
"text": entry_speech,
"handler": self._play_fixed_speech,
}
]
return config
def _terminal_config(self, node_id: str) -> NodeConfig:
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 ""))
async def _queue_visible_speech(self, text: str) -> 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()
await runtime.queue_frame(
OutputTransportMessageUrgentFrame(
message={
"type": "transcript",
"role": "assistant",
"content": content,
"timestamp": time_now_iso8601(),
}
)
)
await runtime.queue_frame(TTSSpeakFrame(content, append_to_context=False))
def _passive_node_config(self, node_id: str) -> NodeConfig:
"""Keep a non-conversational terminal node active without ending the call."""
return {
"name": node_id,
"role_message": self._store.render(self._engine.global_prompt()),
@@ -235,24 +344,13 @@ class WorkflowBrain(BaseBrain):
properties=properties,
required=required,
handler=handler,
)
def _flow_edge(self, edge: dict) -> FlowsFunctionSchema:
async def handler(_args, _flow_manager):
return None, await self._follow_edge(edge)
return FlowsFunctionSchema(
name=self._engine.edge_fn_name(edge),
description=self._engine.edge_description(edge),
properties={},
required=[],
handler=handler,
cancel_on_interruption=True,
)
def _knowledge_function(self, node_id: str) -> FlowsFunctionSchema | None:
data = self._engine.data(node_id)
knowledge_id = str(data.get("knowledgeBaseId") or "")
if not knowledge_id or data.get("knowledgeMode") != "on_demand":
stage = self._engine.agent_stage_config(node_id)
knowledge_id = str(stage.knowledge_base_id or "")
if not knowledge_id or stage.knowledge_mode != "on_demand":
return None
cfg = self._cfg or AssistantConfig(type="workflow")
knowledge = cfg.workflow_knowledge_bases.get(knowledge_id)
@@ -270,8 +368,8 @@ class WorkflowBrain(BaseBrain):
session,
knowledge_id,
query,
top_k=int(data.get("knowledgeTopN") or 5),
score_threshold=float(data.get("knowledgeScoreThreshold") or 0.0),
top_k=stage.knowledge_top_n,
score_threshold=stage.knowledge_score_threshold,
)
return {"status": "ok", "results": results}
except Exception as exc: # noqa: BLE001 - tool errors are returned to the LLM
@@ -286,14 +384,13 @@ class WorkflowBrain(BaseBrain):
},
required=["query"],
handler=handler,
cancel_on_interruption=True,
)
async def _follow_edge(self, edge: dict) -> NodeConfig:
speech = self._engine.edge_transition_speech(edge)
if speech:
await self._require_runtime().queue_frame(
TTSSpeakFrame(self._store.render(speech), append_to_context=False)
)
await self._queue_visible_speech(self._store.render(speech))
return await self._resolve_path(str(edge.get("target") or ""))
async def _resolve_path(self, node_id: str) -> NodeConfig:
@@ -304,7 +401,7 @@ class WorkflowBrain(BaseBrain):
return self._agent_config(node_id)
if node_type == "end":
await self._enter_end(node_id)
return self._terminal_config(node_id)
return self._passive_node_config(node_id)
if node_type == "action":
await self._enter_action(node_id)
elif node_type == "handoff":
@@ -313,6 +410,8 @@ class WorkflowBrain(BaseBrain):
await self._emit_node_active(node_id)
else:
raise RuntimeError(f"工作流指向未知节点:{node_id}")
if not self._engine.has_outgoing(node_id):
return self._passive_node_config(node_id)
edge = self._engine.deterministic_edge(
node_id,
self._store,
@@ -322,9 +421,7 @@ class WorkflowBrain(BaseBrain):
raise RuntimeError(f"自动节点 {node_id} 没有命中的表达式边或默认边")
speech = self._engine.edge_transition_speech(edge)
if speech:
await self._require_runtime().queue_frame(
TTSSpeakFrame(self._store.render(speech), append_to_context=False)
)
await self._queue_visible_speech(self._store.render(speech))
node_id = str(edge.get("target") or "")
raise RuntimeError("工作流连续自动跳转超过安全上限")
@@ -366,9 +463,7 @@ class WorkflowBrain(BaseBrain):
)
)
if message:
await self._require_runtime().queue_frame(
TTSSpeakFrame(message, append_to_context=False)
)
await self._queue_visible_speech(message)
self._store.values["system__handoff_status"] = "requested"
async def _enter_end(self, node_id: str) -> None:
@@ -389,12 +484,12 @@ class WorkflowBrain(BaseBrain):
)
)
if message:
await runtime.queue_frame(TTSSpeakFrame(message, append_to_context=False))
await self._queue_visible_speech(message)
return
runtime.call_end.begin("workflow_completed")
if message:
runtime.call_end.arm_after_speech()
await runtime.queue_frame(TTSSpeakFrame(message, append_to_context=False))
await self._queue_visible_speech(message)
else:
await runtime.call_end.finish()

View File

@@ -10,6 +10,8 @@ from typing import Any
SPEC_VERSION = "3"
NODE_TYPES = {"start", "agent", "action", "handoff", "end"}
EDGE_MODES = {"llm", "expression", "always"}
AGENT_ENTRY_MODES = {"wait_user", "generate", "fixed_speech"}
AUTOMATIC_NODE_TYPES = {"start", "action", "handoff"}
EXPRESSION_OPERATORS = {
"eq",
"neq",
@@ -34,7 +36,7 @@ NODE_SPECS: list[dict[str, Any]] = [
"constraints": {
"minIncoming": 0,
"maxIncoming": 0,
"minOutgoing": 1,
"minOutgoing": 0,
"minInstances": 1,
"maxInstances": 1,
},
@@ -51,7 +53,7 @@ NODE_SPECS: list[dict[str, Any]] = [
"icon": "Bot",
"accent": "sky",
"addable": True,
"constraints": {"minIncoming": 1, "minOutgoing": 1},
"constraints": {"minIncoming": 1, "minOutgoing": 0},
"fields": [
{"key": "name", "label": "节点名称", "type": "text", "default": "Agent"},
{
@@ -84,7 +86,7 @@ NODE_SPECS: list[dict[str, Any]] = [
"icon": "PhoneForwarded",
"accent": "lavender",
"addable": True,
"constraints": {"minIncoming": 1, "minOutgoing": 1},
"constraints": {"minIncoming": 1, "minOutgoing": 0},
"fields": [
{"key": "name", "label": "节点名称", "type": "text", "default": "Handoff"},
{"key": "target", "label": "转交目标", "type": "text", "default": ""},
@@ -132,13 +134,49 @@ def _edge_data_v3(edge: dict, source_type: str) -> dict:
return data
def _normalize_agent_data(data: dict[str, Any]) -> None:
"""Add v3 Agent defaults without changing existing node-level behavior."""
data.setdefault("contextPolicy", "inherit")
data.setdefault("entryMode", "wait_user")
data.setdefault("entrySpeech", "")
if "inheritGlobalConfig" not in data:
has_node_overrides = any(
(
data.get("llmResourceId"),
data.get("asrResourceId"),
data.get("ttsResourceId"),
data.get("knowledgeBaseId"),
data.get("toolIds"),
)
)
data["inheritGlobalConfig"] = not has_node_overrides
def _normalize_settings(settings: dict[str, Any], *, global_prompt: str = "") -> None:
settings.setdefault("globalPrompt", global_prompt)
settings.setdefault("defaultLlmResourceId", "")
settings.setdefault("defaultAsrResourceId", "")
settings.setdefault("defaultTtsResourceId", "")
settings.setdefault("toolIds", [])
settings.setdefault("knowledgeBaseId", "")
settings.setdefault("knowledgeMode", "automatic")
settings.setdefault("knowledgeTopN", 5)
settings.setdefault("knowledgeScoreThreshold", 0.0)
def normalize_graph(graph: dict[str, Any] | None) -> dict[str, Any]:
"""Return a deep-copied v3 graph; preserve v3 IDs and migrate v2 semantics."""
source = deepcopy(graph or {})
if str(source.get("specVersion") or "") == SPEC_VERSION:
source.setdefault("settings", {})
settings = source.setdefault("settings", {})
_normalize_settings(settings)
source.setdefault("nodes", [])
source.setdefault("edges", [])
for node in source["nodes"]:
if node.get("type") != "agent":
continue
data = node.setdefault("data", {})
_normalize_agent_data(data)
return source
nodes = source.get("nodes") or []
@@ -171,9 +209,7 @@ def normalize_graph(graph: dict[str, Any] | None) -> dict[str, Any]:
data["message"] = data.pop("message", data.pop("prompt", ""))
data.setdefault("scope", "session")
elif new_type == "agent":
data.setdefault("contextPolicy", "inherit")
data.setdefault("toolIds", [])
data.setdefault("knowledgeMode", "disabled")
_normalize_agent_data(data)
elif new_type == "start":
prompt = str(data.pop("prompt", "") or "").strip()
if prompt:
@@ -207,8 +243,9 @@ def normalize_graph(graph: dict[str, Any] | None) -> dict[str, Any]:
"name": "迁移的开场 Agent",
"prompt": prompt,
"contextPolicy": "inherit",
"toolIds": [],
"knowledgeMode": "disabled",
"inheritGlobalConfig": True,
"entryMode": "wait_user",
"entrySpeech": "",
},
}
)
@@ -228,9 +265,7 @@ def normalize_graph(graph: dict[str, Any] | None) -> dict[str, Any]:
)
settings = deepcopy(source.get("settings") or {})
settings.setdefault("globalPrompt", global_prompt)
settings.setdefault("defaultAsrResourceId", "")
settings.setdefault("defaultTtsResourceId", "")
_normalize_settings(settings, global_prompt=global_prompt)
return {
"specVersion": 3,
"settings": settings,
@@ -281,11 +316,18 @@ def validate_graph(graph: dict[str, Any]) -> list[str]:
node_by_id[node_id] = node
counts[node_type] += 1
if node_type == "agent":
data = node.get("data") or {}
entry_mode = data.get("entryMode", "wait_user")
if entry_mode not in AGENT_ENTRY_MODES:
errors.append(f"Agent 节点 {node_id} 的进入模式无效:{entry_mode}")
elif entry_mode == "fixed_speech" and not str(
data.get("entrySpeech") or ""
).strip():
errors.append(f"Agent 节点 {node_id} 的固定进入语不能为空")
if counts["start"] != 1:
errors.append("工作流必须有且仅有一个 Start 节点")
if counts["end"] < 1:
errors.append("工作流至少需要一个 End 节点")
incoming: dict[str, int] = defaultdict(int)
outgoing: dict[str, int] = defaultdict(int)
adj: dict[str, list[str]] = defaultdict(list)
@@ -313,7 +355,8 @@ def validate_graph(graph: dict[str, Any]) -> list[str]:
if mode == "llm" and not str(data.get("condition") or "").strip():
errors.append(f"LLM 判断边缺少自然语言条件:{edge_id}")
if mode == "expression":
errors.extend(f"{edge_id}:{item}" for item in _validate_expression(data.get("expression")))
expression_errors = _validate_expression(data.get("expression"))
errors.extend(f"{edge_id}:{item}" for item in expression_errors)
try:
priority = int(data.get("priority", 10))
except (TypeError, ValueError):
@@ -329,7 +372,9 @@ def validate_graph(graph: dict[str, Any]) -> list[str]:
incoming[target_id] += 1
outgoing[source_id] += 1
adj[source_id].append(target_id)
if node_by_id[source_id].get("type") != "agent" and node_by_id[target_id].get("type") != "agent":
source_is_automatic = node_by_id[source_id].get("type") != "agent"
target_is_automatic = node_by_id[target_id].get("type") != "agent"
if source_is_automatic and target_is_automatic:
auto_adj[source_id].append(target_id)
for node_id, node in node_by_id.items():
@@ -347,10 +392,18 @@ def validate_graph(graph: dict[str, Any]) -> list[str]:
errors.append(f"节点 {node_id}{label}不能少于 {lo}")
if hi is not None and actual > hi:
errors.append(f"节点 {node_id}{label}不能多于 {hi}")
if node.get("type") in {"start", "action", "handoff"} and always_counts[node_id] != 1:
errors.append(f"自动节点 {node_id} 必须有且仅有一条默认边")
node_type = node.get("type")
if (
node_type in AUTOMATIC_NODE_TYPES
and outgoing[node_id] > 0
and always_counts[node_id] != 1
):
errors.append(f"自动节点 {node_id} 存在出边时必须有且仅有一条默认边")
start_id = next((nid for nid, n in node_by_id.items() if n.get("type") == "start"), None)
start_id = next(
(node_id for node_id, node in node_by_id.items() if node.get("type") == "start"),
None,
)
if start_id:
reached = {start_id}
queue = deque([start_id])
@@ -380,7 +433,12 @@ def validate_graph(graph: dict[str, Any]) -> list[str]:
visited.add(node_id)
return False
if any(visit(node_id) for node_id, node in node_by_id.items() if node.get("type") != "agent"):
automatic_node_ids = (
node_id
for node_id, node in node_by_id.items()
if node.get("type") != "agent"
)
if any(visit(node_id) for node_id in automatic_node_ids):
errors.append("Start/Action/Handoff/End 之间不能形成无等待循环")
return list(dict.fromkeys(errors))
@@ -392,22 +450,35 @@ def graph_references(graph: dict[str, Any]) -> dict[str, set[str]]:
resources = {
str(value)
for value in (
settings.get("defaultLlmResourceId"),
settings.get("defaultAsrResourceId"),
settings.get("defaultTtsResourceId"),
)
if value
}
tools: set[str] = set()
knowledge: set[str] = set()
tools: set[str] = {str(tool_id) for tool_id in settings.get("toolIds") or []}
knowledge: set[str] = (
{str(settings["knowledgeBaseId"])}
if settings.get("knowledgeBaseId")
else set()
)
for node in normalized.get("nodes") or []:
data = node.get("data") or {}
for resource_id in (data.get("asrResourceId"), data.get("ttsResourceId")):
if resource_id:
resources.add(str(resource_id))
for tool_id in data.get("toolIds") or []:
tools.add(str(tool_id))
inherits_global = (
node.get("type") == "agent" and data.get("inheritGlobalConfig", True)
)
if not inherits_global:
for resource_id in (
data.get("llmResourceId"),
data.get("asrResourceId"),
data.get("ttsResourceId"),
):
if resource_id:
resources.add(str(resource_id))
for tool_id in data.get("toolIds") or []:
tools.add(str(tool_id))
if data.get("knowledgeBaseId"):
knowledge.add(str(data["knowledgeBaseId"]))
if data.get("toolId"):
tools.add(str(data["toolId"]))
if data.get("knowledgeBaseId"):
knowledge.add(str(data["knowledgeBaseId"]))
return {"model_resources": resources, "tools": tools, "knowledge_bases": knowledge}

View File

@@ -24,6 +24,7 @@ from services.pipecat.call_lifecycle import (
)
from services.pipecat.service_factory import (
config_with_resource,
create_llm,
create_realtime_service,
create_stt,
create_tts,
@@ -51,6 +52,7 @@ from pipecat.frames.frames import (
UserImageRequestFrame,
)
from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.llm_switcher import LLMSwitcher
from pipecat.pipeline.service_switcher import ServiceSwitcher
from pipecat.pipeline.worker import PipelineParams, PipelineWorker
from pipecat.processors.aggregators.llm_context import LLMContext
@@ -487,6 +489,49 @@ class KnowledgeRetrievalProcessor(FrameProcessor):
await self.push_frame(frame, direction)
class UserTurnRoutingProcessor(FrameProcessor):
"""Give a brain first right of refusal before a new user turn reaches the LLM."""
def __init__(self, brain: Brain):
super().__init__()
self._brain = brain
self._last_user_message: dict | None = None
async def process_frame(self, frame, direction: FrameDirection):
await super().process_frame(frame, direction)
if direction != FrameDirection.DOWNSTREAM or not isinstance(
frame, LLMContextFrame
):
await self.push_frame(frame, direction)
return
user_message = next(
(
message
for message in reversed(frame.context.get_messages())
if message.get("role") == "user"
and isinstance(message.get("content"), str)
and str(message.get("content") or "").strip()
),
None,
)
if user_message is None:
await self.push_frame(frame, direction)
return
if user_message is self._last_user_message:
# Programmatic LLMRunFrame after a node transition reuses the same
# user message. It is a response run, not another routing event.
await self.push_frame(frame, direction)
return
self._last_user_message = user_message
content = str(user_message.get("content") or "").strip()
handled = await self._brain.on_user_turn_end(content)
if not handled:
await self.push_frame(frame, direction)
class PassthroughLLMAssistantAggregator(LLMAssistantAggregator):
"""聚合 LLM 回复进上下文,同时继续把回复帧交给下游 TTS。"""
@@ -589,6 +634,29 @@ def _workflow_service_switcher(
return ServiceSwitcher(services=services), services_by_id, primary
def _workflow_llm_switcher(cfg: AssistantConfig, base_service):
"""Build an LLM switcher for the global model and Agent overrides."""
settings = cfg.graph.get("settings") or {}
default_id = str(settings.get("defaultLlmResourceId") or "")
services_by_id = {}
for resource_id, resource in cfg.workflow_model_resources.items():
if resource.capability != "LLM":
continue
services_by_id[resource_id] = (
base_service
if resource_id == default_id
else create_llm(config_with_resource(cfg, resource))
)
primary = services_by_id.get(default_id, base_service)
services = [primary]
services.extend(
service for service in services_by_id.values() if service is not primary
)
if base_service is not primary:
services.append(base_service)
return LLMSwitcher(llms=services), services_by_id, primary
async def run_pipeline(
transport,
cfg: AssistantConfig,
@@ -630,6 +698,9 @@ async def run_pipeline(
return
graph_settings = cfg.graph.get("settings") or {}
default_llm_resource = cfg.workflow_model_resources.get(
str(graph_settings.get("defaultLlmResourceId") or "")
)
default_asr_resource = cfg.workflow_model_resources.get(
str(graph_settings.get("defaultAsrResourceId") or "")
)
@@ -713,7 +784,16 @@ async def run_pipeline(
)
input_state = {"enabled": True}
# LLM 槽由大脑提供:本地模型或 Dify/FastGPT 外部托管适配器。
llm = brain.build_llm(cfg, context)
llm = brain.build_llm(
config_with_resource(cfg, default_llm_resource)
if cfg.type == "workflow" and default_llm_resource
else cfg,
context,
)
llm_services: dict[str, FrameProcessor] = {}
current_llm_service = llm
if cfg.type == "workflow":
llm, llm_services, current_llm_service = _workflow_llm_switcher(cfg, llm)
user_aggregator = LLMUserAggregator(
context,
params=LLMUserAggregatorParams(
@@ -730,6 +810,7 @@ async def run_pipeline(
),
),
)
user_turn_router = UserTurnRoutingProcessor(brain)
assistant_aggregator = PassthroughLLMAssistantAggregator(context)
text_input = TextInputProcessor(
should_ignore_input=lambda: call_end.ending or not input_state["enabled"]
@@ -880,6 +961,7 @@ async def run_pipeline(
properties=vision_schema.properties,
required=vision_schema.required,
handler=flow_fetch_user_image,
cancel_on_interruption=True,
)
)
@@ -913,6 +995,7 @@ async def run_pipeline(
text_input,
stt_processor,
user_aggregator,
user_turn_router,
knowledge_retrieval,
llm,
# Aggregate the streamed LLM text before TTS. On interruption,
@@ -934,24 +1017,42 @@ async def run_pipeline(
enable_rtvi=False,
)
worker_holder["worker"] = worker
default_voice_services = dict(current_voice_services)
default_workflow_services = {
"llm": current_llm_service,
**current_voice_services,
}
async def switch_workflow_services(
llm_resource_id: str | None,
asr_resource_id: str | None,
tts_resource_id: str | None,
) -> None:
nonlocal current_llm_service
requested = (
("llm", llm_services, llm_resource_id),
("asr", stt_services, asr_resource_id),
("tts", tts_services, tts_resource_id),
)
for kind, services, resource_id in requested:
target = services.get(resource_id) if resource_id else default_voice_services[kind]
target = (
services.get(resource_id)
if resource_id
else default_workflow_services[kind]
)
if target is None:
raise ValueError(f"Workflow {kind.upper()} 资源未加载:{resource_id}")
if current_voice_services[kind] is target:
current = (
current_llm_service
if kind == "llm"
else current_voice_services[kind]
)
if current is target:
continue
await worker.queue_frame(ManuallySwitchServiceFrame(service=target))
current_voice_services[kind] = target
if kind == "llm":
current_llm_service = target
else:
current_voice_services[kind] = target
await worker.queue_frame(
OutputTransportMessageUrgentFrame(
message={
@@ -1020,8 +1121,6 @@ async def run_pipeline(
@user_aggregator.event_handler("on_user_turn_stopped")
async def on_user_turn_stopped(_aggregator, _strategy, message):
if message.content:
brain.record_user_message(message.content)
await queue_transcript("user", message.content, message.timestamp)
@assistant_aggregator.event_handler("on_assistant_text_start")
@@ -1066,7 +1165,6 @@ async def run_pipeline(
@text_input.event_handler("on_text_input")
async def on_text_input(_processor, text):
pending_text_inputs.append(text)
brain.record_user_message(text)
# 前端显示不依赖 interruption 后续事件,必须在打断前先排入发送队列。
await queue_transcript("user", text, time_now_iso8601())

View File

@@ -29,11 +29,18 @@ TTS_STOP_FRAME_TIMEOUT_S = 1.0
def config_with_resource(
cfg: AssistantConfig, resource: RuntimeModelResource
) -> AssistantConfig:
"""Return a call-local config view for one workflow ASR/TTS resource."""
"""Return a call-local config view for one workflow model resource."""
result = cfg.model_copy(deep=True)
values = resource.values or {}
secrets = resource.secrets or {}
if resource.capability == "ASR":
if resource.capability == "LLM":
result.model = str(values.get("modelId") or "")
result.llm_interface_type = resource.interface_type
result.llm_values = values
result.llm_secrets = secrets
result.llm_api_key = str(secrets.get("apiKey") or "")
result.llm_base_url = str(values.get("apiUrl") or "")
elif resource.capability == "ASR":
result.asr = str(values.get("modelId") or "")
result.stt_language = str(values.get("language") or "")
result.stt_interface_type = resource.interface_type
@@ -51,7 +58,7 @@ def config_with_resource(
result.tts_api_key = str(secrets.get("apiKey") or "")
result.tts_base_url = str(values.get("apiUrl") or "")
else:
raise ValueError(f"工作流语音资源能力无效:{resource.capability}")
raise ValueError(f"工作流模型资源能力无效:{resource.capability}")
return result

View File

@@ -3,12 +3,28 @@
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Any
from services.node_specs import normalize_graph
from services.runtime_variables import DynamicVariableStore
@dataclass(frozen=True)
class AgentStageConfig:
"""The complete assistant configuration active inside one Agent node."""
inherits_global: bool
llm_resource_id: str
asr_resource_id: str
tts_resource_id: str
tool_ids: tuple[str, ...]
knowledge_base_id: str | None
knowledge_mode: str
knowledge_top_n: int
knowledge_score_threshold: float
class WorkflowEngine:
def __init__(self, graph: dict[str, Any]):
self.graph = normalize_graph(graph)
@@ -20,7 +36,11 @@ class WorkflowEngine:
}
self.edges: list[dict] = list(self.graph.get("edges") or [])
self.start_id = next(
(node_id for node_id, node in self.nodes.items() if node.get("type") == "start"),
(
node_id
for node_id, node in self.nodes.items()
if node.get("type") == "start"
),
None,
)
@@ -38,7 +58,13 @@ class WorkflowEngine:
def outgoing(self, node_id: str | None) -> list[dict]:
result = [edge for edge in self.edges if edge.get("source") == node_id]
return sorted(result, key=lambda edge: int((edge.get("data") or {}).get("priority", 10)))
return sorted(
result,
key=lambda edge: int((edge.get("data") or {}).get("priority", 10)),
)
def has_outgoing(self, node_id: str | None) -> bool:
return any(edge.get("source") == node_id for edge in self.edges)
def edge_mode(self, edge: dict) -> str:
return str((edge.get("data") or {}).get("mode") or "always")
@@ -60,15 +86,49 @@ class WorkflowEngine:
if not edge:
return ""
data = edge.get("data") or {}
return str(data.get("transitionSpeech") or data.get("transition_speech") or "")
return str(
data.get("transitionSpeech") or data.get("transition_speech") or ""
)
def global_prompt(self) -> str:
return str(self.settings.get("globalPrompt") or "").strip()
def inherits_global_config(self, node_id: str) -> bool:
"""Return the Agent's explicit configuration scope, defaulting to global."""
return bool(self.data(node_id).get("inheritGlobalConfig", True))
def agent_stage_config(self, node_id: str) -> AgentStageConfig:
"""Resolve either Workflow defaults or one Agent's complete override."""
data = self.data(node_id)
inherits_global = self.inherits_global_config(node_id)
source = self.settings if inherits_global else data
llm_key = "defaultLlmResourceId" if inherits_global else "llmResourceId"
asr_key = "defaultAsrResourceId" if inherits_global else "asrResourceId"
tts_key = "defaultTtsResourceId" if inherits_global else "ttsResourceId"
knowledge_base_id = str(source.get("knowledgeBaseId") or "")
return AgentStageConfig(
inherits_global=inherits_global,
llm_resource_id=str(source.get(llm_key) or ""),
asr_resource_id=str(source.get(asr_key) or ""),
tts_resource_id=str(source.get(tts_key) or ""),
tool_ids=tuple(str(tool_id) for tool_id in source.get("toolIds") or []),
knowledge_base_id=knowledge_base_id or None,
knowledge_mode=(
str(source.get("knowledgeMode") or "automatic")
if knowledge_base_id
else "disabled"
),
knowledge_top_n=int(source.get("knowledgeTopN") or 5),
knowledge_score_threshold=float(
source.get("knowledgeScoreThreshold") or 0.0
),
)
def prompt_for(self, node_id: str, store: DynamicVariableStore) -> str:
"""Build the Agent system prompt according to its inheritance setting."""
prompt = store.render(str(self.data(node_id).get("prompt") or "").strip())
sections = [f"[当前阶段:{self.name(node_id)}]"]
if self.global_prompt():
if self.inherits_global_config(node_id) and self.global_prompt():
sections.append(f"[全局规则]\n{store.render(self.global_prompt())}")
if prompt:
sections.append(f"[当前阶段任务]\n{prompt}")
@@ -111,7 +171,11 @@ class WorkflowEngine:
results.append(matched)
if not results:
return False
return all(results) if expression.get("combinator", "and") == "and" else any(results)
return (
all(results)
if expression.get("combinator", "and") == "and"
else any(results)
)
def deterministic_edge(
self,

View File

@@ -0,0 +1,129 @@
"""Pre-response LLM routing for Workflow Agent edges.
The router deliberately uses a separate, short completion. Its only output is
a required function choice, so the current Agent cannot speak before the graph
has decided whether the user turn belongs to another node.
"""
from __future__ import annotations
import json
from collections.abc import Callable
from typing import Any
from loguru import logger
from models import AssistantConfig
from openai import AsyncOpenAI
STAY_ON_CURRENT_AGENT = "workflow_stay_on_current_agent"
MAX_ROUTING_HISTORY_ENTRIES = 20
class WorkflowLLMRouter:
"""Select one LLM edge before the conversational LLM is allowed to reply."""
def __init__(self, cfg: AssistantConfig):
self._cfg = cfg
async def select_edge(
self,
*,
node_name: str,
node_prompt: str,
edges: list[dict[str, Any]],
history: list[dict[str, str]],
variables: dict[str, Any],
edge_name: Callable[[dict[str, Any]], str],
edge_description: Callable[[dict[str, Any]], str],
) -> str | None:
"""Return an edge function name, STAY, or None when routing failed."""
if not edges:
return STAY_ON_CURRENT_AGENT
names = {edge_name(edge) for edge in edges}
stay_name = STAY_ON_CURRENT_AGENT
while stay_name in names:
stay_name = f"_{stay_name}"
tools = [
{
"type": "function",
"function": {
"name": edge_name(edge),
"description": edge_description(edge),
"parameters": {"type": "object", "properties": {}},
},
}
for edge in edges
]
tools.append(
{
"type": "function",
"function": {
"name": stay_name,
"description": "所有转移条件都不满足,继续由当前 Agent 处理用户消息。",
"parameters": {"type": "object", "properties": {}},
},
}
)
ordered_conditions = "\n".join(
f"{index + 1}. {edge_description(edge)}"
for index, edge in enumerate(edges)
)
router_prompt = (
"你是工作流路由器,不是对话助手。收到一轮完整用户输入后,"
"必须且只能调用一个提供的函数,禁止输出任何口头回复。\n"
"按给出的顺序判断转移条件;选择第一个明确满足的转移函数。"
"如果没有条件满足,调用留在当前 Agent 的函数。\n\n"
f"当前节点:{node_name}\n"
f"当前节点任务:{node_prompt or '未配置'}\n"
f"转移条件:\n{ordered_conditions}"
)
recent_history = history[-MAX_ROUTING_HISTORY_ENTRIES:]
routing_input = json.dumps(
{
"conversation": recent_history,
"session_variables": variables,
},
ensure_ascii=False,
separators=(",", ":"),
)
extra_body = self._cfg.llm_values.get("extraBody")
request_extra = (
{"extra_body": extra_body} if isinstance(extra_body, dict) else {}
)
client = AsyncOpenAI(
api_key=self._cfg.llm_api_key,
base_url=self._cfg.llm_base_url,
timeout=15.0,
)
try:
response = await client.chat.completions.create(
model=self._cfg.model,
messages=[
{"role": "system", "content": router_prompt},
{"role": "user", "content": routing_input},
],
tools=tools,
tool_choice="required",
temperature=0,
**request_extra,
)
tool_calls = response.choices[0].message.tool_calls or []
if not tool_calls:
logger.warning("Workflow 路由 LLM 未返回函数调用,留在当前 Agent")
return STAY_ON_CURRENT_AGENT
selected = str(tool_calls[0].function.name or "")
if selected == stay_name:
return STAY_ON_CURRENT_AGENT
if selected not in names:
logger.warning(f"Workflow 路由 LLM 返回未知函数:{selected}")
return STAY_ON_CURRENT_AGENT
return selected
except Exception as exc: # noqa: BLE001 - routing failure must not end the call
logger.warning(f"Workflow LLM 边判断失败,留在当前 Agent:{exc}")
return None
finally:
await client.close()