feat: make workflow messages resumable
This commit is contained in:
@@ -965,7 +965,7 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertNotIn("fetch_user_image", custom_config["role_message"])
|
||||
self.assertFalse(scopes[-1]["enabled"])
|
||||
|
||||
async def test_initial_fixed_speech_starts_without_workflow_greeting(self):
|
||||
async def test_initial_message_starts_without_workflow_greeting(self):
|
||||
brain = WorkflowBrain(
|
||||
{
|
||||
"specVersion": 3,
|
||||
@@ -976,20 +976,30 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
"type": "start",
|
||||
"data": {"greeting": "欢迎使用"},
|
||||
},
|
||||
{
|
||||
"id": "message",
|
||||
"type": "message",
|
||||
"data": {
|
||||
"speech": "请问您怎么称呼?",
|
||||
"showMessage": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "agent",
|
||||
"type": "agent",
|
||||
"data": {
|
||||
"prompt": "收集用户信息",
|
||||
"entryMode": "fixed_speech",
|
||||
"entrySpeech": "请问您怎么称呼?",
|
||||
},
|
||||
"data": {"prompt": "收集用户信息"},
|
||||
},
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"id": "begin",
|
||||
"source": "start",
|
||||
"target": "message",
|
||||
"data": {"mode": "always", "priority": 0},
|
||||
},
|
||||
{
|
||||
"id": "after_message",
|
||||
"source": "message",
|
||||
"target": "agent",
|
||||
"data": {"mode": "always", "priority": 0},
|
||||
}
|
||||
@@ -1032,15 +1042,17 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
)
|
||||
await brain.on_connected()
|
||||
|
||||
self.assertEqual(brain._manager.current_node, "message")
|
||||
for _ in range(3):
|
||||
await asyncio.sleep(0)
|
||||
self.assertEqual(brain._manager.current_node, "agent")
|
||||
fixed_speech_frames = [
|
||||
message_speech_frames = [
|
||||
frame for frame in queued if isinstance(frame, TTSSpeakFrame)
|
||||
]
|
||||
self.assertEqual(len(fixed_speech_frames), 1)
|
||||
self.assertEqual(fixed_speech_frames[0].text, "请问您怎么称呼?")
|
||||
self.assertEqual(len(message_speech_frames), 1)
|
||||
self.assertEqual(message_speech_frames[0].text, "请问您怎么称呼?")
|
||||
|
||||
# Workflow no longer owns a greeting playback lifecycle. Stray generic
|
||||
# transport notifications must not repeat Agent entry behavior.
|
||||
# Stray generic greeting notifications must not replay the Message.
|
||||
await brain.on_greeting_finished()
|
||||
self.assertEqual(
|
||||
len([frame for frame in queued if isinstance(frame, TTSSpeakFrame)]),
|
||||
@@ -1533,6 +1545,175 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertTrue(result.succeeded)
|
||||
self.assertEqual(input_states, [False, True])
|
||||
|
||||
async def test_message_between_agents_resumes_after_playback(self):
|
||||
graph = {
|
||||
"specVersion": 3,
|
||||
"settings": {},
|
||||
"nodes": [
|
||||
{"id": "start", "type": "start", "data": {}},
|
||||
{
|
||||
"id": "opening",
|
||||
"type": "message",
|
||||
"data": {"speech": "欢迎使用。"},
|
||||
},
|
||||
{
|
||||
"id": "agent1",
|
||||
"type": "agent",
|
||||
"data": {"prompt": "收集基本信息"},
|
||||
},
|
||||
{
|
||||
"id": "middle",
|
||||
"type": "message",
|
||||
"data": {"speech": "现在进入信息确认。"},
|
||||
},
|
||||
{
|
||||
"id": "agent2",
|
||||
"type": "agent",
|
||||
"data": {"prompt": "确认信息", "contextPolicy": "fresh"},
|
||||
},
|
||||
{
|
||||
"id": "end",
|
||||
"type": "end",
|
||||
"data": {"scope": "session"},
|
||||
},
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"id": "start-opening",
|
||||
"source": "start",
|
||||
"target": "opening",
|
||||
"data": {"mode": "always"},
|
||||
},
|
||||
{
|
||||
"id": "opening-agent1",
|
||||
"source": "opening",
|
||||
"target": "agent1",
|
||||
"data": {"mode": "always"},
|
||||
},
|
||||
{
|
||||
"id": "agent1-middle",
|
||||
"source": "agent1",
|
||||
"target": "middle",
|
||||
"data": {
|
||||
"mode": "llm",
|
||||
"priority": 10,
|
||||
"condition": "基本信息已经收集完成",
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "middle-agent2",
|
||||
"source": "middle",
|
||||
"target": "agent2",
|
||||
"data": {"mode": "always"},
|
||||
},
|
||||
{
|
||||
"id": "agent2-end",
|
||||
"source": "agent2",
|
||||
"target": "end",
|
||||
"data": {
|
||||
"mode": "llm",
|
||||
"priority": 10,
|
||||
"condition": "用户确认可以结束通话",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
brain = WorkflowBrain(graph)
|
||||
queued = []
|
||||
input_states = []
|
||||
|
||||
class PlaybackCallEnd(FakeCallEnd):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.completions = []
|
||||
|
||||
def track_speech(self):
|
||||
completion = asyncio.get_running_loop().create_future()
|
||||
self.completions.append(completion)
|
||||
return completion
|
||||
|
||||
class FakeManager:
|
||||
def __init__(self):
|
||||
self.current_node = None
|
||||
self.configs = []
|
||||
|
||||
async def initialize(self, config):
|
||||
self.current_node = config["name"]
|
||||
self.configs.append(config)
|
||||
|
||||
async def set_node_from_config(self, config):
|
||||
self.current_node = config["name"]
|
||||
self.configs.append(config)
|
||||
|
||||
async def queue_frame(frame):
|
||||
queued.append(frame)
|
||||
|
||||
call_end = PlaybackCallEnd()
|
||||
manager = FakeManager()
|
||||
|
||||
class MatchingRouter:
|
||||
async def select_edge(self, **kwargs):
|
||||
edge = kwargs["edges"][0]
|
||||
return LLMRouteResult(
|
||||
status=RouteStatus.MATCHED,
|
||||
function_name=kwargs["edge_name"](edge),
|
||||
)
|
||||
|
||||
brain._router = MatchingRouter()
|
||||
brain._runtime = BrainRuntime(
|
||||
context=LLMContext(messages=[]),
|
||||
llm=FakeLLM(),
|
||||
queue_frame=queue_frame,
|
||||
set_system_prompt=lambda _prompt: None,
|
||||
set_tools=lambda _tools: None,
|
||||
call_end=call_end,
|
||||
set_input_enabled=input_states.append,
|
||||
)
|
||||
brain._manager = manager
|
||||
|
||||
await brain.on_connected()
|
||||
await asyncio.sleep(0)
|
||||
self.assertEqual(manager.current_node, "opening")
|
||||
self.assertEqual(len(call_end.completions), 1)
|
||||
|
||||
call_end.completions[0].set_result(None)
|
||||
for _ in range(5):
|
||||
await asyncio.sleep(0)
|
||||
if manager.current_node == "agent1":
|
||||
break
|
||||
self.assertEqual(manager.current_node, "agent1")
|
||||
|
||||
# The user-turn processor must return while the second Message is
|
||||
# still waiting for its transport playback boundary.
|
||||
await asyncio.wait_for(
|
||||
brain.on_user_turn_end("基本信息已经收集完成"),
|
||||
timeout=0.1,
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
self.assertEqual(manager.current_node, "middle")
|
||||
self.assertEqual(len(call_end.completions), 2)
|
||||
self.assertFalse(call_end.completions[1].done())
|
||||
|
||||
call_end.completions[1].set_result(None)
|
||||
for _ in range(5):
|
||||
await asyncio.sleep(0)
|
||||
if manager.current_node == "agent2":
|
||||
break
|
||||
self.assertEqual(manager.current_node, "agent2")
|
||||
self.assertTrue(any(isinstance(frame, LLMRunFrame) for frame in queued))
|
||||
self.assertEqual(
|
||||
manager.configs[-1]["task_messages"],
|
||||
[
|
||||
{"role": "user", "content": "基本信息已经收集完成"},
|
||||
{"role": "assistant", "content": "现在进入信息确认。"},
|
||||
],
|
||||
)
|
||||
|
||||
await brain.on_assistant_text_end("agent2-turn", "信息确认完成", False)
|
||||
await brain.on_user_turn_end("结束通话")
|
||||
self.assertEqual(manager.current_node, "end")
|
||||
self.assertTrue(call_end.finished)
|
||||
|
||||
async def test_nodes_without_outgoing_edges_remain_active(self):
|
||||
queued = []
|
||||
|
||||
@@ -2087,6 +2268,7 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual(context.get_messages(), [])
|
||||
await brain.on_connected()
|
||||
self.assertEqual(brain._manager.current_node, "agent")
|
||||
await brain.on_client_ready()
|
||||
variable_events = [
|
||||
frame.message
|
||||
for frame in queued
|
||||
@@ -2150,58 +2332,14 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
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.assertNotIn("pre_actions", fixed_config)
|
||||
self.assertEqual(
|
||||
fixed_config["task_messages"],
|
||||
[{"role": "assistant", "content": "您好,王先生"}],
|
||||
)
|
||||
brain._engine.data("agent")["entryMode"] = "wait_user"
|
||||
self.assertEqual(
|
||||
brain._agent_config(
|
||||
"agent",
|
||||
[{"role": "assistant", "content": "正在进入下一阶段"}],
|
||||
)["task_messages"],
|
||||
[
|
||||
{"role": "assistant", "content": "正在进入下一阶段"},
|
||||
{"role": "assistant", "content": "您好,王先生"},
|
||||
],
|
||||
[{"role": "assistant", "content": "正在进入下一阶段"}],
|
||||
)
|
||||
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 = [
|
||||
frame
|
||||
for frame in worker.frames
|
||||
if isinstance(frame, LLMMessagesUpdateFrame)
|
||||
]
|
||||
self.assertEqual(
|
||||
context_updates[-1].messages,
|
||||
[{"role": "assistant", "content": "您好,王先生"}],
|
||||
)
|
||||
self.assertFalse(
|
||||
any(
|
||||
isinstance(frame, OutputTransportMessageUrgentFrame)
|
||||
and frame.message.get("source") == "workflow-fixed-reply"
|
||||
for frame in queued
|
||||
)
|
||||
)
|
||||
await brain.on_client_ready()
|
||||
fixed_reply_events = [
|
||||
frame.message
|
||||
for frame in queued
|
||||
if isinstance(frame, OutputTransportMessageUrgentFrame)
|
||||
and frame.message.get("source") == "workflow-fixed-reply"
|
||||
]
|
||||
self.assertEqual(fixed_reply_events[0]["content"], "您好,王先生")
|
||||
self.assertEqual(fixed_reply_events[0]["nodeId"], "agent")
|
||||
self.assertIn("您好,王先生", brain._store.values["system__conversation_history"])
|
||||
|
||||
self.assertFalse(
|
||||
any(
|
||||
@@ -2252,7 +2390,7 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
]
|
||||
self.assertEqual(
|
||||
assistant_transcripts,
|
||||
["您好,王先生", "正在为你结束流程", "感谢来电"],
|
||||
["正在为你结束流程", "感谢来电"],
|
||||
)
|
||||
self.assertIn(
|
||||
"正在为你结束流程",
|
||||
|
||||
Reference in New Issue
Block a user