- Update ConversationRecorder to include source and nodeId metadata in transcripts for better context tracking. - Introduce optional variable handling in DynamicVariableStore, allowing for unset variables to be rendered as empty without raising errors. - Refactor WorkflowBrain to apply turn configurations and manage interaction policies dynamically, improving agent responsiveness. - Implement tests to ensure proper handling of updated session variables and workflow metadata in various scenarios.
220 lines
8.4 KiB
Python
220 lines
8.4 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 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 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"}
|
|
]
|