Make end call tool can be cancelled
This commit is contained in:
@@ -60,10 +60,24 @@ class FakeCallEnd:
|
||||
self.response_started = False
|
||||
self.waited_for_text: bool | None = None
|
||||
self.tracked_speeches = 0
|
||||
self.interruptible = False
|
||||
|
||||
def begin(self, reason: str) -> None:
|
||||
@property
|
||||
def accepts_input(self) -> bool:
|
||||
return not self.ending or self.interruptible
|
||||
|
||||
def begin(self, reason: str, *, interruptible: bool = False) -> None:
|
||||
self.ending = True
|
||||
self.reason = reason
|
||||
self.interruptible = interruptible
|
||||
|
||||
def cancel_pending_end(self) -> bool:
|
||||
if not self.ending or not self.interruptible:
|
||||
return False
|
||||
self.ending = False
|
||||
self.interruptible = False
|
||||
self.armed = False
|
||||
return True
|
||||
|
||||
def begin_response(self) -> None:
|
||||
self.response_started = True
|
||||
@@ -105,14 +119,14 @@ async def noop_queue_frame(_frame):
|
||||
return None
|
||||
|
||||
|
||||
def system_runtime_tool(kind: str) -> RuntimeTool:
|
||||
def system_runtime_tool(kind: str, **config) -> 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}},
|
||||
definition={"type": "system", "config": {"kind": kind, **config}},
|
||||
)
|
||||
|
||||
|
||||
@@ -175,6 +189,12 @@ class BrainRegistryTests(unittest.TestCase):
|
||||
):
|
||||
self.assertEqual(SystemToolConfig(kind=kind).kind, kind)
|
||||
|
||||
def test_end_interruption_is_opt_in(self):
|
||||
self.assertFalse(SystemToolConfig().allow_end_interruption)
|
||||
self.assertTrue(
|
||||
SystemToolConfig(allowEndInterruption=True).allow_end_interruption
|
||||
)
|
||||
|
||||
def test_system_tool_config_rejects_unknown_kind(self):
|
||||
with self.assertRaises(ValueError):
|
||||
SystemToolConfig(kind="magic")
|
||||
@@ -1018,6 +1038,36 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertFalse(call_end.finished)
|
||||
self.assertTrue(call_end.armed)
|
||||
|
||||
async def test_end_conversation_passes_interruptible_tool_setting(self):
|
||||
cfg = AssistantConfig(
|
||||
type="prompt",
|
||||
tools=[
|
||||
system_runtime_tool(
|
||||
"end_conversation",
|
||||
allow_end_interruption=True,
|
||||
)
|
||||
],
|
||||
)
|
||||
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 llm.functions["end_conversation"](FakeFunctionParams({}))
|
||||
|
||||
self.assertTrue(call_end.ending)
|
||||
self.assertTrue(call_end.interruptible)
|
||||
|
||||
async def test_end_conversation_finishes_when_no_speech(self):
|
||||
cfg = AssistantConfig(
|
||||
type="prompt",
|
||||
@@ -1481,7 +1531,10 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
call_end=call_end,
|
||||
)
|
||||
tool = brain._workflow_end_conversation_tool(
|
||||
system_runtime_tool("end_conversation"),
|
||||
system_runtime_tool(
|
||||
"end_conversation",
|
||||
allow_end_interruption=True,
|
||||
),
|
||||
"agent",
|
||||
)
|
||||
|
||||
@@ -1489,6 +1542,7 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
result = await tool.handler({"reason": "用户告别"}, None)
|
||||
self.assertEqual(result["action"], "ending_call")
|
||||
self.assertTrue(call_end.ending)
|
||||
self.assertTrue(call_end.interruptible)
|
||||
self.assertEqual(call_end.reason, "用户告别")
|
||||
self.assertTrue(getattr(tool.handler, "_suppress_followup_llm"))
|
||||
|
||||
|
||||
@@ -7,11 +7,13 @@ from pipecat.frames.frames import (
|
||||
BotStartedSpeakingFrame,
|
||||
BotStoppedSpeakingFrame,
|
||||
InterruptionFrame,
|
||||
VADUserStartedSpeakingFrame,
|
||||
)
|
||||
from services.pipecat.call_lifecycle import (
|
||||
CallEndCoordinator,
|
||||
playback_marker_for,
|
||||
)
|
||||
from services.pipecat.processors import CallEndingUserMuteStrategy
|
||||
|
||||
|
||||
class CallEndCoordinatorTest(unittest.IsolatedAsyncioTestCase):
|
||||
@@ -66,6 +68,59 @@ class CallEndCoordinatorTest(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
self.assertEqual(self.reasons, ["tool_only"])
|
||||
|
||||
async def test_interruptible_end_keeps_input_open_and_can_be_cancelled(self):
|
||||
self.coordinator.begin_response()
|
||||
self.coordinator.begin("user_goodbye", interruptible=True)
|
||||
await self.coordinator.finish_after_current_speech(has_text=True)
|
||||
await self.coordinator.observe(BotStartedSpeakingFrame())
|
||||
|
||||
self.assertTrue(self.coordinator.ending)
|
||||
self.assertTrue(self.coordinator.accepts_input)
|
||||
self.assertTrue(self.coordinator.cancel_pending_end())
|
||||
self.assertFalse(self.coordinator.ending)
|
||||
self.assertTrue(self.coordinator.accepts_input)
|
||||
self.assertTrue(self.coordinator.speaking)
|
||||
|
||||
await self.coordinator.observe(BotStoppedSpeakingFrame())
|
||||
self.assertFalse(self.coordinator.speaking)
|
||||
self.assertEqual(self.reasons, [])
|
||||
|
||||
async def test_committed_end_rejects_cancellation(self):
|
||||
self.coordinator.begin("required_shutdown")
|
||||
|
||||
self.assertFalse(self.coordinator.accepts_input)
|
||||
self.assertFalse(self.coordinator.cancel_pending_end())
|
||||
self.assertTrue(self.coordinator.ending)
|
||||
|
||||
async def test_late_arm_and_finish_cannot_restore_cancelled_end(self):
|
||||
self.coordinator.begin("user_goodbye", interruptible=True)
|
||||
self.assertTrue(self.coordinator.cancel_pending_end())
|
||||
|
||||
self.coordinator.arm_after_speech()
|
||||
await self.coordinator.arm_after_tracked_speech()
|
||||
await self.coordinator.finish_after_current_speech(has_text=False)
|
||||
await self.coordinator.finish()
|
||||
await self.coordinator.observe(BotStartedSpeakingFrame())
|
||||
await self.coordinator.observe(BotStoppedSpeakingFrame())
|
||||
|
||||
self.assertEqual(self.reasons, [])
|
||||
|
||||
async def test_voice_turn_start_cancels_interruptible_end_before_muting(self):
|
||||
strategy = CallEndingUserMuteStrategy(
|
||||
lambda: not self.coordinator.accepts_input,
|
||||
is_user_interruption=lambda frame: isinstance(
|
||||
frame, VADUserStartedSpeakingFrame
|
||||
),
|
||||
cancel_pending_end=self.coordinator.cancel_pending_end,
|
||||
)
|
||||
self.coordinator.begin("user_goodbye", interruptible=True)
|
||||
|
||||
muted = await strategy.process_frame(VADUserStartedSpeakingFrame())
|
||||
|
||||
self.assertFalse(muted)
|
||||
self.assertFalse(self.coordinator.ending)
|
||||
self.assertEqual(self.reasons, [])
|
||||
|
||||
async def test_workflow_end_waits_for_every_queued_fixed_speech(self):
|
||||
first_completion = self.coordinator.track_speech()
|
||||
second_completion = self.coordinator.track_speech()
|
||||
@@ -134,6 +189,19 @@ class CallEndCoordinatorTest(unittest.IsolatedAsyncioTestCase):
|
||||
await marker.completion.mark_played()
|
||||
self.assertTrue(completion.done())
|
||||
|
||||
async def test_interrupted_fixed_end_does_not_finish_after_cancellation(self):
|
||||
completion = self.coordinator.track_speech()
|
||||
marker = playback_marker_for(completion)
|
||||
marker.completion.mark_queued()
|
||||
self.coordinator.begin("user_goodbye", interruptible=True)
|
||||
await self.coordinator.arm_after_tracked_speech()
|
||||
|
||||
self.assertTrue(self.coordinator.cancel_pending_end())
|
||||
await self.coordinator.observe(InterruptionFrame())
|
||||
|
||||
self.assertTrue(completion.done())
|
||||
self.assertEqual(self.reasons, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from pipecat.frames.frames import LLMMessagesAppendFrame, UserImageRawFrame
|
||||
from pipecat.frames.frames import (
|
||||
InputTransportMessageFrame,
|
||||
LLMMessagesAppendFrame,
|
||||
UserImageRawFrame,
|
||||
)
|
||||
from pipecat.processors.frame_processor import FrameDirection
|
||||
from services.pipecat.call_lifecycle import CallEndCoordinator
|
||||
from services.pipecat.pipeline import _multimodal_user_input_frame
|
||||
from services.pipecat.processors import UserInputError, parse_user_input
|
||||
from services.pipecat.processors import (
|
||||
UserInputError,
|
||||
UserInputProcessor,
|
||||
parse_user_input,
|
||||
)
|
||||
|
||||
|
||||
class UserInputParserTests(unittest.TestCase):
|
||||
@@ -127,5 +138,40 @@ class UserInputParserTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
class UserInputProcessorTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_interrupting_text_cancels_pending_end_before_mute_check(self):
|
||||
async def queue_end(_reason: str) -> None:
|
||||
self.fail("撤销后不应挂断")
|
||||
|
||||
call_end = CallEndCoordinator(queue_end)
|
||||
call_end.begin("user_goodbye", interruptible=True)
|
||||
processor = UserInputProcessor(
|
||||
should_ignore_input=lambda: not call_end.accepts_input,
|
||||
cancel_pending_end=call_end.cancel_pending_end,
|
||||
)
|
||||
processor._call_event_handler = AsyncMock()
|
||||
processor.broadcast_interruption = AsyncMock()
|
||||
|
||||
await processor.process_frame(
|
||||
InputTransportMessageFrame(
|
||||
message={
|
||||
"type": "user-input",
|
||||
"schema_version": 1,
|
||||
"input_id": "input_interrupt_end",
|
||||
"parts": [{"type": "input_text", "text": "等等"}],
|
||||
"options": {
|
||||
"run_immediately": True,
|
||||
"interrupt": True,
|
||||
},
|
||||
}
|
||||
),
|
||||
FrameDirection.DOWNSTREAM,
|
||||
)
|
||||
|
||||
self.assertFalse(call_end.ending)
|
||||
processor._call_event_handler.assert_awaited_once()
|
||||
processor.broadcast_interruption.assert_awaited_once()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user