feat: add session-scoped client tool waits
This commit is contained in:
@@ -518,6 +518,42 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
|
||||
class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
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_session_update_refreshes_current_agent_without_routing(self):
|
||||
cfg = prepare_dynamic_config(
|
||||
AssistantConfig(
|
||||
|
||||
@@ -3,8 +3,11 @@ import unittest
|
||||
|
||||
from models import RuntimeTool
|
||||
from pipecat.frames.frames import (
|
||||
CancelFrame,
|
||||
EndFrame,
|
||||
InputTransportMessageFrame,
|
||||
OutputTransportMessageUrgentFrame,
|
||||
StopFrame,
|
||||
)
|
||||
from pipecat.processors.frame_processor import FrameDirection
|
||||
from services.client_tools import ClientToolBroker, ClientToolError
|
||||
@@ -51,14 +54,32 @@ class FakeClientTools:
|
||||
*,
|
||||
timeout_seconds,
|
||||
wait_for_response=True,
|
||||
response_wait_mode="timeout",
|
||||
):
|
||||
self.calls.append(
|
||||
(function_name, arguments, timeout_seconds, wait_for_response)
|
||||
(
|
||||
function_name,
|
||||
arguments,
|
||||
timeout_seconds,
|
||||
wait_for_response,
|
||||
response_wait_mode,
|
||||
)
|
||||
)
|
||||
return self.result
|
||||
|
||||
|
||||
class ClientToolExecutorTests(unittest.IsolatedAsyncioTestCase):
|
||||
def test_response_wait_mode_defaults_and_uses_camel_case(self):
|
||||
legacy = ClientToolConfig()
|
||||
session = ClientToolConfig(responseWaitMode="session")
|
||||
|
||||
self.assertEqual(legacy.response_wait_mode, "timeout")
|
||||
self.assertEqual(session.response_wait_mode, "session")
|
||||
self.assertEqual(
|
||||
session.model_dump(by_alias=True)["responseWaitMode"],
|
||||
"session",
|
||||
)
|
||||
|
||||
def test_fire_and_forget_rejects_result_assignments(self):
|
||||
with self.assertRaisesRegex(ValueError, "不能配置结果变量赋值"):
|
||||
ClientToolConfig(
|
||||
@@ -80,9 +101,27 @@ class ClientToolExecutorTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertIs(store.values["photo_button_visible"], True)
|
||||
self.assertEqual(
|
||||
port.calls,
|
||||
[("set_photo_button_visible", {"visible": True}, 3.0, True)],
|
||||
[
|
||||
(
|
||||
"set_photo_button_visible",
|
||||
{"visible": True},
|
||||
3.0,
|
||||
True,
|
||||
"timeout",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
async def test_session_wait_mode_is_forwarded(self):
|
||||
tool = client_tool()
|
||||
tool.definition["config"]["response_wait_mode"] = "session"
|
||||
port = FakeClientTools({"status": "ok", "data": {"visible": True}})
|
||||
executor = ToolExecutor(DynamicVariableStore({}), client_tools=port)
|
||||
|
||||
await executor.execute(tool, {"visible": True})
|
||||
|
||||
self.assertEqual(port.calls[0][-1], "session")
|
||||
|
||||
async def test_failure_does_not_update_variable(self):
|
||||
store = DynamicVariableStore({"photo_button_visible": False})
|
||||
executor = ToolExecutor(
|
||||
@@ -152,6 +191,160 @@ class ClientToolBrokerTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual(broker._pending, {})
|
||||
self.assertFalse(outbound[0][0].message["wait_for_response"])
|
||||
|
||||
async def test_session_wait_is_released_by_end_frame(self):
|
||||
broker = ClientToolBroker()
|
||||
|
||||
async def push_frame(frame, direction=FrameDirection.DOWNSTREAM):
|
||||
pass
|
||||
|
||||
broker.push_frame = push_frame
|
||||
call = asyncio.create_task(
|
||||
broker.call(
|
||||
"show_message",
|
||||
{},
|
||||
timeout_seconds=0.001,
|
||||
response_wait_mode="session",
|
||||
)
|
||||
)
|
||||
await asyncio.sleep(0.01)
|
||||
self.assertFalse(call.done())
|
||||
|
||||
await broker.process_frame(EndFrame(), FrameDirection.DOWNSTREAM)
|
||||
|
||||
with self.assertRaisesRegex(ClientToolError, "会话已结束"):
|
||||
await call
|
||||
self.assertEqual(broker._pending, {})
|
||||
|
||||
async def test_session_wait_is_released_by_cleanup(self):
|
||||
broker = ClientToolBroker()
|
||||
|
||||
async def push_frame(frame, direction=FrameDirection.DOWNSTREAM):
|
||||
pass
|
||||
|
||||
broker.push_frame = push_frame
|
||||
call = asyncio.create_task(
|
||||
broker.call(
|
||||
"show_message",
|
||||
{},
|
||||
timeout_seconds=0.001,
|
||||
response_wait_mode="session",
|
||||
)
|
||||
)
|
||||
await asyncio.sleep(0.01)
|
||||
self.assertFalse(call.done())
|
||||
|
||||
await broker.cleanup()
|
||||
|
||||
with self.assertRaisesRegex(ClientToolError, "通道已关闭"):
|
||||
await call
|
||||
self.assertEqual(broker._pending, {})
|
||||
|
||||
async def test_call_after_end_frame_fails_without_registering(self):
|
||||
broker = ClientToolBroker()
|
||||
outbound = []
|
||||
|
||||
async def push_frame(frame, direction=FrameDirection.DOWNSTREAM):
|
||||
outbound.append(frame)
|
||||
|
||||
broker.push_frame = push_frame
|
||||
await broker.process_frame(EndFrame(), FrameDirection.DOWNSTREAM)
|
||||
|
||||
with self.assertRaisesRegex(ClientToolError, "会话已结束"):
|
||||
await broker.call(
|
||||
"show_message",
|
||||
{},
|
||||
timeout_seconds=1,
|
||||
response_wait_mode="session",
|
||||
)
|
||||
self.assertEqual(broker._pending, {})
|
||||
self.assertEqual(len(outbound), 1)
|
||||
|
||||
async def test_call_after_cleanup_fails_without_registering(self):
|
||||
broker = ClientToolBroker()
|
||||
|
||||
await broker.cleanup()
|
||||
|
||||
with self.assertRaisesRegex(ClientToolError, "通道已关闭"):
|
||||
await broker.call(
|
||||
"show_message",
|
||||
{},
|
||||
timeout_seconds=1,
|
||||
response_wait_mode="session",
|
||||
)
|
||||
self.assertEqual(broker._pending, {})
|
||||
|
||||
async def test_cancel_frame_closes_broker(self):
|
||||
broker = ClientToolBroker()
|
||||
|
||||
async def push_frame(frame, direction=FrameDirection.DOWNSTREAM):
|
||||
pass
|
||||
|
||||
broker.push_frame = push_frame
|
||||
await broker.process_frame(CancelFrame(), FrameDirection.DOWNSTREAM)
|
||||
|
||||
with self.assertRaisesRegex(ClientToolError, "会话已取消"):
|
||||
await broker.call(
|
||||
"show_message",
|
||||
{},
|
||||
timeout_seconds=1,
|
||||
response_wait_mode="session",
|
||||
)
|
||||
|
||||
async def test_stop_frame_releases_pending_but_keeps_broker_reusable(self):
|
||||
broker = ClientToolBroker()
|
||||
outbound = []
|
||||
|
||||
async def push_frame(frame, direction=FrameDirection.DOWNSTREAM):
|
||||
outbound.append(frame)
|
||||
|
||||
broker.push_frame = push_frame
|
||||
stopped_call = asyncio.create_task(
|
||||
broker.call(
|
||||
"show_message",
|
||||
{},
|
||||
timeout_seconds=1,
|
||||
response_wait_mode="session",
|
||||
)
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
await broker.process_frame(StopFrame(), FrameDirection.DOWNSTREAM)
|
||||
|
||||
with self.assertRaisesRegex(ClientToolError, "管线已停止"):
|
||||
await stopped_call
|
||||
self.assertEqual(broker._pending, {})
|
||||
|
||||
next_call = asyncio.create_task(
|
||||
broker.call(
|
||||
"show_message",
|
||||
{},
|
||||
timeout_seconds=1,
|
||||
response_wait_mode="session",
|
||||
)
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
message = next(
|
||||
frame.message
|
||||
for frame in reversed(outbound)
|
||||
if isinstance(frame, OutputTransportMessageUrgentFrame)
|
||||
)
|
||||
await broker.process_frame(
|
||||
InputTransportMessageFrame(
|
||||
message={
|
||||
"type": "client-tool-result",
|
||||
"tool_call_id": message["tool_call_id"],
|
||||
"status": "ok",
|
||||
"data": {"action": "confirmed"},
|
||||
}
|
||||
),
|
||||
FrameDirection.DOWNSTREAM,
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
await next_call,
|
||||
{"status": "ok", "data": {"action": "confirmed"}},
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -32,6 +32,10 @@ class ToolPolicyTests(unittest.TestCase):
|
||||
policy_for_tool(runtime_tool("client")).execution_mode,
|
||||
"async",
|
||||
)
|
||||
self.assertEqual(
|
||||
policy_for_tool(runtime_tool("client")).response_wait_mode,
|
||||
"timeout",
|
||||
)
|
||||
|
||||
def test_explicit_policy_is_normalized(self):
|
||||
policy = policy_for_tool(
|
||||
@@ -41,12 +45,14 @@ class ToolPolicyTests(unittest.TestCase):
|
||||
"allow_interruptions": False,
|
||||
"execution_mode": "immediate",
|
||||
"wait_for_response": False,
|
||||
"response_wait_mode": "session",
|
||||
},
|
||||
)
|
||||
)
|
||||
self.assertFalse(policy.allow_interruptions)
|
||||
self.assertTrue(policy.cancel_on_interruption)
|
||||
self.assertFalse(policy.wait_for_response)
|
||||
self.assertEqual(policy.response_wait_mode, "session")
|
||||
|
||||
|
||||
class ToolInterruptionStrategyTests(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
Reference in New Issue
Block a user