- 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.
239 lines
9.2 KiB
Python
239 lines
9.2 KiB
Python
"""Pure Workflow v3 graph queries and deterministic edge evaluation."""
|
|
|
|
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
|
|
enable_interrupt: bool
|
|
turn_config: dict[str, Any]
|
|
|
|
|
|
class WorkflowEngine:
|
|
def __init__(self, graph: dict[str, Any]):
|
|
self.graph = normalize_graph(graph)
|
|
self.settings = self.graph.get("settings") or {}
|
|
self.nodes: dict[str, dict] = {
|
|
str(node["id"]): node
|
|
for node in self.graph.get("nodes") or []
|
|
if node.get("id")
|
|
}
|
|
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"
|
|
),
|
|
None,
|
|
)
|
|
|
|
def has_graph(self) -> bool:
|
|
return bool(self.start_id)
|
|
|
|
def node_type(self, node_id: str | None) -> str | None:
|
|
return self.nodes.get(node_id or "", {}).get("type")
|
|
|
|
def data(self, node_id: str | None) -> dict:
|
|
return self.nodes.get(node_id or "", {}).get("data") or {}
|
|
|
|
def name(self, node_id: str | None) -> str:
|
|
return str(self.data(node_id).get("name") or self.node_type(node_id) or "")
|
|
|
|
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)),
|
|
)
|
|
|
|
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")
|
|
|
|
def edge_fn_name(self, edge: dict) -> str:
|
|
raw = edge.get("id") or f"{edge.get('source')}_{edge.get('target')}"
|
|
slug = re.sub(r"[^a-z0-9]+", "_", str(raw).lower()).strip("_")
|
|
return f"goto_{slug or 'next'}"
|
|
|
|
def edge_description(self, edge: dict) -> str:
|
|
data = edge.get("data") or {}
|
|
target = self.name(str(edge.get("target") or ""))
|
|
condition = str(data.get("condition") or "").strip()
|
|
if condition:
|
|
return f"当满足以下条件时转到「{target}」:{condition}"
|
|
return f"当当前阶段任务完成时转到「{target}」。"
|
|
|
|
def edge_transition_speech(self, edge: dict | None) -> str:
|
|
if not edge:
|
|
return ""
|
|
data = edge.get("data") 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 "")
|
|
global_turn_config = self.settings.get("turnConfig")
|
|
if not isinstance(global_turn_config, dict):
|
|
global_turn_config = {}
|
|
turn_config = source.get("turnConfig", global_turn_config)
|
|
if not isinstance(turn_config, dict):
|
|
turn_config = global_turn_config
|
|
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
|
|
),
|
|
enable_interrupt=bool(
|
|
source.get(
|
|
"enableInterrupt",
|
|
self.settings.get("enableInterrupt", True),
|
|
)
|
|
),
|
|
turn_config=dict(turn_config),
|
|
)
|
|
|
|
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.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}")
|
|
return "\n\n".join(sections)
|
|
|
|
def greeting(self, store: DynamicVariableStore) -> str:
|
|
return store.render(str(self.data(self.start_id).get("greeting") or ""))
|
|
|
|
def routing_prompt(self, node_id: str, store: DynamicVariableStore) -> str:
|
|
"""Describe the current node to the small LLM edge router."""
|
|
if self.node_type(node_id) == "agent":
|
|
return self.prompt_for(node_id, store)
|
|
data = self.data(node_id)
|
|
details = (
|
|
data.get("greeting")
|
|
or data.get("message")
|
|
or data.get("target")
|
|
or ""
|
|
)
|
|
rendered = store.render(str(details)).strip()
|
|
return f"{self.node_type(node_id) or 'workflow'} 节点:{rendered or self.name(node_id)}"
|
|
|
|
def expression_matches(self, expression: dict, values: dict[str, Any]) -> bool:
|
|
results = []
|
|
for rule in expression.get("rules") or []:
|
|
name = str(rule.get("variable") or "")
|
|
operator = str(rule.get("operator") or "")
|
|
expected = rule.get("value")
|
|
exists = name in values
|
|
actual = values.get(name)
|
|
try:
|
|
if operator == "exists":
|
|
matched = exists if expected is not False else not exists
|
|
elif not exists:
|
|
# Missing values are never equal, unequal, greater or
|
|
# contained. Use the explicit ``exists`` operator when the
|
|
# distinction between missing and present is important.
|
|
matched = False
|
|
elif operator == "eq":
|
|
matched = actual == expected
|
|
elif operator == "neq":
|
|
matched = actual != expected
|
|
elif operator == "gt":
|
|
matched = actual > expected
|
|
elif operator == "gte":
|
|
matched = actual >= expected
|
|
elif operator == "lt":
|
|
matched = actual < expected
|
|
elif operator == "lte":
|
|
matched = actual <= expected
|
|
elif operator == "contains":
|
|
matched = expected in actual
|
|
elif operator == "in":
|
|
matched = actual in expected
|
|
else:
|
|
matched = False
|
|
except (TypeError, ValueError):
|
|
matched = False
|
|
results.append(matched)
|
|
if not results:
|
|
return False
|
|
return (
|
|
all(results)
|
|
if expression.get("combinator", "and") == "and"
|
|
else any(results)
|
|
)
|
|
|
|
def deterministic_edge(
|
|
self,
|
|
node_id: str,
|
|
store: DynamicVariableStore,
|
|
*,
|
|
include_default: bool,
|
|
) -> dict | None:
|
|
default = None
|
|
for edge in self.outgoing(node_id):
|
|
data = edge.get("data") or {}
|
|
mode = data.get("mode")
|
|
if mode == "expression" and self.expression_matches(
|
|
data.get("expression") or {}, store.values
|
|
):
|
|
return edge
|
|
if mode == "always":
|
|
default = edge
|
|
return default if include_default else None
|
|
|
|
def llm_edges(self, node_id: str) -> list[dict]:
|
|
return [
|
|
edge
|
|
for edge in self.outgoing(node_id)
|
|
if self.edge_mode(edge) in {"llm", "always"}
|
|
]
|