Files
ai-video-fullstack/backend/tests/test_brains.py
T

3349 lines
115 KiB
Python

from __future__ import annotations
import asyncio
import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from models import AssistantConfig, RuntimeTool
from pipecat.flows import FlowManager
from pipecat.frames.frames import (
LLMContextFrame,
LLMFullResponseEndFrame,
LLMFullResponseStartFrame,
LLMMessagesAppendFrame,
LLMMessagesUpdateFrame,
LLMRunFrame,
LLMTextFrame,
LLMUpdateSettingsFrame,
OutputTransportMessageUrgentFrame,
TTSSpeakFrame,
)
from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.processors.frame_processor import FrameDirection
from schemas import AssistantUpsert, REALTIME_CAPABLE_TYPES, SystemToolConfig
from services.brains import BrainRuntime, SPECS, build_brain
from services.brains.base import GREETING_CONTEXT_MARKER
from services.brains.dify_llm import (
DifyLLMService,
last_user_text,
normalize_api_base,
)
from services.brains.workflow_brain import ConfiguredFlowManager, WorkflowBrain
from services.fixed_speech import FIXED_SPEECH_CONTEXT_MARKER
from services.runtime_variables import prepare_dynamic_config
from services.action_runtime import ActionOutcome, ActionStatus
from services.workflow.models import (
LLMRouteResult,
RouteStatus,
WorkflowStatus,
)
class FakeLLM:
def __init__(self):
self.functions = {}
self.function_options = {}
def register_function(self, name, handler, **options):
self.functions[name] = handler
self.function_options[name] = options
class FakeCallEnd:
def __init__(self):
self.ending = False
self.reason = ""
self.armed = False
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
self.reason = reason
def begin_response(self) -> None:
self.response_started = True
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:
self.armed = True
else:
await self.finish()
async def finish(self) -> None:
self.finished = True
class FakeFunctionParams:
def __init__(self, arguments=None):
self.arguments = arguments or {}
self.result = None
self.properties = None
async def result_callback(self, result, properties=None):
self.result = result
self.properties = properties
async def noop_queue_frame(_frame):
return None
def system_runtime_tool(kind: str) -> RuntimeTool:
return RuntimeTool(
id=f"tool_{kind}",
name=kind,
function_name=kind,
type="system",
description=f"{kind} system tool",
definition={"type": "system", "config": {"kind": kind}},
)
class BrainRegistryTests(unittest.TestCase):
def test_capability_matrix(self):
self.assertEqual(
{
name: spec.supported_runtime_modes
for name, spec in SPECS.items()
},
{
"prompt": frozenset({"pipeline", "realtime"}),
"workflow": frozenset({"pipeline"}),
"dify": frozenset({"pipeline"}),
"fastgpt": frozenset({"pipeline"}),
},
)
self.assertEqual(
REALTIME_CAPABLE_TYPES,
{
name
for name, spec in SPECS.items()
if "realtime" in spec.supported_runtime_modes
},
)
def test_unknown_brain_does_not_fallback_to_prompt(self):
with self.assertRaisesRegex(ValueError, "尚未实现"):
build_brain(AssistantConfig(type="opencode"))
def test_workflow_realtime_is_rejected_at_schema_boundary(self):
with self.assertRaises(ValueError):
AssistantUpsert(
name="workflow",
type="workflow",
runtimeMode="realtime",
)
def test_prompt_realtime_keeps_dynamic_variable_definitions(self):
assistant = AssistantUpsert(
name="realtime prompt",
type="prompt",
runtimeMode="realtime",
dynamicVariableDefinitions={
"user_name": {
"type": "string",
"required": True,
"default": None,
}
},
)
self.assertIn("user_name", assistant.dynamic_variable_definitions)
def test_system_tool_config_accepts_all_platform_actions(self):
for kind in (
"end_conversation",
"update_state",
"skip_turn",
"request_human_handoff",
):
self.assertEqual(SystemToolConfig(kind=kind).kind, kind)
def test_system_tool_config_rejects_unknown_kind(self):
with self.assertRaises(ValueError):
SystemToolConfig(kind="magic")
def test_workflow_keeps_dynamic_variables_and_tool_bindings(self):
assistant = AssistantUpsert(
name="workflow",
type="workflow",
toolIds=["tool_a"],
dynamicVariableDefinitions={
"customer": {"type": "string", "required": False, "default": "王先生"}
},
graph={},
)
self.assertEqual(assistant.tool_ids, ["tool_a"])
self.assertIn("customer", assistant.dynamic_variable_definitions)
class DifyHelpersTests(unittest.TestCase):
def test_normalize_api_base(self):
self.assertEqual(
normalize_api_base("https://api.dify.ai"),
"https://api.dify.ai/v1",
)
self.assertEqual(
normalize_api_base("https://example.test/v1/chat-messages"),
"https://example.test/v1",
)
def test_last_user_text(self):
self.assertEqual(
last_user_text(
[
{"role": "user", "content": "first"},
{"role": "assistant", "content": "answer"},
{
"role": "user",
"content": [{"type": "text", "text": "latest"}],
},
]
),
"latest",
)
class DifyLLMServiceTests(unittest.IsolatedAsyncioTestCase):
async def test_streams_sdk_events_and_keeps_conversation_id(self):
class FakeDifyClient:
requests = []
async def achat_messages(self, request, **_kwargs):
self.requests.append(request)
async def events():
yield SimpleNamespace(
event="message",
answer="你好",
conversation_id="conversation-1",
)
yield SimpleNamespace(
event="message_end",
conversation_id="conversation-1",
)
return events()
client = FakeDifyClient()
service = DifyLLMService(
AssistantConfig(type="dify"),
client=client,
user_id="test-user",
)
frames = []
async def push_frame(frame, *_args, **_kwargs):
frames.append(frame)
service.push_frame = push_frame
context = LLMContext(messages=[{"role": "user", "content": "问题"}])
await service.process_frame(
LLMContextFrame(context),
FrameDirection.DOWNSTREAM,
)
self.assertIsInstance(frames[0], LLMFullResponseStartFrame)
self.assertIsInstance(frames[1], LLMTextFrame)
self.assertEqual(frames[1].text, "你好")
self.assertIsInstance(frames[-1], LLMFullResponseEndFrame)
self.assertEqual(service._conversation_id, "conversation-1")
context.add_message({"role": "user", "content": "追问"})
await service.process_frame(
LLMContextFrame(context),
FrameDirection.DOWNSTREAM,
)
self.assertEqual(client.requests[-1].conversation_id, "conversation-1")
class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
async def test_startup_only_tool_is_not_registered_with_llm(self):
startup_tool = RuntimeTool(
id="opening_message",
name="开场确认",
function_name="show_message",
type="client",
)
conversation_tool = RuntimeTool(
id="lookup_order",
name="查询订单",
function_name="lookup_order",
type="http",
)
cfg = AssistantConfig(
type="prompt",
tools=[startup_tool, conversation_tool],
llm_tool_ids=[conversation_tool.id],
startup={
"actions": [
{
"id": "opening_message",
"phase": "opening",
"tool_id": startup_tool.id,
"required": True,
}
]
},
)
brain = build_brain(cfg)
llm = FakeLLM()
visible_schemas = []
await brain.setup(
cfg,
BrainRuntime(
context=LLMContext(messages=[]),
llm=llm,
queue_frame=noop_queue_frame,
set_system_prompt=lambda _prompt: None,
set_tools=visible_schemas.extend,
call_end=FakeCallEnd(),
),
)
self.assertIn(startup_tool.id, brain._tool_by_id)
self.assertNotIn(startup_tool.function_name, llm.functions)
self.assertIn(conversation_tool.function_name, llm.functions)
self.assertEqual(len(visible_schemas), 1)
async def test_preflight_runs_multiple_server_tools_in_order(self):
tools = [
RuntimeTool(
id=f"preflight_{index}",
name=f"预检动作 {index}",
function_name=f"preflight_{index}",
type="http",
)
for index in (1, 2)
]
cfg = AssistantConfig(
type="prompt",
tools=tools,
startup={
"actions": [
{
"id": tool.id,
"phase": "preflight",
"tool_id": tool.id,
"required": True,
}
for tool in tools
]
},
)
brain = build_brain(cfg)
await brain.setup(
cfg,
BrainRuntime(
context=LLMContext(messages=[]),
llm=FakeLLM(),
queue_frame=noop_queue_frame,
set_system_prompt=lambda _prompt: None,
set_tools=lambda _tools: None,
call_end=FakeCallEnd(),
),
)
brain._actions.execute = AsyncMock(
side_effect=[
ActionOutcome(
invocation_id=f"act_{index}",
status=ActionStatus.SUCCESS,
duration_ms=index,
)
for index in (1, 2)
]
)
await brain.run_preflight()
self.assertEqual(
[call.args[0].id for call in brain._actions.execute.await_args_list],
["preflight_1", "preflight_2"],
)
async def test_opening_stage_triggers_reply_after_confirmation(self):
tool = RuntimeTool(
id="opening_data",
name="加载开场数据",
function_name="load_opening_data",
type="http",
)
cfg = AssistantConfig(
type="prompt",
greeting="请阅读并确认重要信息",
tools=[tool],
startup={
"execution_mode": "sequential",
"opening_mode": "confirmation",
"entry_mode": "generate",
"opening_message": {
"title": "重要提示",
"message": "请确认已阅读。",
"confirm_label": "确认",
},
"actions": [
{
"id": "opening_data",
"phase": "opening",
"tool_id": "opening_data",
"arguments": {},
"required": True,
}
],
},
)
brain = build_brain(cfg)
input_states = []
queued = []
async def queue_frame(frame):
queued.append(frame)
confirmation_started = asyncio.Event()
user_confirmed = asyncio.Event()
client_calls = []
class FakeClientTools:
async def call(self, function_name, arguments, **options):
client_calls.append((function_name, arguments, options))
confirmation_started.set()
await user_confirmed.wait()
return {"status": "ok", "data": {"action": "confirmed"}}
await brain.setup(
cfg,
BrainRuntime(
context=LLMContext(messages=[]),
llm=FakeLLM(),
queue_frame=queue_frame,
set_system_prompt=lambda _prompt: None,
set_tools=lambda _tools: None,
call_end=FakeCallEnd(),
client_tools=FakeClientTools(),
set_input_enabled=input_states.append,
),
)
called_tool_ids = []
async def execute(tool, *_args, **_kwargs):
called_tool_ids.append(tool.id)
return ActionOutcome(
invocation_id=f"act_{len(called_tool_ids)}",
status=ActionStatus.SUCCESS,
duration_ms=len(called_tool_ids),
)
brain._actions.execute = AsyncMock(side_effect=execute)
self.assertEqual(await brain.greeting(cfg), "")
await brain.on_connected(greeting_pending=False)
opening_task = asyncio.create_task(brain.on_client_ready())
await confirmation_started.wait()
self.assertEqual(input_states, [False])
self.assertEqual(called_tool_ids, [])
self.assertEqual(client_calls[0][0], "show_message")
self.assertFalse(client_calls[0][1]["dismissible"])
self.assertTrue(client_calls[0][2]["interrupt_on_result"])
self.assertTrue(
any(
isinstance(frame, TTSSpeakFrame)
and frame.text == "请阅读并确认重要信息"
for frame in queued
)
)
self.assertTrue(
any(
isinstance(frame, OutputTransportMessageUrgentFrame)
and frame.message.get("type") == "transcript"
and frame.message.get("content") == "请阅读并确认重要信息"
for frame in queued
)
)
user_confirmed.set()
await opening_task
self.assertEqual(input_states, [False, True])
self.assertEqual(called_tool_ids, ["opening_data"])
self.assertEqual(
sum(isinstance(frame, LLMRunFrame) for frame in queued),
1,
)
self.assertEqual(
len(
[
frame
for frame in queued
if isinstance(frame, OutputTransportMessageUrgentFrame)
and frame.message.get("type") == "startup-action-result"
]
),
1,
)
# Replayed client-ready must not execute startup actions twice.
await brain.on_client_ready()
self.assertEqual(brain._actions.execute.await_count, 1)
self.assertEqual(
sum(isinstance(frame, LLMRunFrame) for frame in queued),
1,
)
async def test_opening_confirmation_can_wait_for_user_afterwards(self):
cfg = AssistantConfig(
type="prompt",
greeting="请先确认",
startup={
"opening_mode": "confirmation",
"entry_mode": "wait_user",
"opening_message": {
"title": "重要提示",
"message": "请确认已阅读。",
},
},
)
brain = build_brain(cfg)
queued = []
input_states = []
async def queue_frame(frame):
queued.append(frame)
class FakeClientTools:
async def call(self, *_args, **_kwargs):
return {"status": "ok", "data": {"action": "confirmed"}}
await brain.setup(
cfg,
BrainRuntime(
context=LLMContext(messages=[]),
llm=FakeLLM(),
queue_frame=queue_frame,
set_system_prompt=lambda _prompt: None,
set_tools=lambda _tools: None,
call_end=FakeCallEnd(),
client_tools=FakeClientTools(),
set_input_enabled=input_states.append,
),
)
await brain.on_connected(greeting_pending=False)
await brain.on_client_ready()
self.assertEqual(input_states, [False, True])
self.assertFalse(any(isinstance(frame, LLMRunFrame) for frame in queued))
async def test_prompt_generate_entry_waits_for_shared_greeting_playback(self):
cfg = AssistantConfig(
type="prompt",
greeting="固定开场白",
startup={
"opening_mode": "playback",
"entry_mode": "generate",
},
)
brain = build_brain(cfg)
queued = []
input_states = []
async def queue_frame(frame):
queued.append(frame)
await brain.setup(
cfg,
BrainRuntime(
context=LLMContext(messages=[]),
llm=FakeLLM(),
queue_frame=queue_frame,
set_system_prompt=lambda _prompt: None,
set_tools=lambda _tools: None,
call_end=FakeCallEnd(),
set_input_enabled=input_states.append,
),
)
await brain.on_connected(greeting_pending=True)
await brain.on_client_ready()
self.assertEqual(input_states, [False])
self.assertFalse(any(isinstance(frame, LLMRunFrame) for frame in queued))
await brain.on_greeting_finished()
self.assertEqual(input_states, [False, True])
self.assertEqual(
sum(isinstance(frame, LLMRunFrame) for frame in queued),
1,
)
async def test_interruptible_opening_user_input_suppresses_entry_reply(self):
cfg = AssistantConfig(
type="prompt",
greeting="可以打断的开场白",
startup={
"opening_mode": "interruptible",
"entry_mode": "generate",
},
)
brain = build_brain(cfg)
queued = []
input_states = []
async def queue_frame(frame):
queued.append(frame)
await brain.setup(
cfg,
BrainRuntime(
context=LLMContext(messages=[]),
llm=FakeLLM(),
queue_frame=queue_frame,
set_system_prompt=lambda _prompt: None,
set_tools=lambda _tools: None,
call_end=FakeCallEnd(),
set_input_enabled=input_states.append,
),
)
await brain.on_connected(greeting_pending=True)
self.assertEqual(input_states, [])
await brain.on_interruption_processed()
await brain.on_greeting_finished()
self.assertFalse(any(isinstance(frame, LLMRunFrame) for frame in queued))
async def test_interruptible_opening_natural_finish_runs_entry_behavior(self):
cfg = AssistantConfig(
type="prompt",
greeting="可以打断的开场白",
startup={
"opening_mode": "interruptible",
"entry_mode": "generate",
},
)
brain = build_brain(cfg)
queued = []
input_states = []
async def queue_frame(frame):
queued.append(frame)
await brain.setup(
cfg,
BrainRuntime(
context=LLMContext(messages=[]),
llm=FakeLLM(),
queue_frame=queue_frame,
set_system_prompt=lambda _prompt: None,
set_tools=lambda _tools: None,
call_end=FakeCallEnd(),
set_input_enabled=input_states.append,
),
)
await brain.on_connected(greeting_pending=True)
await brain.on_greeting_finished()
self.assertEqual(input_states, [])
self.assertEqual(
sum(isinstance(frame, LLMRunFrame) for frame in queued),
1,
)
async def test_required_opening_failure_keeps_input_blocked_and_ends_call(self):
cfg = AssistantConfig(
type="prompt",
startup={
"opening_message": {
"title": "重要提示",
"message": "请确认已阅读。",
"confirm_label": "确认",
}
},
)
brain = build_brain(cfg)
call_end = FakeCallEnd()
input_states = []
async def queue_frame(_frame):
pass
class FailingClientTools:
async def call(self, *_args, **_kwargs):
return {"status": "error", "message": "客户端未显示消息"}
await brain.setup(
cfg,
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,
client_tools=FailingClientTools(),
set_input_enabled=input_states.append,
),
)
await brain.on_connected(greeting_pending=False)
await brain.on_client_ready()
self.assertEqual(input_states, [False])
self.assertTrue(call_end.ending)
self.assertTrue(call_end.finished)
self.assertEqual(call_end.reason, "startup_action_failed")
async def test_realtime_prompt_brain_renders_dynamic_variables(self):
cfg = prepare_dynamic_config(
AssistantConfig(
type="prompt",
runtimeMode="realtime",
prompt="服务用户 {{user_name}}",
greeting="您好,{{user_name}}",
dynamic_variable_definitions={
"user_name": {"type": "string", "required": True}
},
),
{"user_name": "王先生"},
assistant_id="asst_realtime",
)
brain = build_brain(cfg)
self.assertEqual(brain.system_prompt(cfg), "服务用户 王先生")
self.assertEqual(await brain.greeting(cfg), "您好,王先生")
context = LLMContext(
messages=[{"role": "system", "content": brain.system_prompt(cfg)}]
)
brain.prepare_greeting_context("您好,王先生", context)
self.assertEqual(
[message["role"] for message in context.get_messages()],
["system", "system"],
)
self.assertNotEqual(context.get_messages()[0]["role"], "assistant")
self.assertEqual(
context.get_messages()[1]["content"],
f"{GREETING_CONTEXT_MARKER}\n您好,王先生",
)
async def test_session_update_refreshes_prompt_without_running_llm(self):
cfg = prepare_dynamic_config(
AssistantConfig(
type="prompt",
runtimeMode="pipeline",
prompt="面板状态:{{panel_open}}",
dynamic_variable_definitions={
"panel_open": {"type": "boolean", "default": False}
},
),
{},
assistant_id="asst_session_update",
)
brain = build_brain(cfg)
prompts = []
queued_frames = []
async def queue_frame(frame):
queued_frames.append(frame)
await brain.setup(
cfg,
BrainRuntime(
context=LLMContext(
messages=[{"role": "system", "content": brain.system_prompt(cfg)}]
),
llm=FakeLLM(),
queue_frame=queue_frame,
set_system_prompt=prompts.append,
set_tools=lambda _tools: None,
call_end=FakeCallEnd(),
),
)
result = await brain.on_session_update({"panel_open": True})
self.assertEqual(result.changed, ["panel_open"])
self.assertEqual(result.dynamic_variables, {"panel_open": True})
self.assertEqual(prompts, ["面板状态:true"])
self.assertEqual(queued_frames, [])
async def test_end_conversation_system_tool_is_owned_by_prompt_brain(self):
brain = build_brain(
AssistantConfig(
type="prompt",
tools=[
RuntimeTool(
id="end-call",
name="结束通话",
function_name="end_call",
type="system",
definition={
"config": {
"kind": "end_conversation",
"message_type": "none",
"capture_reason": True,
}
},
)
],
)
)
llm = FakeLLM()
call_end = FakeCallEnd()
visible_tools = []
async def queue_frame(_frame):
pass
await brain.setup(
AssistantConfig(
type="prompt",
tools=[
RuntimeTool(
id="end-call",
name="结束通话",
function_name="end_call",
type="system",
definition={
"config": {
"kind": "end_conversation",
"capture_reason": True,
}
},
)
],
),
BrainRuntime(
context=LLMContext(messages=[]),
llm=llm,
queue_frame=queue_frame,
set_system_prompt=lambda _prompt: None,
set_tools=lambda tools: visible_tools.extend(tools or []),
call_end=call_end,
),
)
self.assertEqual(visible_tools[0].name, "end_call")
params = FakeFunctionParams({"reason": "用户已完成咨询"})
await brain.on_assistant_text_start("closing-turn")
await llm.functions["end_call"](params)
self.assertEqual(call_end.reason, "用户已完成咨询")
self.assertFalse(call_end.finished)
await brain.on_assistant_text_end("closing-turn", "", False)
self.assertTrue(call_end.finished)
self.assertFalse(call_end.waited_for_text)
self.assertTrue(call_end.response_started)
self.assertEqual(params.result["action"], "ending_call")
async def test_system_end_conversation_waits_for_generated_closing_speech(self):
tool = RuntimeTool(
id="end-call",
name="结束通话",
function_name="end_call",
type="system",
definition={
"config": {
"kind": "end_conversation",
"message_type": "none",
}
},
)
cfg = AssistantConfig(type="prompt", tools=[tool])
brain = build_brain(cfg)
llm = FakeLLM()
call_end = FakeCallEnd()
await brain.setup(
cfg,
BrainRuntime(
context=LLMContext(messages=[]),
llm=llm,
queue_frame=lambda _frame: None,
set_system_prompt=lambda _prompt: None,
set_tools=lambda _tools: None,
call_end=call_end,
),
)
await brain.on_assistant_text_start("closing-turn")
await llm.functions["end_call"](FakeFunctionParams({}))
await brain.on_assistant_text_end(
"closing-turn",
"感谢您的来电。祝您生活愉快,再见。",
False,
)
self.assertFalse(call_end.finished)
self.assertTrue(call_end.armed)
self.assertTrue(call_end.waited_for_text)
async def test_system_tools_register_with_fixed_names(self):
cfg = AssistantConfig(
type="prompt",
dynamic_variable_definitions={
"user_name": {
"type": "string",
"required": False,
"default": None,
}
},
tools=[
system_runtime_tool("end_conversation"),
system_runtime_tool("update_state"),
system_runtime_tool("skip_turn"),
system_runtime_tool("request_human_handoff"),
],
)
brain = build_brain(cfg)
llm = FakeLLM()
visible_tools = []
await brain.setup(
cfg,
BrainRuntime(
context=LLMContext(messages=[]),
llm=llm,
queue_frame=lambda _frame: None,
set_system_prompt=lambda _prompt: None,
set_tools=lambda tools: visible_tools.extend(tools or []),
call_end=FakeCallEnd(),
),
)
self.assertEqual(
[tool.name for tool in visible_tools],
[
"end_conversation",
"update_state",
"skip_turn",
"request_human_handoff",
],
)
update_schema = next(
tool for tool in visible_tools if tool.name == "update_state"
)
self.assertEqual(
update_schema.properties,
{
"user_name": {
"type": "string",
"description": "更新动态变量 user_name。",
}
},
)
for name in (
"end_conversation",
"update_state",
"skip_turn",
"request_human_handoff",
):
self.assertIn(name, llm.functions)
async def test_end_conversation_ends_call_after_generated_speech(self):
cfg = AssistantConfig(
type="prompt",
tools=[system_runtime_tool("end_conversation")],
)
brain = build_brain(cfg)
llm = FakeLLM()
call_end = FakeCallEnd()
await brain.setup(
cfg,
BrainRuntime(
context=LLMContext(messages=[]),
llm=llm,
queue_frame=lambda _frame: None,
set_system_prompt=lambda _prompt: None,
set_tools=lambda _tools: None,
call_end=call_end,
),
)
await brain.on_assistant_text_start("turn-1")
params = FakeFunctionParams({"reason": "用户已完成咨询"})
await llm.functions["end_conversation"](params)
self.assertEqual(call_end.reason, "用户已完成咨询")
self.assertTrue(call_end.ending)
self.assertFalse(call_end.finished)
self.assertEqual(params.result["action"], "ending_call")
self.assertFalse(params.properties.run_llm)
await brain.on_assistant_text_end(
"turn-1",
"好的,再见。",
False,
)
self.assertFalse(call_end.finished)
self.assertTrue(call_end.armed)
async def test_end_conversation_finishes_when_no_speech(self):
cfg = AssistantConfig(
type="prompt",
tools=[system_runtime_tool("end_conversation")],
)
brain = build_brain(cfg)
llm = FakeLLM()
call_end = FakeCallEnd()
await brain.setup(
cfg,
BrainRuntime(
context=LLMContext(messages=[]),
llm=llm,
queue_frame=lambda _frame: None,
set_system_prompt=lambda _prompt: None,
set_tools=lambda _tools: None,
call_end=call_end,
),
)
await brain.on_assistant_text_start("turn-1")
await llm.functions["end_conversation"](FakeFunctionParams({}))
await brain.on_assistant_text_end("turn-1", "", False)
self.assertTrue(call_end.finished)
async def test_update_state_updates_declared_variables(self):
cfg = AssistantConfig(
type="prompt",
prompt="当前用户名: {{user_name}}",
dynamic_variable_definitions={
"user_name": {"type": "string", "required": False, "default": None}
},
tools=[system_runtime_tool("update_state")],
)
brain = build_brain(cfg)
llm = FakeLLM()
prompts = []
queued_frames = []
async def collect(frame):
queued_frames.append(frame)
await brain.setup(
cfg,
BrainRuntime(
context=LLMContext(messages=[]),
llm=llm,
queue_frame=collect,
set_system_prompt=prompts.append,
set_tools=lambda _tools: None,
call_end=FakeCallEnd(),
),
)
params = FakeFunctionParams({"user_name": "王小明"})
await llm.functions["update_state"](params)
self.assertEqual(params.result["status"], "success")
self.assertEqual(params.result["changed"], ["user_name"])
self.assertIsNone(params.properties)
self.assertEqual(
[frame.message for frame in queued_frames],
[
{
"type": "session-variables",
"reason": "update_state",
"variables": {"user_name": "王小明"},
"changed": ["user_name"],
}
],
)
self.assertTrue(prompts[-1].endswith("当前用户名: 王小明"))
async def test_update_state_rejects_undeclared_variable(self):
cfg = AssistantConfig(
type="prompt",
tools=[system_runtime_tool("update_state")],
)
brain = build_brain(cfg)
llm = FakeLLM()
await brain.setup(
cfg,
BrainRuntime(
context=LLMContext(messages=[]),
llm=llm,
queue_frame=lambda _frame: None,
set_system_prompt=lambda _prompt: None,
set_tools=lambda _tools: None,
call_end=FakeCallEnd(),
),
)
params = FakeFunctionParams({"unknown_key": "x"})
await llm.functions["update_state"](params)
self.assertEqual(params.result["status"], "error")
self.assertIn("未声明", params.result["message"])
async def test_skip_turn_suppresses_response(self):
cfg = AssistantConfig(
type="prompt",
tools=[system_runtime_tool("skip_turn")],
)
brain = build_brain(cfg)
llm = FakeLLM()
await brain.setup(
cfg,
BrainRuntime(
context=LLMContext(messages=[]),
llm=llm,
queue_frame=lambda _frame: None,
set_system_prompt=lambda _prompt: None,
set_tools=lambda _tools: None,
call_end=FakeCallEnd(),
),
)
params = FakeFunctionParams({"reason": "无需回复"})
await llm.functions["skip_turn"](params)
self.assertEqual(params.result["action"], "skip_turn")
self.assertEqual(params.result["reason"], "无需回复")
self.assertFalse(params.properties.run_llm)
async def test_request_human_handoff_keeps_call_available(self):
cfg = AssistantConfig(
type="prompt",
tools=[system_runtime_tool("request_human_handoff")],
)
brain = build_brain(cfg)
llm = FakeLLM()
call_end = FakeCallEnd()
queued_frames = []
async def collect(frame):
queued_frames.append(frame)
await brain.setup(
cfg,
BrainRuntime(
context=LLMContext(messages=[]),
llm=llm,
queue_frame=collect,
set_system_prompt=lambda _prompt: None,
set_tools=lambda _tools: None,
call_end=call_end,
),
)
await brain.on_assistant_text_start("turn-1")
params = FakeFunctionParams({"reason": "用户要求人工客服"})
await llm.functions["request_human_handoff"](params)
self.assertEqual(
[frame.message for frame in queued_frames],
[
{
"type": "handoff-requested",
"source": "prompt-system-tool",
"reason": "用户要求人工客服",
"message": "用户请求转接人工服务。",
}
],
)
self.assertEqual(call_end.reason, "")
self.assertFalse(call_end.ending)
self.assertEqual(params.result["action"], "human_handoff_requested")
self.assertIsNone(params.properties)
await brain.on_assistant_text_end("turn-1", "", False)
self.assertFalse(call_end.finished)
async def test_http_tool_renders_secrets_and_updates_prompt_variable(self):
requests = []
class FakeResponse:
status_code = 200
content = b'{"order":{"status":"paid"}}'
def raise_for_status(self):
return None
def json(self):
return {"order": {"status": "paid"}}
class FakeClient:
def __init__(self, **_kwargs):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *_args):
return None
async def request(self, method, url, **kwargs):
requests.append((method, url, kwargs))
return FakeResponse()
cfg = prepare_dynamic_config(
AssistantConfig(
type="prompt",
runtimeMode="pipeline",
prompt="订单状态:{{order_status}}",
dynamic_variable_definitions={
"order_status": {"type": "string", "default": "unknown"}
},
tools=[
RuntimeTool(
id="lookup",
name="查询订单",
function_name="lookup_order",
type="http",
description="查询订单状态",
definition={
"config": {
"method": "GET",
"url": "https://example.test/orders/{order_id}",
"headers": {"Authorization": "Bearer {{secret__token}}"},
"parameters": [
{
"name": "order_id",
"type": "string",
"location": "path",
"required": True,
},
{
"name": "Authorization",
"type": "string",
"location": "header",
"required": False,
},
],
"dynamic_variable_assignments": {
"order_status": "response.order.status"
},
}
},
secrets={"dynamic_variables": {"secret__token": "server-token"}},
)
],
),
{},
assistant_id="asst_1",
)
brain = build_brain(cfg)
llm = FakeLLM()
prompts = []
visible_tools = []
async def queue_frame(_frame):
pass
await brain.setup(
cfg,
BrainRuntime(
context=LLMContext(messages=[]),
llm=llm,
queue_frame=queue_frame,
set_system_prompt=prompts.append,
set_tools=lambda tools: visible_tools.extend(tools or []),
call_end=FakeCallEnd(),
),
)
params = FakeFunctionParams(
{"order_id": "A/1", "Authorization": "attacker-value"}
)
with patch("services.tool_executor.httpx.AsyncClient", FakeClient):
await llm.functions["lookup_order"](params)
self.assertEqual(requests[0][1], "https://example.test/orders/A%2F1")
self.assertEqual(
requests[0][2]["headers"]["Authorization"], "Bearer server-token"
)
self.assertEqual(params.result["updated_variables"], ["order_status"])
self.assertEqual(prompts[-1], "订单状态:paid")
class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
async def test_agent_system_tools_are_scoped_and_update_declared_state(self):
cfg = prepare_dynamic_config(
AssistantConfig(
type="workflow",
graph={
"specVersion": 3,
"settings": {},
"nodes": [
{"id": "start", "type": "start", "data": {}},
{
"id": "agent",
"type": "agent",
"data": {
"inheritGlobalConfig": False,
"toolIds": [
"tool_update_state",
"tool_skip_turn",
"tool_request_human_handoff",
],
"stateVariableNames": ["customer_name"],
},
},
],
"edges": [],
},
dynamic_variable_definitions={
"customer_name": {
"type": "string",
"required": False,
"default": None,
},
"internal_note": {
"type": "string",
"required": False,
"default": None,
},
},
tools=[
system_runtime_tool("update_state"),
system_runtime_tool("skip_turn"),
system_runtime_tool("request_human_handoff"),
],
),
{},
assistant_id="asst_workflow_system_tools",
)
brain = WorkflowBrain(cfg)
queued = []
async def queue_frame(frame):
queued.append(frame)
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._agent_stage = SimpleNamespace(
node_config=lambda node_id, *, functions, leading_messages: {
"name": node_id,
"functions": functions,
"leading_messages": leading_messages,
}
)
brain._emit_variables = AsyncMock()
brain._refresh_agent_prompt = AsyncMock()
node_config = brain._agent_config("agent")
functions = {tool.name: tool for tool in node_config["functions"]}
self.assertEqual(
list(functions),
["update_state", "skip_turn", "request_human_handoff"],
)
self.assertEqual(
list(functions["update_state"].properties),
["customer_name"],
)
result = await functions["update_state"].handler(
{"customer_name": "李白"},
None,
)
self.assertEqual(result["changed"], ["customer_name"])
self.assertEqual(brain._store.public_values(), {"customer_name": "李白"})
brain._emit_variables.assert_awaited_once_with(
reason="update_state",
node_id="agent",
changed=["customer_name"],
)
brain._refresh_agent_prompt.assert_awaited_once_with("agent")
unauthorized = await functions["update_state"].handler(
{"internal_note": "不可写"},
None,
)
self.assertEqual(unauthorized["status"], "error")
self.assertIn("未获当前节点授权", unauthorized["message"])
handoff = await functions["request_human_handoff"].handler(
{"reason": "用户要求人工"},
None,
)
self.assertEqual(handoff["status"], "requested")
self.assertFalse(brain._runtime.call_end.ending)
event = next(
frame.message
for frame in queued
if isinstance(frame, OutputTransportMessageUrgentFrame)
)
self.assertEqual(event["type"], "handoff-requested")
self.assertEqual(event["source"], "workflow-system-tool")
async def test_update_state_node_applies_assignments_atomically(self):
cfg = prepare_dynamic_config(
AssistantConfig(
type="workflow",
graph={
"specVersion": 3,
"settings": {},
"nodes": [
{"id": "start", "type": "start", "data": {}},
{
"id": "set_state",
"type": "update_state",
"data": {
"assignments": {
"confirmed": True,
"display_name": "{{source_name}}",
}
},
},
],
"edges": [],
},
dynamic_variable_definitions={
"source_name": {"type": "string", "default": "李白"},
"display_name": {
"type": "string",
"required": False,
"default": None,
},
"confirmed": {"type": "boolean", "default": False},
},
),
{},
assistant_id="asst_workflow_update_state_node",
)
brain = WorkflowBrain(cfg)
brain._emit_node_active = AsyncMock()
brain._emit_variables = AsyncMock()
await brain._enter_update_state("set_state")
self.assertEqual(brain._store.public_values()["display_name"], "李白")
self.assertTrue(brain._store.public_values()["confirmed"])
brain._emit_variables.assert_awaited_once_with(
reason="update_state",
node_id="set_state",
changed=["confirmed", "display_name"],
)
async def test_workflow_end_conversation_waits_for_generated_speech(self):
brain = WorkflowBrain(
{
"specVersion": 3,
"settings": {},
"nodes": [{"id": "start", "type": "start", "data": {}}],
"edges": [],
}
)
call_end = FakeCallEnd()
brain._runtime = BrainRuntime(
context=LLMContext(messages=[]),
llm=FakeLLM(),
queue_frame=noop_queue_frame,
set_system_prompt=lambda _prompt: None,
set_tools=lambda _tools: None,
call_end=call_end,
)
tool = brain._workflow_end_conversation_tool(
system_runtime_tool("end_conversation"),
"agent",
)
await brain.on_assistant_text_start("turn-1")
result = await tool.handler({"reason": "用户告别"}, None)
self.assertEqual(result["action"], "ending_call")
self.assertTrue(call_end.ending)
self.assertEqual(call_end.reason, "用户告别")
self.assertTrue(getattr(tool.handler, "_suppress_followup_llm"))
await brain.on_assistant_text_end("turn-1", "再见。", False)
self.assertTrue(call_end.armed)
self.assertTrue(call_end.waited_for_text)
async def test_flow_manager_dispatches_native_vision_without_auxiliary_handler(self):
manager = object.__new__(ConfiguredFlowManager)
fallback_transition = AsyncMock()
native_handler = AsyncMock()
native_enabled = {"value": True}
async def flow_handler(_args, _manager):
return {"status": "ok"}
setattr(
flow_handler,
"_workflow_native_vision_handler",
native_handler,
)
setattr(
flow_handler,
"_workflow_native_vision_enabled",
lambda: native_enabled["value"],
)
with patch.object(
FlowManager,
"_create_transition_func",
new=AsyncMock(return_value=fallback_transition),
):
transition = await manager._create_transition_func(
"fetch_user_image",
flow_handler,
)
params = SimpleNamespace()
await transition(params)
native_handler.assert_awaited_once_with(params)
fallback_transition.assert_not_awaited()
native_enabled["value"] = False
await transition(params)
fallback_transition.assert_awaited_once_with(params)
def test_client_tool_session_wait_disables_flow_timeout(self):
brain = WorkflowBrain(
{
"specVersion": 3,
"settings": {},
"nodes": [{"id": "start", "type": "start", "data": {}}],
"edges": [],
}
)
timeout_tool = RuntimeTool(
id="client_timeout",
name="限时等待",
function_name="wait_with_timeout",
type="client",
definition={
"type": "client",
"config": {"timeout_seconds": 7},
},
)
session_tool = RuntimeTool(
id="client_session",
name="会话内等待",
function_name="wait_for_session",
type="client",
definition={
"type": "client",
"config": {
"timeout_seconds": 7,
"response_wait_mode": "session",
},
},
)
self.assertEqual(brain._flow_tool(timeout_tool, "start").timeout_secs, 7.0)
self.assertIsNone(brain._flow_tool(session_tool, "start").timeout_secs)
async def test_flow_transition_honors_strict_agent_entry_mode(self):
brain = WorkflowBrain(
{
"specVersion": 3,
"settings": {},
"nodes": [
{"id": "start", "type": "start", "data": {}},
{
"id": "waiting",
"type": "agent",
"data": {"entryMode": "wait_user"},
},
{
"id": "immediate",
"type": "agent",
"data": {"entryMode": "generate"},
},
],
"edges": [],
}
)
async def queue_frame(_frame):
pass
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._state.begin_user_turn("完成上一阶段")
waiting_config = brain._flow_managed_transition_config(
{"name": "waiting"},
triggering_user_text="完成上一阶段",
)
waiting_action = waiting_config["pre_actions"][0]
self.assertFalse(waiting_config["respond_immediately"])
self.assertTrue(waiting_action["consume_triggering_turn"])
await waiting_action["handler"](waiting_action, SimpleNamespace())
self.assertEqual(brain._state.status, WorkflowStatus.WAITING_USER)
self.assertIsNone(brain._state.pending_user_turn)
brain._state.begin_user_turn("请立即处理")
immediate_config = brain._flow_managed_transition_config(
{"name": "immediate"},
triggering_user_text="请立即处理",
)
immediate_action = immediate_config["pre_actions"][0]
self.assertTrue(immediate_config["respond_immediately"])
self.assertFalse(immediate_action["consume_triggering_turn"])
await immediate_action["handler"](immediate_action, SimpleNamespace())
self.assertEqual(brain._state.status, WorkflowStatus.RUNNING_AGENT)
self.assertIsNotNone(brain._state.pending_user_turn)
async def test_session_update_refreshes_current_agent_without_routing(self):
cfg = prepare_dynamic_config(
AssistantConfig(
type="workflow",
graph={
"specVersion": 3,
"settings": {"globalPrompt": "面板 {{panel_open}}"},
"nodes": [
{"id": "start", "type": "start", "data": {}},
{
"id": "agent",
"type": "agent",
"data": {"prompt": "等待用户操作"},
},
],
"edges": [
{
"id": "begin",
"source": "start",
"target": "agent",
"data": {"mode": "always", "priority": 0},
}
],
},
dynamic_variable_definitions={
"panel_open": {"type": "boolean", "default": False}
},
),
{},
assistant_id="asst_workflow_session_update",
)
brain = WorkflowBrain(cfg)
queued = []
async def queue_frame(frame):
queued.append(frame)
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._state.enter("agent", WorkflowStatus.WAITING_USER)
result = await brain.on_session_update({"panel_open": True})
self.assertEqual(result.changed, ["panel_open"])
prompt_updates = [
frame for frame in queued if isinstance(frame, LLMUpdateSettingsFrame)
]
self.assertEqual(len(prompt_updates), 1)
self.assertIn("面板 true", prompt_updates[0].delta.system_instruction)
self.assertFalse(any(isinstance(frame, LLMRunFrame) for frame in queued))
variable_event = next(
frame.message
for frame in queued
if isinstance(frame, OutputTransportMessageUrgentFrame)
and frame.message.get("type") == "workflow-variables"
)
self.assertEqual(variable_event["reason"], "session_update")
self.assertEqual(variable_event["changed"], ["panel_open"])
async def test_agent_vision_tool_is_scoped_to_effective_stage(self):
brain = WorkflowBrain(
{
"specVersion": 3,
"settings": {
"globalPrompt": "全局规则",
"defaultLlmResourceId": "llm_global",
"visionEnabled": True,
"visionModelResourceId": "vision_global",
},
"nodes": [
{"id": "start", "type": "start", "data": {}},
{
"id": "agent",
"type": "agent",
"data": {
"prompt": "观察用户需要展示的物品",
"inheritGlobalConfig": True,
},
},
],
"edges": [
{
"id": "begin",
"source": "start",
"target": "agent",
"data": {"mode": "always", "priority": 0},
}
],
}
)
scopes = []
vision_function = object()
async def queue_frame(_frame):
pass
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(),
set_vision_scope=scopes.append,
vision_function=vision_function,
)
await brain._apply_agent_stage("agent")
inherited_config = brain._agent_config("agent")
self.assertIn(vision_function, inherited_config["functions"])
self.assertIn("fetch_user_image", inherited_config["role_message"])
self.assertEqual(
scopes[-1],
{
"enabled": True,
"vision_model_resource_id": "vision_global",
"llm_resource_id": "llm_global",
},
)
brain._engine.data("agent").update(
{
"inheritGlobalConfig": False,
"llmResourceId": "llm_agent",
"visionEnabled": False,
"visionModelResourceId": "",
}
)
await brain._apply_agent_stage("agent")
custom_config = brain._agent_config("agent")
self.assertNotIn(vision_function, custom_config["functions"])
self.assertNotIn("fetch_user_image", custom_config["role_message"])
self.assertFalse(scopes[-1]["enabled"])
async def test_initial_message_starts_without_workflow_greeting(self):
brain = WorkflowBrain(
{
"specVersion": 3,
"settings": {"globalPrompt": "全局规则"},
"nodes": [
{
"id": "start",
"type": "start",
"data": {"greeting": "欢迎使用"},
},
{
"id": "message",
"type": "message",
"data": {
"speech": "请问您怎么称呼?",
"completionPolicy": "playback",
},
},
{
"id": "agent",
"type": "agent",
"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},
}
],
}
)
queued = []
async def queue_frame(frame):
queued.append(frame)
class FakeManager:
def __init__(self):
self.current_node = None
async def initialize(self, config):
self.current_node = config["name"]
async def set_node_from_config(self, config):
self.current_node = config["name"]
for action in config.get("pre_actions", []):
await action["handler"](action, self)
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._manager = FakeManager()
self.assertNotIn("greeting", brain._engine.data("start"))
self.assertEqual(
await brain.greeting(
AssistantConfig(type="workflow", greeting="旧助手级开场白")
),
"",
)
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")
message_speech_frames = [
frame for frame in queued if isinstance(frame, TTSSpeakFrame)
]
self.assertEqual(len(message_speech_frames), 1)
self.assertEqual(message_speech_frames[0].text, "请问您怎么称呼?")
# 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)]),
1,
)
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(),
session_id="conv_action",
)
brain._tools.execute = execute
outcome = await brain._enter_action("lookup_action")
self.assertEqual(outcome.status, ActionStatus.SUCCESS)
self.assertEqual(outcome.updated_variables, ("order_status",))
self.assertEqual(
brain._store.values["system__last_action_invocation_id"],
outcome.invocation_id,
)
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"})
completed_event = next(
frame.message
for frame in queued
if isinstance(frame, OutputTransportMessageUrgentFrame)
and frame.message.get("event") == "action_completed"
)
self.assertEqual(completed_event["outcome"]["status"], "success")
self.assertEqual(completed_event["outcome"]["updatedVariables"], ["order_status"])
self.assertEqual(completed_event["sessionId"], "conv_action")
self.assertEqual(completed_event["workflowRevision"], brain._engine.revision)
self.assertNotIn("result", completed_event["outcome"])
async def test_action_result_assignment_modes_reach_tool_executor(self):
tool = RuntimeTool(
id="client_action",
name="客户端操作",
function_name="show_message",
type="client",
)
brain = WorkflowBrain(
AssistantConfig(
type="workflow",
graph={
"specVersion": 3,
"settings": {},
"nodes": [
{"id": "start", "type": "start", "data": {}},
{
"id": "inherit_action",
"type": "action",
"data": {
"toolId": "client_action",
"resultAssignmentMode": "inherit",
},
},
{
"id": "override_action",
"type": "action",
"data": {
"toolId": "client_action",
"resultAssignmentMode": "override",
"resultAssignments": {"choice": "action"},
},
},
{
"id": "none_action",
"type": "action",
"data": {
"toolId": "client_action",
"resultAssignmentMode": "none",
},
},
],
"edges": [],
},
tools=[tool],
)
)
captured = []
async def execute(_tool, _arguments, *, result_assignments=None):
captured.append(result_assignments)
return {"status": "ok", "updated_variables": []}
brain._runtime = BrainRuntime(
context=LLMContext(messages=[]),
llm=FakeLLM(),
queue_frame=noop_queue_frame,
set_system_prompt=lambda _prompt: None,
set_tools=lambda _tools: None,
call_end=FakeCallEnd(),
)
brain._tools.execute = execute
await brain._enter_action("inherit_action")
await brain._enter_action("override_action")
await brain._enter_action("none_action")
self.assertEqual(captured, [None, {"choice": "action"}, {}])
async def test_action_client_error_sets_error_status(self):
tool = RuntimeTool(
id="client_action",
name="客户端操作",
function_name="show_message",
type="client",
)
brain = WorkflowBrain(
AssistantConfig(
type="workflow",
graph={
"specVersion": 3,
"settings": {},
"nodes": [
{"id": "start", "type": "start", "data": {}},
{
"id": "action",
"type": "action",
"data": {"toolId": "client_action"},
},
],
"edges": [],
},
tools=[tool],
)
)
async def execute(_tool, _arguments, *, result_assignments=None):
return {
"status": "error",
"message": "用户关闭了确认弹窗",
"updated_variables": [],
}
brain._runtime = BrainRuntime(
context=LLMContext(messages=[]),
llm=FakeLLM(),
queue_frame=noop_queue_frame,
set_system_prompt=lambda _prompt: None,
set_tools=lambda _tools: None,
call_end=FakeCallEnd(),
)
brain._tools.execute = execute
outcome = await brain._enter_action("action")
self.assertEqual(outcome.status, ActionStatus.FAILURE)
self.assertEqual(outcome.error.code, "tool_error")
self.assertEqual(brain._store.values["system__last_action_status"], "error")
self.assertEqual(
brain._store.values["system__last_action_error"],
"用户关闭了确认弹窗",
)
async def test_cancelled_action_does_not_follow_failure_or_default_edge(self):
tool = RuntimeTool(
id="client_action",
name="客户端操作",
function_name="show_message",
type="client",
)
brain = WorkflowBrain(
AssistantConfig(
type="workflow",
graph={
"specVersion": 3,
"settings": {},
"nodes": [
{"id": "start", "type": "start", "data": {}},
{
"id": "action",
"type": "action",
"data": {"toolId": "client_action"},
},
{"id": "end", "type": "end", "data": {}},
],
"edges": [
{
"id": "after_action",
"source": "action",
"target": "end",
"data": {"mode": "always"},
}
],
},
tools=[tool],
)
)
queued = []
async def queue_frame(frame):
queued.append(frame)
async def execute(_tool, _arguments, *, result_assignments=None):
return {
"status": "error",
"message": "会话已结束",
"updated_variables": [],
}
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
config = await brain._resolve_path("action")
self.assertEqual(config["name"], "action")
self.assertEqual(
brain._store.values["system__last_action_status"],
"cancelled",
)
self.assertFalse(
any(
isinstance(frame, OutputTransportMessageUrgentFrame)
and frame.message.get("event") == "edge_selected"
for frame in queued
)
)
async def test_action_block_policy_only_suppresses_input_while_running(self):
tool = RuntimeTool(
id="client_action",
name="客户端操作",
function_name="show_message",
type="client",
)
brain = WorkflowBrain(
AssistantConfig(
type="workflow",
graph={
"specVersion": 3,
"settings": {},
"nodes": [
{"id": "start", "type": "start", "data": {}},
{
"id": "block_action",
"type": "action",
"data": {
"toolId": "client_action",
"userInputPolicy": "block",
},
},
{
"id": "queue_action",
"type": "action",
"data": {
"toolId": "client_action",
"userInputPolicy": "queue",
},
},
],
"edges": [],
},
tools=[tool],
)
)
input_states = []
async def execute(_tool, _arguments, *, result_assignments=None):
input_states.append("executing")
return {"status": "ok", "updated_variables": []}
brain._runtime = BrainRuntime(
context=LLMContext(messages=[]),
llm=FakeLLM(),
queue_frame=noop_queue_frame,
set_system_prompt=lambda _prompt: None,
set_tools=lambda _tools: None,
call_end=FakeCallEnd(),
set_input_enabled=input_states.append,
)
brain._tools.execute = execute
await brain._enter_action("block_action")
self.assertEqual(input_states, [False, "executing", True])
input_states.clear()
await brain._enter_action("queue_action")
self.assertEqual(input_states, ["executing"])
async def test_message_starts_speech_and_releases_on_confirmation(self):
brain = WorkflowBrain(
AssistantConfig(
type="workflow",
graph={
"specVersion": 3,
"settings": {},
"nodes": [
{"id": "start", "type": "start", "data": {}},
{
"id": "message",
"type": "message",
"data": {
"speech": "请先确认 {{customer}} 的重要信息。",
"title": "重要提示",
"message": "请核对客户信息。",
"confirmLabel": "确认",
"completionPolicy": "confirmation",
},
},
],
"edges": [],
},
)
)
brain._store.values["customer"] = "王先生"
events = []
async def queue_frame(frame):
if isinstance(frame, TTSSpeakFrame):
events.append(("speech", frame.text))
elif (
isinstance(frame, OutputTransportMessageUrgentFrame)
and frame.message.get("event") == "message_completed"
):
events.append("message_completed")
class OrderedCallEnd(FakeCallEnd):
def __init__(self):
super().__init__()
self.playback_completion = None
def track_speech(self):
self.tracked_speeches += 1
events.append("tracked")
self.playback_completion = asyncio.get_running_loop().create_future()
return self.playback_completion
message_started = asyncio.Event()
user_confirmed = asyncio.Event()
class FakeClientTools:
async def call(self, function_name, arguments, **options):
self.function_name = function_name
self.arguments = arguments
self.options = options
events.append("message_displayed")
message_started.set()
await user_confirmed.wait()
return {"status": "ok", "data": {"action": "confirmed"}}
input_states = []
call_end = OrderedCallEnd()
client_tools = FakeClientTools()
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,
client_tools=client_tools,
set_input_enabled=input_states.append,
)
brain._message_stages.set_client_tools(client_tools)
message_task = asyncio.create_task(brain._enter_message("message"))
await message_started.wait()
self.assertEqual(
events,
[
"tracked",
("speech", "请先确认 王先生 的重要信息。"),
"message_displayed",
],
)
self.assertEqual(input_states, [False])
self.assertEqual(client_tools.function_name, "show_message")
self.assertEqual(client_tools.options["response_wait_mode"], "session")
self.assertTrue(client_tools.options["interrupt_on_result"])
user_confirmed.set()
result = await message_task
self.assertTrue(result.succeeded)
self.assertEqual(result.action, "confirmed")
self.assertFalse(call_end.playback_completion.done())
self.assertEqual(input_states, [False, True])
self.assertEqual(events[-1], "message_completed")
async def test_speech_only_message_waits_for_transport_playback(self):
brain = WorkflowBrain(
{
"specVersion": 3,
"settings": {},
"nodes": [
{"id": "start", "type": "start", "data": {}},
{
"id": "message",
"type": "message",
"data": {"speech": "正在为您准备服务。"},
},
],
"edges": [],
}
)
class PlaybackCallEnd(FakeCallEnd):
def __init__(self):
super().__init__()
self.completion = None
def track_speech(self):
self.completion = asyncio.get_running_loop().create_future()
return self.completion
call_end = PlaybackCallEnd()
input_states = []
brain._runtime = BrainRuntime(
context=LLMContext(messages=[]),
llm=FakeLLM(),
queue_frame=noop_queue_frame,
set_system_prompt=lambda _prompt: None,
set_tools=lambda _tools: None,
call_end=call_end,
set_input_enabled=input_states.append,
)
message_task = asyncio.create_task(brain._enter_message("message"))
await asyncio.sleep(0)
self.assertFalse(message_task.done())
self.assertEqual(input_states, [False])
call_end.completion.set_result(None)
result = await message_task
self.assertTrue(result.succeeded)
self.assertEqual(input_states, [False, True])
async def test_interruptible_message_forwards_multimodal_input_once(self):
graph = {
"specVersion": 3,
"settings": {},
"nodes": [
{"id": "start", "type": "start", "data": {}},
{
"id": "message",
"type": "message",
"data": {
"speech": "请按提示操作,也可以直接告诉我需求。",
"completionPolicy": "interruptible",
},
},
{
"id": "agent",
"type": "agent",
"data": {
"prompt": "处理用户输入",
"contextPolicy": "fresh",
"entryMode": "generate",
},
},
],
"edges": [
{
"id": "start-message",
"source": "start",
"target": "message",
"data": {"mode": "always"},
},
{
"id": "message-agent",
"source": "message",
"target": "agent",
"data": {"mode": "always"},
},
],
}
brain = WorkflowBrain(graph)
queued = []
input_states = []
class PlaybackCallEnd(FakeCallEnd):
def __init__(self):
super().__init__()
self.completion = None
def track_speech(self):
self.completion = asyncio.get_running_loop().create_future()
return self.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()
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, "message")
self.assertIsNotNone(call_end.completion)
self.assertTrue(all(input_states))
image_message = {
"role": "user",
"content": [
{"type": "text", "text": "已发送一张图片"},
{
"type": "image_url",
"image_url": {"url": "data:image/jpeg;base64,AA=="},
},
],
}
await brain.on_user_turn_end(
"已发送一张图片",
user_message=image_message,
)
await asyncio.sleep(0)
self.assertEqual(manager.current_node, "agent")
self.assertEqual(
[config["name"] for config in manager.configs].count("agent"),
1,
)
self.assertTrue(call_end.completion.cancelled())
self.assertTrue(all(input_states))
self.assertEqual(
manager.configs[-1]["task_messages"],
[image_message],
)
self.assertEqual(
sum(isinstance(frame, LLMRunFrame) for frame in queued),
1,
)
self.assertTrue(
any(
isinstance(frame, OutputTransportMessageUrgentFrame)
and frame.message.get("event") == "message_interrupted"
for frame in queued
)
)
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": "收集基本信息",
"entryMode": "generate",
},
},
{
"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")
self.assertEqual(
manager.configs[-1]["task_messages"],
[
{
"role": "system",
"content": f"{FIXED_SPEECH_CONTEXT_MARKER}\n欢迎使用。",
}
],
)
self.assertFalse(
any(
message["role"] == "assistant"
for message in manager.configs[-1]["task_messages"]
)
)
runs_before_second_transition = sum(
isinstance(frame, LLMRunFrame) for frame in queued
)
# 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.assertEqual(
sum(isinstance(frame, LLMRunFrame) for frame in queued),
runs_before_second_transition,
)
self.assertEqual(brain._state.status, WorkflowStatus.WAITING_USER)
self.assertIsNone(brain._state.pending_user_turn)
self.assertEqual(
manager.configs[-1]["task_messages"],
[
{
"role": "system",
"content": (
f"{FIXED_SPEECH_CONTEXT_MARKER}\n现在进入信息确认。"
),
},
{"role": "user", "content": "基本信息已经收集完成"},
],
)
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 = []
async def queue_frame(frame):
queued.append(frame)
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(),
)
class FakeManager:
def __init__(self, current_node=None):
self.current_node = current_node
async def initialize(self, config):
self.current_node = config["name"]
start_brain = WorkflowBrain(
{
"specVersion": 3,
"settings": {},
"nodes": [{"id": "start", "type": "start", "data": {}}],
"edges": [],
}
)
start_brain._runtime = runtime
start_brain._manager = FakeManager()
await start_brain.on_connected()
self.assertEqual(start_brain._manager.current_node, "start")
queued.clear()
await start_brain.on_client_ready()
ready_messages = [
frame.message
for frame in queued
if isinstance(frame, OutputTransportMessageUrgentFrame)
]
self.assertIn(
{"type": "node-active", "nodeId": "start"},
ready_messages,
)
self.assertTrue(
any(
message.get("type") == "workflow-variables"
and message.get("reason") == "client_ready"
and message.get("nodeId") == "start"
for message in ready_messages
)
)
agent_brain = WorkflowBrain(
{
"specVersion": 3,
"settings": {"globalPrompt": "全局规则"},
"nodes": [
{"id": "start", "type": "start", "data": {}},
{
"id": "agent",
"type": "agent",
"data": {"prompt": "持续回答"},
},
],
"edges": [
{
"id": "begin",
"source": "start",
"target": "agent",
"data": {"mode": "always", "priority": 0},
}
],
}
)
agent_brain._runtime = runtime
agent_brain._manager = FakeManager("agent")
queued.clear()
await agent_brain.on_client_ready()
self.assertTrue(
any(
isinstance(frame, OutputTransportMessageUrgentFrame)
and frame.message
== {"type": "node-active", "nodeId": "agent"}
for frame in queued
)
)
queued.clear()
handled = await agent_brain.on_user_turn_end("请继续回答")
self.assertTrue(handled)
self.assertEqual(agent_brain._manager.current_node, "agent")
self.assertTrue(any(isinstance(frame, LLMRunFrame) for frame in queued))
handoff_brain = WorkflowBrain(
{
"specVersion": 3,
"settings": {},
"nodes": [
{"id": "start", "type": "start", "data": {}},
{
"id": "handoff",
"type": "handoff",
"data": {"targetType": "human"},
},
],
"edges": [],
}
)
handoff_brain._runtime = runtime
handoff_config = await handoff_brain._resolve_path("handoff")
self.assertEqual(handoff_config["name"], "handoff")
self.assertTrue(
any(
isinstance(frame, OutputTransportMessageUrgentFrame)
and frame.message.get("type") == "handoff-requested"
for frame in queued
)
)
async def test_waiting_agent_does_not_reply_to_transitioning_user_turn(self):
brain = WorkflowBrain(
{
"specVersion": 3,
"settings": {"globalPrompt": "全局规则"},
"nodes": [
{
"id": "start",
"type": "start",
"data": {"name": "Start"},
},
{
"id": "eat",
"type": "agent",
"data": {
"name": "点饭",
"prompt": "帮助用户点饭",
"contextPolicy": "fresh",
"entryMode": "wait_user",
},
},
{"id": "drink", "type": "agent", "data": {}},
{"id": "run", "type": "agent", "data": {}},
],
"edges": [
{
"id": "eat",
"source": "start",
"target": "eat",
"data": {
"mode": "llm",
"priority": 10,
"condition": "用户想吃饭",
},
},
{
"id": "drink",
"source": "start",
"target": "drink",
"data": {
"mode": "llm",
"priority": 20,
"condition": "用户想喝水",
},
},
{
"id": "run",
"source": "start",
"target": "run",
"data": {
"mode": "llm",
"priority": 30,
"condition": "用户想跑步",
},
},
],
}
)
queued = []
async def queue_frame(frame):
queued.append(frame)
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(),
)
class FakeManager:
def __init__(self):
self.current_node = None
self.config = None
async def initialize(self, config):
self.current_node = config["name"]
self.config = config
async def set_node_from_config(self, config):
self.current_node = config["name"]
self.config = config
class FakeRouter:
def __init__(self):
self.calls = 0
self.current_user_message = None
async def select_edge(self, **kwargs):
self.calls += 1
self.current_user_message = kwargs.get("current_user_message")
return LLMRouteResult(
status=RouteStatus.MATCHED,
function_name="goto_eat",
)
manager = FakeManager()
router = FakeRouter()
brain._manager = manager
brain._router = router
await brain.on_connected()
self.assertEqual(manager.current_node, "start")
self.assertEqual(router.calls, 0)
image_message = {
"role": "user",
"content": [
{"type": "text", "text": "我想吃饭"},
{
"type": "image_url",
"image_url": {"url": "data:image/jpeg;base64,AA=="},
},
],
}
handled = await brain.on_user_turn_end(
"我想吃饭",
user_message=image_message,
)
self.assertTrue(handled)
self.assertEqual(router.calls, 1)
self.assertEqual(router.current_user_message, image_message)
self.assertEqual(manager.current_node, "eat")
self.assertIn(
image_message,
manager.config["task_messages"],
)
self.assertFalse(any(isinstance(frame, LLMRunFrame) for frame in queued))
self.assertEqual(brain._state.status, WorkflowStatus.WAITING_USER)
self.assertIsNone(brain._state.pending_user_turn)
self.assertIn("我想吃饭", brain._store.values["system__conversation_history"])
await brain.on_user_turn_end("我要一份米饭")
self.assertTrue(any(isinstance(frame, LLMRunFrame) for frame in queued))
self.assertEqual(brain._state.status, WorkflowStatus.RUNNING_AGENT)
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(
{
"specVersion": 3,
"settings": {"globalPrompt": "全局规则"},
"nodes": [
{"id": "start", "type": "start", "data": {}},
{
"id": "handoff",
"type": "handoff",
"data": {"name": "人工转接", "targetType": "human"},
},
{
"id": "agent",
"type": "agent",
"data": {"name": "继续服务", "prompt": "继续处理"},
},
],
"edges": [
{
"id": "to-agent",
"source": "handoff",
"target": "agent",
"data": {
"mode": "llm",
"priority": 10,
"condition": "转接后仍需 AI 继续服务",
},
}
],
}
)
queued = []
async def queue_frame(frame):
queued.append(frame)
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(),
)
class FakeRouter:
async def select_edge(self, **kwargs):
self.node_name = kwargs["node_name"]
return LLMRouteResult(
status=RouteStatus.MATCHED,
function_name="goto_to_agent",
)
router = FakeRouter()
brain._router = router
config = await brain._resolve_path("handoff")
self.assertEqual(config["name"], "agent")
self.assertEqual(router.node_name, "人工转接")
async def test_mixed_edge_conditions_follow_priority(self):
brain = WorkflowBrain(
{
"specVersion": 3,
"settings": {},
"nodes": [
{"id": "start", "type": "start", "data": {}},
{"id": "agent", "type": "agent", "data": {}},
{"id": "llm-target", "type": "end", "data": {}},
{"id": "expression-target", "type": "end", "data": {}},
{"id": "default-target", "type": "end", "data": {}},
],
"edges": [
{
"id": "llm",
"source": "agent",
"target": "llm-target",
"data": {
"mode": "llm",
"priority": 10,
"condition": "大模型条件成立",
},
},
{
"id": "expression",
"source": "agent",
"target": "expression-target",
"data": {
"mode": "expression",
"priority": 20,
"expression": {
"combinator": "and",
"rules": [
{
"variable": "route",
"operator": "eq",
"value": "expression",
}
],
},
},
},
{
"id": "default",
"source": "agent",
"target": "default-target",
"data": {"mode": "always", "priority": 30},
},
],
}
)
brain._store.values["route"] = "expression"
class FakeRouter:
def __init__(self):
self.result = LLMRouteResult(status=RouteStatus.NO_MATCH)
self.edge_ids = []
async def select_edge(self, **kwargs):
self.edge_ids = [edge["id"] for edge in kwargs["edges"]]
return self.result
router = FakeRouter()
brain._router = router
selected = await brain._select_edge("agent")
self.assertEqual(router.edge_ids, ["llm"])
self.assertEqual(selected["id"], "expression")
router.result = LLMRouteResult(
status=RouteStatus.MATCHED,
function_name="goto_llm",
)
selected = await brain._select_edge("agent")
self.assertEqual(selected["id"], "llm")
expression_edge = next(
edge for edge in brain._engine.edges if edge["id"] == "expression"
)
expression_edge["data"]["priority"] = 5
router.edge_ids = []
selected = await brain._select_edge("agent")
self.assertEqual(selected["id"], "expression")
self.assertEqual(router.edge_ids, [])
async def test_transition_and_end_are_owned_by_workflow_brain(self):
graph = {
"specVersion": 3,
"settings": {
"globalPrompt": "全局规则",
"defaultLlmResourceId": "llm_global",
"defaultAsrResourceId": "asr_global",
"defaultTtsResourceId": "tts_global",
"knowledgeBaseId": "kb_global",
"knowledgeMode": "automatic",
"enableInterrupt": False,
"turnConfig": {
"bargeIn": {"strategy": "transcription"},
"vad": {"confidence": 0.55},
},
},
"nodes": [
{
"id": "start",
"type": "start",
"data": {"name": "Start"},
},
{
"id": "agent",
"type": "agent",
"data": {
"name": "收集需求",
"prompt": "服务 {{user_name}}",
"contextPolicy": "fresh",
},
},
{
"id": "end",
"type": "end",
"data": {"name": "End", "message": "感谢来电", "scope": "session"},
},
],
"edges": [
{
"id": "begin",
"source": "start",
"target": "agent",
"data": {"mode": "always", "priority": 0},
},
{
"id": "finish",
"source": "agent",
"target": "end",
"data": {
"mode": "llm",
"priority": 10,
"condition": "需求已收集",
},
}
],
}
cfg = prepare_dynamic_config(
AssistantConfig(
type="workflow",
graph=graph,
dynamic_variable_definitions={
"user_name": {"type": "string", "required": True}
},
),
{"user_name": "王先生"},
assistant_id="asst_workflow",
)
brain = WorkflowBrain(cfg)
llm = FakeLLM()
context = LLMContext(messages=[])
queued = []
service_switches = []
knowledge_scopes = []
turn_configs = []
call_end = FakeCallEnd()
class FakeWorker:
def __init__(self):
self.frames = []
self.handlers = {}
def set_reached_downstream_filter(self, *_args):
pass
def event_handler(self, name):
def decorator(fn):
self.handlers[name] = fn
return fn
return decorator
async def queue_frame(self, frame):
self.frames.append(frame)
async def queue_frames(self, frames):
self.frames.extend(frames)
worker = FakeWorker()
pair = SimpleNamespace(
user=lambda: SimpleNamespace(_context=context),
assistant=lambda: SimpleNamespace(has_function_calls_in_progress=False),
)
async def queue_frame(frame):
queued.append(frame)
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,
queue_frame=queue_frame,
set_system_prompt=lambda _prompt: None,
set_tools=lambda _tools: None,
call_end=call_end,
worker=worker,
context_aggregator=pair,
switch_services=switch_services,
set_knowledge_scope=knowledge_scopes.append,
apply_turn_config=apply_turn_config,
)
await brain.setup(cfg, runtime)
greeting = await brain.greeting(cfg)
self.assertEqual(greeting, "")
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
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(
{
"inheritGlobalConfig": False,
"llmResourceId": "llm_agent",
"asrResourceId": "asr_agent",
"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")
self.assertEqual(
service_switches[-1],
("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"])
self.assertEqual(agent_config["task_messages"], [])
self.assertFalse(agent_config["respond_immediately"])
self.assertFalse(any(isinstance(frame, LLMRunFrame) for frame in worker.frames))
self.assertEqual(
agent_config["context_strategy"].strategy.value,
"reset",
)
brain._engine.data("agent")["entryMode"] = "generate"
generate_config = brain._agent_config("agent")
self.assertFalse(generate_config["respond_immediately"])
worker.frames.clear()
await brain._manager.set_node_from_config(generate_config)
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")["entryMode"] = "wait_user"
self.assertEqual(
brain._agent_config(
"agent",
[{"role": "assistant", "content": "正在进入下一阶段"}],
)["task_messages"],
[{"role": "assistant", "content": "正在进入下一阶段"}],
)
self.assertFalse(
any(
function.name == "goto_finish"
for function in brain._agent_config("agent")["functions"]
)
)
await brain.on_assistant_text_end("old-turn", "需求已收集", False)
self.assertEqual(brain._manager.current_node, "agent")
class FakeRouter:
async def select_edge(self, **_kwargs):
return LLMRouteResult(
status=RouteStatus.MATCHED,
function_name="goto_finish",
)
brain._router = FakeRouter()
handled = await brain.on_user_turn_end("我的需求已经说完了")
self.assertTrue(handled)
self.assertEqual(brain._manager.current_node, "end")
self.assertIn("我的需求已经说完了", brain._store.values["system__conversation_history"])
self.assertTrue(call_end.ending)
self.assertTrue(call_end.armed)
self.assertTrue(any(getattr(frame, "text", "") == "感谢来电" for frame in queued))
assistant_transcripts = [
frame.message.get("content")
for frame in queued
if isinstance(frame, OutputTransportMessageUrgentFrame)
and frame.message.get("type") == "transcript"
and frame.message.get("role") == "assistant"
]
self.assertEqual(
assistant_transcripts,
["感谢来电"],
)
self.assertIn(
"感谢来电",
brain._store.values["system__conversation_history"],
)
if __name__ == "__main__":
unittest.main()