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:
@@ -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("我的需求已经说完了")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"]],
|
||||
|
||||
80
backend/tests/test_workflow_routing.py
Normal file
80
backend/tests/test_workflow_routing.py
Normal 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()
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user