Enhance conversation history and runtime variable management
- 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.
This commit is contained in:
@@ -9,6 +9,7 @@ from pipecat.frames.frames import (
|
||||
LLMContextFrame,
|
||||
LLMFullResponseEndFrame,
|
||||
LLMFullResponseStartFrame,
|
||||
LLMMessagesUpdateFrame,
|
||||
LLMRunFrame,
|
||||
LLMTextFrame,
|
||||
OutputTransportMessageUrgentFrame,
|
||||
@@ -391,6 +392,78 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
|
||||
class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_action_publishes_updated_session_variables(self):
|
||||
tool = RuntimeTool(
|
||||
id="lookup",
|
||||
name="查询订单",
|
||||
function_name="lookup_order",
|
||||
type="http",
|
||||
)
|
||||
cfg = prepare_dynamic_config(
|
||||
AssistantConfig(
|
||||
type="workflow",
|
||||
graph={
|
||||
"specVersion": 3,
|
||||
"settings": {},
|
||||
"nodes": [
|
||||
{"id": "start", "type": "start", "data": {}},
|
||||
{
|
||||
"id": "lookup_action",
|
||||
"type": "action",
|
||||
"data": {
|
||||
"toolId": "lookup",
|
||||
"resultAssignments": {
|
||||
"order_status": "order.status"
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
"edges": [],
|
||||
},
|
||||
dynamic_variable_definitions={
|
||||
"order_status": {"type": "string", "default": "pending"}
|
||||
},
|
||||
tools=[tool],
|
||||
),
|
||||
{},
|
||||
assistant_id="asst_workflow_action",
|
||||
)
|
||||
brain = WorkflowBrain(cfg)
|
||||
queued = []
|
||||
|
||||
async def queue_frame(frame):
|
||||
queued.append(frame)
|
||||
|
||||
async def execute(_tool, _arguments, *, result_assignments=None):
|
||||
self.assertEqual(result_assignments, {"order_status": "order.status"})
|
||||
brain._store.assign("order_status", "paid")
|
||||
return {
|
||||
"status": "ok",
|
||||
"updated_variables": ["order_status"],
|
||||
}
|
||||
|
||||
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=FakeCallEnd(),
|
||||
)
|
||||
brain._tools.execute = execute
|
||||
|
||||
await brain._enter_action("lookup_action")
|
||||
|
||||
variable_events = [
|
||||
frame.message
|
||||
for frame in queued
|
||||
if isinstance(frame, OutputTransportMessageUrgentFrame)
|
||||
and frame.message.get("type") == "workflow-variables"
|
||||
]
|
||||
self.assertEqual(variable_events[-1]["reason"], "action")
|
||||
self.assertEqual(variable_events[-1]["changed"], ["order_status"])
|
||||
self.assertEqual(variable_events[-1]["variables"], {"order_status": "paid"})
|
||||
|
||||
async def test_nodes_without_outgoing_edges_remain_active(self):
|
||||
queued = []
|
||||
|
||||
@@ -492,6 +565,11 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
"defaultTtsResourceId": "tts_global",
|
||||
"knowledgeBaseId": "kb_global",
|
||||
"knowledgeMode": "automatic",
|
||||
"enableInterrupt": False,
|
||||
"turnConfig": {
|
||||
"bargeIn": {"strategy": "transcription"},
|
||||
"vad": {"confidence": 0.55},
|
||||
},
|
||||
},
|
||||
"nodes": [
|
||||
{
|
||||
@@ -551,6 +629,7 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
queued = []
|
||||
service_switches = []
|
||||
knowledge_scopes = []
|
||||
turn_configs = []
|
||||
call_end = FakeCallEnd()
|
||||
|
||||
class FakeWorker:
|
||||
@@ -585,6 +664,9 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def switch_services(llm_id, asr_id, tts_id):
|
||||
service_switches.append((llm_id, asr_id, tts_id))
|
||||
|
||||
async def apply_turn_config(enable_interrupt, turn_config):
|
||||
turn_configs.append((enable_interrupt, turn_config))
|
||||
|
||||
runtime = BrainRuntime(
|
||||
context=context,
|
||||
llm=llm,
|
||||
@@ -596,15 +678,27 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
context_aggregator=pair,
|
||||
switch_services=switch_services,
|
||||
set_knowledge_scope=knowledge_scopes.append,
|
||||
apply_turn_config=apply_turn_config,
|
||||
)
|
||||
await brain.setup(cfg, runtime)
|
||||
await brain.on_connected()
|
||||
self.assertEqual(brain._manager.current_node, "agent")
|
||||
variable_events = [
|
||||
frame.message
|
||||
for frame in queued
|
||||
if isinstance(frame, OutputTransportMessageUrgentFrame)
|
||||
and frame.message.get("type") == "workflow-variables"
|
||||
]
|
||||
self.assertEqual(variable_events[0]["reason"], "initialized")
|
||||
self.assertEqual(variable_events[0]["variables"], {"user_name": "王先生"})
|
||||
self.assertNotIn("system__conversation_id", variable_events[0]["variables"])
|
||||
self.assertEqual(
|
||||
service_switches,
|
||||
[("llm_global", "asr_global", "tts_global")],
|
||||
)
|
||||
self.assertEqual(knowledge_scopes[-1]["knowledge_base_id"], "kb_global")
|
||||
self.assertEqual(turn_configs[-1][0], False)
|
||||
self.assertEqual(turn_configs[-1][1]["vad"]["confidence"], 0.55)
|
||||
|
||||
brain._engine.data("agent").update(
|
||||
{
|
||||
@@ -614,6 +708,11 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
"ttsResourceId": "tts_agent",
|
||||
"knowledgeBaseId": "kb_agent",
|
||||
"knowledgeMode": "on_demand",
|
||||
"enableInterrupt": True,
|
||||
"turnConfig": {
|
||||
"bargeIn": {"strategy": "vad"},
|
||||
"turnDetection": {"strategy": "smart_turn"},
|
||||
},
|
||||
}
|
||||
)
|
||||
await brain._apply_agent_stage("agent")
|
||||
@@ -622,6 +721,11 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
("llm_agent", "asr_agent", "tts_agent"),
|
||||
)
|
||||
self.assertEqual(knowledge_scopes[-1]["knowledge_base_id"], "kb_agent")
|
||||
self.assertEqual(turn_configs[-1][0], True)
|
||||
self.assertEqual(
|
||||
turn_configs[-1][1]["turnDetection"]["strategy"],
|
||||
"smart_turn",
|
||||
)
|
||||
agent_config = brain._agent_config("agent")
|
||||
self.assertIn("王先生", agent_config["role_message"])
|
||||
self.assertIn("工作流路由已在用户一轮输入结束时完成", agent_config["role_message"])
|
||||
@@ -654,11 +758,30 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
fixed_config["task_messages"],
|
||||
[{"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)
|
||||
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": "您好,王先生"}],
|
||||
)
|
||||
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(
|
||||
|
||||
37
backend/tests/test_conversation_history.py
Normal file
37
backend/tests/test_conversation_history.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from services.conversation_history import ConversationRecorder
|
||||
|
||||
|
||||
class ConversationRecorderTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_fixed_reply_transcript_keeps_workflow_metadata(self):
|
||||
recorder = ConversationRecorder("conv_test")
|
||||
recorder._append = AsyncMock()
|
||||
|
||||
await recorder.record_transport_message(
|
||||
{
|
||||
"type": "transcript",
|
||||
"role": "assistant",
|
||||
"content": "请稍等,我正在处理。",
|
||||
"timestamp": "2026-07-14T10:00:00+08:00",
|
||||
"source": "workflow-fixed-reply",
|
||||
"nodeId": "agent_service",
|
||||
}
|
||||
)
|
||||
|
||||
recorder._append.assert_awaited_once_with(
|
||||
"assistant",
|
||||
"请稍等,我正在处理。",
|
||||
"2026-07-14T10:00:00+08:00",
|
||||
{
|
||||
"source": "workflow-fixed-reply",
|
||||
"node_id": "agent_service",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -79,6 +79,58 @@ class DynamicVariableTests(unittest.TestCase):
|
||||
"Bearer private",
|
||||
)
|
||||
|
||||
def test_optional_workflow_variable_renders_empty_but_remains_unset(self):
|
||||
cfg = AssistantConfig(
|
||||
type="workflow",
|
||||
graph={
|
||||
"specVersion": 3,
|
||||
"settings": {"globalPrompt": "称呼 {{nickname}}"},
|
||||
"nodes": [
|
||||
{
|
||||
"id": "start",
|
||||
"type": "start",
|
||||
"data": {"greeting": "您好 {{nickname}}"},
|
||||
}
|
||||
],
|
||||
"edges": [],
|
||||
},
|
||||
greeting="",
|
||||
dynamic_variable_definitions={
|
||||
"nickname": {
|
||||
"type": "string",
|
||||
"required": False,
|
||||
"default": None,
|
||||
}
|
||||
},
|
||||
)
|
||||
prepared = prepare_dynamic_config(cfg, {}, assistant_id="asst_workflow")
|
||||
store = DynamicVariableStore.from_config(prepared)
|
||||
|
||||
self.assertEqual(store.render("您好 {{nickname}}"), "您好 ")
|
||||
self.assertNotIn("nickname", store.values)
|
||||
with self.assertRaisesRegex(DynamicVariableError, "缺少动态变量"):
|
||||
store.render("{{not_defined}}")
|
||||
|
||||
def test_exact_placeholder_keeps_action_argument_type(self):
|
||||
store = DynamicVariableStore(
|
||||
{"count": 3, "confirmed": True},
|
||||
variable_types={"count": "number", "confirmed": "boolean"},
|
||||
)
|
||||
|
||||
rendered = store.render_data(
|
||||
{
|
||||
"count": "{{count}}",
|
||||
"confirmed": "{{confirmed}}",
|
||||
"label": "数量 {{count}}",
|
||||
}
|
||||
)
|
||||
self.assertEqual(rendered["count"], 3)
|
||||
self.assertIs(rendered["confirmed"], True)
|
||||
self.assertEqual(rendered["label"], "数量 3")
|
||||
|
||||
with self.assertRaisesRegex(DynamicVariableError, "类型应为 number"):
|
||||
store.assign("count", "four")
|
||||
|
||||
def test_tool_assignment_and_system_history(self):
|
||||
store = DynamicVariableStore(
|
||||
{
|
||||
|
||||
@@ -170,6 +170,11 @@ class WorkflowGraphTests(unittest.TestCase):
|
||||
"knowledgeMode": "on_demand",
|
||||
"knowledgeTopN": 8,
|
||||
"knowledgeScoreThreshold": 0.4,
|
||||
"enableInterrupt": False,
|
||||
"turnConfig": {
|
||||
"bargeIn": {"strategy": "transcription"},
|
||||
"turnDetection": {"strategy": "silence", "silenceTimeoutSecs": 1.2},
|
||||
},
|
||||
}
|
||||
)
|
||||
engine = WorkflowEngine(graph)
|
||||
@@ -177,6 +182,11 @@ class WorkflowGraphTests(unittest.TestCase):
|
||||
self.assertEqual(inherited.llm_resource_id, "llm_global")
|
||||
self.assertEqual(inherited.tool_ids, ("tool_global",))
|
||||
self.assertEqual(inherited.knowledge_mode, "on_demand")
|
||||
self.assertFalse(inherited.enable_interrupt)
|
||||
self.assertEqual(
|
||||
inherited.turn_config["bargeIn"]["strategy"],
|
||||
"transcription",
|
||||
)
|
||||
|
||||
engine.data("agent").update(
|
||||
{
|
||||
@@ -184,12 +194,22 @@ class WorkflowGraphTests(unittest.TestCase):
|
||||
"llmResourceId": "llm_agent",
|
||||
"toolIds": ["tool_agent"],
|
||||
"knowledgeBaseId": "",
|
||||
"enableInterrupt": True,
|
||||
"turnConfig": {
|
||||
"bargeIn": {"strategy": "vad"},
|
||||
"turnDetection": {"strategy": "smart_turn"},
|
||||
},
|
||||
}
|
||||
)
|
||||
custom = engine.agent_stage_config("agent")
|
||||
self.assertEqual(custom.llm_resource_id, "llm_agent")
|
||||
self.assertEqual(custom.tool_ids, ("tool_agent",))
|
||||
self.assertEqual(custom.knowledge_mode, "disabled")
|
||||
self.assertTrue(custom.enable_interrupt)
|
||||
self.assertEqual(
|
||||
custom.turn_config["turnDetection"]["strategy"],
|
||||
"smart_turn",
|
||||
)
|
||||
|
||||
def test_start_agent_and_handoff_may_have_no_outgoing_edge(self):
|
||||
terminal_graphs = [
|
||||
|
||||
Reference in New Issue
Block a user