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:
Xin Wang
2026-07-17 22:37:15 +08:00
parent 162a3d8bec
commit bdf3d3dd9c
23 changed files with 1374 additions and 475 deletions

View File

@@ -28,7 +28,7 @@ from services.brains.dify_llm import (
)
from services.brains.workflow_brain import WorkflowBrain
from services.runtime_variables import prepare_dynamic_config
from services.workflow_router import STAY_ON_CURRENT_NODE
from services.workflow.models import LLMRouteResult, RouteStatus
class FakeLLM:
@@ -47,6 +47,7 @@ class FakeCallEnd:
self.finished = False
self.response_started = False
self.waited_for_text: bool | None = None
self.tracked_speeches = 0
def begin(self, reason: str) -> None:
self.ending = True
@@ -58,6 +59,14 @@ class FakeCallEnd:
def arm_after_speech(self) -> None:
self.armed = True
def track_speech(self) -> None:
self.tracked_speeches += 1
async def arm_after_tracked_speech(self) -> None:
self.armed = True
if self.tracked_speeches == 0:
await self.finish()
async def finish_after_current_speech(self, *, has_text: bool) -> None:
self.waited_for_text = has_text
if has_text:
@@ -824,7 +833,10 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
async def select_edge(self, **_kwargs):
self.calls += 1
return "goto_eat"
return LLMRouteResult(
status=RouteStatus.MATCHED,
function_name="goto_eat",
)
manager = FakeManager()
router = FakeRouter()
@@ -847,6 +859,45 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
self.assertTrue(any(isinstance(frame, LLMRunFrame) for frame in queued))
self.assertIn("我想吃饭", brain._store.values["system__conversation_history"])
async def test_start_expression_condition_also_waits_for_user_turn(self):
brain = WorkflowBrain(
{
"specVersion": 3,
"settings": {},
"nodes": [
{"id": "start", "type": "start", "data": {}},
{"id": "agent", "type": "agent", "data": {}},
],
"edges": [
{
"id": "route",
"source": "start",
"target": "agent",
"data": {
"mode": "expression",
"priority": 10,
"expression": {
"combinator": "and",
"rules": [
{
"variable": "route",
"operator": "eq",
"value": "agent",
}
],
},
},
}
],
}
)
brain._store.values["route"] = "agent"
config = await brain._initial_node_config()
self.assertEqual(config["name"], "start")
self.assertEqual(brain._state.status.value, "waiting_user")
async def test_automatic_node_can_follow_llm_condition(self):
brain = WorkflowBrain(
{
@@ -896,7 +947,10 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
class FakeRouter:
async def select_edge(self, **kwargs):
self.node_name = kwargs["node_name"]
return "goto_to_agent"
return LLMRouteResult(
status=RouteStatus.MATCHED,
function_name="goto_to_agent",
)
router = FakeRouter()
brain._router = router
@@ -961,7 +1015,7 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
class FakeRouter:
def __init__(self):
self.result = STAY_ON_CURRENT_NODE
self.result = LLMRouteResult(status=RouteStatus.NO_MATCH)
self.edge_ids = []
async def select_edge(self, **kwargs):
@@ -975,7 +1029,10 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(router.edge_ids, ["llm"])
self.assertEqual(selected["id"], "expression")
router.result = "goto_llm"
router.result = LLMRouteResult(
status=RouteStatus.MATCHED,
function_name="goto_llm",
)
selected = await brain._select_edge("agent")
self.assertEqual(selected["id"], "llm")
@@ -1183,21 +1240,19 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
brain._engine.data("agent")["entryMode"] = "generate"
generate_config = brain._agent_config("agent")
self.assertTrue(generate_config["respond_immediately"])
self.assertFalse(generate_config["respond_immediately"])
worker.frames.clear()
await brain._manager.set_node_from_config(generate_config)
self.assertTrue(any(isinstance(frame, LLMRunFrame) for frame in worker.frames))
self.assertFalse(any(isinstance(frame, LLMRunFrame) for frame in worker.frames))
await brain._after_node_activated(generate_config)
self.assertTrue(any(isinstance(frame, LLMRunFrame) for frame in queued))
brain._engine.data("agent").update(
{"entryMode": "fixed_speech", "entrySpeech": "您好,{{user_name}}"}
)
fixed_config = brain._agent_config("agent")
self.assertFalse(fixed_config["respond_immediately"])
self.assertEqual(
fixed_config["pre_actions"][0]["type"],
"workflow_fixed_speech",
)
self.assertEqual(fixed_config["pre_actions"][0]["text"], "您好,王先生")
self.assertNotIn("pre_actions", fixed_config)
self.assertEqual(
fixed_config["task_messages"],
[
@@ -1216,10 +1271,10 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
{"role": "assistant", "content": "您好,王先生"},
],
)
self.assertEqual(fixed_config["pre_actions"][0]["node_id"], "agent")
worker.frames.clear()
queued.clear()
await brain._manager.set_node_from_config(fixed_config)
await brain._after_node_activated(fixed_config)
self.assertTrue(any(isinstance(frame, TTSSpeakFrame) for frame in queued))
self.assertFalse(any(isinstance(frame, LLMRunFrame) for frame in worker.frames))
context_updates = [
@@ -1263,7 +1318,10 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
class FakeRouter:
async def select_edge(self, **_kwargs):
return "goto_finish"
return LLMRouteResult(
status=RouteStatus.MATCHED,
function_name="goto_finish",
)
brain._router = FakeRouter()
handled = await brain.on_user_turn_end("我的需求已经说完了")

View File

@@ -44,6 +44,20 @@ class CallEndCoordinatorTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(self.reasons, ["tool_only"])
async def test_workflow_end_waits_for_every_queued_fixed_speech(self):
self.coordinator.track_speech()
self.coordinator.track_speech()
self.coordinator.begin("workflow_completed")
await self.coordinator.arm_after_tracked_speech()
await self.coordinator.observe(BotStartedSpeakingFrame())
await self.coordinator.observe(BotStoppedSpeakingFrame())
self.assertEqual(self.reasons, [])
await self.coordinator.observe(BotStartedSpeakingFrame())
await self.coordinator.observe(BotStoppedSpeakingFrame())
self.assertEqual(self.reasons, ["workflow_completed"])
if __name__ == "__main__":
unittest.main()

View File

@@ -5,6 +5,7 @@ from types import SimpleNamespace
from unittest.mock import patch
from models import AssistantConfig
from services.workflow.models import RouteStatus
from services.workflow_router import WorkflowLLMRouter
@@ -62,7 +63,8 @@ class WorkflowLLMRouterTest(unittest.IsolatedAsyncioTestCase):
edge_description=lambda _edge: "用户已经回答姓名",
)
self.assertEqual(selected, "goto_age")
self.assertEqual(selected.status, RouteStatus.MATCHED)
self.assertEqual(selected.function_name, "goto_age")
self.assertEqual(requests[0]["tool_choice"], "required")
self.assertEqual(
[message["role"] for message in requests[0]["messages"]],

View File

@@ -0,0 +1,80 @@
from __future__ import annotations
import unittest
from services.runtime_variables import DynamicVariableStore
from services.workflow.models import LLMRouteResult, RouteStatus
from services.workflow.routing import WorkflowEdgeEvaluator
from services.workflow_engine import WorkflowEngine
def routing_graph() -> dict:
return {
"specVersion": 3,
"settings": {},
"nodes": [
{"id": "start", "type": "start", "data": {}},
{"id": "agent", "type": "agent", "data": {"name": "Agent"}},
{"id": "matched", "type": "end", "data": {}},
{"id": "fallback", "type": "end", "data": {}},
],
"edges": [
{
"id": "condition",
"source": "agent",
"target": "matched",
"data": {
"mode": "llm",
"priority": 10,
"condition": "用户要求结束",
},
},
{
"id": "default",
"source": "agent",
"target": "fallback",
"data": {"mode": "always", "priority": 10},
},
],
}
class WorkflowEdgeEvaluatorTest(unittest.IsolatedAsyncioTestCase):
async def test_router_error_does_not_take_default_path(self):
class ErrorRouter:
async def select_edge(self, **_kwargs):
return LLMRouteResult(
status=RouteStatus.ERROR,
error="provider unavailable",
)
evaluator = WorkflowEdgeEvaluator(
WorkflowEngine(routing_graph()),
DynamicVariableStore({}),
lambda _node_id: ErrorRouter(),
)
decision = await evaluator.evaluate("agent")
self.assertEqual(decision.status, RouteStatus.ERROR)
self.assertIsNone(decision.edge)
async def test_explicit_no_match_takes_default_path(self):
class NoMatchRouter:
async def select_edge(self, **_kwargs):
return LLMRouteResult(status=RouteStatus.NO_MATCH)
evaluator = WorkflowEdgeEvaluator(
WorkflowEngine(routing_graph()),
DynamicVariableStore({}),
lambda _node_id: NoMatchRouter(),
)
decision = await evaluator.evaluate("agent")
self.assertEqual(decision.status, RouteStatus.MATCHED)
self.assertEqual(decision.edge["id"], "default")
if __name__ == "__main__":
unittest.main()

View File

@@ -211,7 +211,7 @@ class WorkflowGraphTests(unittest.TestCase):
"smart_turn",
)
def test_start_agent_and_handoff_may_have_no_outgoing_edge(self):
def test_start_agent_action_and_handoff_may_have_no_outgoing_edge(self):
terminal_graphs = [
{
"specVersion": 3,
@@ -277,13 +277,47 @@ class WorkflowGraphTests(unittest.TestCase):
}
],
}
self.assertEqual(validate_graph(action_without_exit), [])
def test_agent_cannot_have_only_one_default_path(self):
graph = {
"specVersion": 3,
"settings": {},
"nodes": [
{"id": "start", "type": "start", "data": {}},
{"id": "agent", "type": "agent", "data": {}},
{"id": "end", "type": "end", "data": {}},
],
"edges": [
{
"id": "begin",
"source": "start",
"target": "agent",
"data": {"mode": "always", "priority": 0},
},
{
"id": "leave",
"source": "agent",
"target": "end",
"data": {"mode": "always", "priority": 0},
},
],
}
self.assertTrue(
any(
"action 的出边不能少于 1" in error
for error in validate_graph(action_without_exit)
"Agent 节点 agent 不能只有默认路径" in error
for error in validate_graph(graph)
)
)
graph["edges"][1]["data"] = {
"mode": "llm",
"priority": 10,
"condition": "当前阶段已经完成",
}
self.assertEqual(validate_graph(graph), [])
def test_all_source_nodes_allow_multiple_conditional_paths(self):
expression = {
"combinator": "and",
@@ -448,6 +482,41 @@ class WorkflowGraphTests(unittest.TestCase):
self.assertNotIn("服务 王先生", custom_prompt)
self.assertIn("处理订单", custom_prompt)
def test_missing_variable_only_matches_explicit_exists_rule(self):
engine = WorkflowEngine(valid_graph())
values = {}
self.assertFalse(
engine.expression_matches(
{
"combinator": "and",
"rules": [
{
"variable": "order_status",
"operator": "neq",
"value": "paid",
}
],
},
values,
)
)
self.assertTrue(
engine.expression_matches(
{
"combinator": "and",
"rules": [
{
"variable": "order_status",
"operator": "exists",
"value": False,
}
],
},
values,
)
)
if __name__ == "__main__":
unittest.main()