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

@@ -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}