diff --git a/backend/services/brains/base.py b/backend/services/brains/base.py index 8b8e146..41fe75a 100644 --- a/backend/services/brains/base.py +++ b/backend/services/brains/base.py @@ -95,7 +95,7 @@ class BrainRuntime: set_vision_scope: Callable[[dict[str, Any]], None] | None = None vision_function: Any = None set_input_enabled: Callable[[bool], None] | None = None - interrupt_output: Callable[[], Awaitable[None]] | None = None + wait_for_output_stopped: Callable[[], Awaitable[None]] | None = None apply_turn_config: ( Callable[[bool, dict[str, Any]], Awaitable[None]] | None ) = None diff --git a/backend/services/brains/workflow_brain.py b/backend/services/brains/workflow_brain.py index ae1dd91..9739e59 100644 --- a/backend/services/brains/workflow_brain.py +++ b/backend/services/brains/workflow_brain.py @@ -1097,12 +1097,12 @@ class WorkflowBrain(BaseBrain): completion_policy == MESSAGE_CONFIRMATION and result.action == "confirmed" ): - if runtime.interrupt_output is None: - raise RuntimeError("当前管线不支持中断 Message 确认语音") - # Keep the client-tool result as the confirmation gate, then - # wait for the pipeline-owned interruption boundary before the - # next node is allowed to enqueue new output. - await runtime.interrupt_output() + if runtime.wait_for_output_stopped is None: + raise RuntimeError("当前管线不支持等待 Message 确认语音停止") + # ClientToolBroker has already broadcast the interruption + # before resolving the confirmation. Keep the next node behind + # the pipeline and transport playback boundaries. + await runtime.wait_for_output_stopped() await self._emit_trace( "message_completed", nodeId=node_id, diff --git a/backend/services/client_tools.py b/backend/services/client_tools.py index ca922b0..863c1a0 100644 --- a/backend/services/client_tools.py +++ b/backend/services/client_tools.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from dataclasses import dataclass from typing import Any, Literal, Protocol from loguru import logger @@ -23,6 +24,12 @@ class ClientToolError(RuntimeError): ClientToolResponseWaitMode = Literal["timeout", "session"] +@dataclass(frozen=True) +class _PendingClientToolCall: + future: asyncio.Future[dict[str, Any]] + interrupt_on_result: bool = False + + class ClientToolPort(Protocol): async def call( self, @@ -32,6 +39,7 @@ class ClientToolPort(Protocol): timeout_seconds: float, wait_for_response: bool = True, response_wait_mode: ClientToolResponseWaitMode = "timeout", + interrupt_on_result: bool = False, ) -> dict[str, Any]: ... @@ -40,7 +48,7 @@ class ClientToolBroker(FrameProcessor): def __init__(self) -> None: super().__init__() - self._pending: dict[str, asyncio.Future[dict[str, Any]]] = {} + self._pending: dict[str, _PendingClientToolCall] = {} self._closed_message: str | None = None async def call( @@ -51,6 +59,7 @@ class ClientToolBroker(FrameProcessor): timeout_seconds: float, wait_for_response: bool = True, response_wait_mode: ClientToolResponseWaitMode = "timeout", + interrupt_on_result: bool = False, ) -> dict[str, Any]: from uuid import uuid4 @@ -81,7 +90,10 @@ class ClientToolBroker(FrameProcessor): loop = asyncio.get_running_loop() future: asyncio.Future[dict[str, Any]] = loop.create_future() - self._pending[tool_call_id] = future + self._pending[tool_call_id] = _PendingClientToolCall( + future=future, + interrupt_on_result=interrupt_on_result, + ) try: await self.push_frame( OutputTransportMessageUrgentFrame(message=message) @@ -125,12 +137,19 @@ class ClientToolBroker(FrameProcessor): return tool_call_id = str(message.get("tool_call_id") or "") - future = self._pending.get(tool_call_id) - if future is None or future.done(): + pending = self._pending.get(tool_call_id) + if pending is None or pending.future.done(): logger.debug(f"忽略未知或过期的客户端工具结果: {tool_call_id}") return status = str(message.get("status") or "error") + # Keep interruption in the input-frame path, matching text input. + # Resolve the tool future only after the interruption has been + # broadcast, so the workflow cannot enqueue its next node first. + if status == "ok" and pending.interrupt_on_result: + await self.broadcast_interruption() + + future = pending.future if status == "ok": future.set_result( { @@ -148,7 +167,8 @@ class ClientToolBroker(FrameProcessor): ) def _fail_pending(self, message: str) -> None: - for future in self._pending.values(): + for pending in self._pending.values(): + future = pending.future if not future.done(): future.set_exception(ClientToolError(message)) self._pending.clear() diff --git a/backend/services/message_stage.py b/backend/services/message_stage.py index b2e02e2..923c302 100644 --- a/backend/services/message_stage.py +++ b/backend/services/message_stage.py @@ -187,6 +187,7 @@ class MessageStageRunner: response_wait_mode=( "session" if require_confirmation else "timeout" ), + interrupt_on_result=require_confirmation, ) except ClientToolError as exc: return MessageStageResult( diff --git a/backend/services/pipecat/call_lifecycle.py b/backend/services/pipecat/call_lifecycle.py index e1a863f..4daf1c2 100644 --- a/backend/services/pipecat/call_lifecycle.py +++ b/backend/services/pipecat/call_lifecycle.py @@ -19,6 +19,8 @@ class CallEndCoordinator: self._ending = False self._armed = False self._speaking = False + self._speech_stopped = asyncio.Event() + self._speech_stopped.set() self._response_speech_started = False self._tracked_speeches = 0 self._tracked_speech_completions: deque[asyncio.Future[None]] = deque() @@ -30,6 +32,15 @@ class CallEndCoordinator: def ending(self) -> bool: return self._ending + @property + def speaking(self) -> bool: + """Whether transport output is currently producing bot speech.""" + return self._speaking + + async def wait_until_silent(self) -> None: + """Wait for the transport-owned bot speech boundary.""" + await self._speech_stopped.wait() + def begin(self, reason: str) -> None: self._ending = True self._reason = reason or "completed" @@ -74,9 +85,11 @@ class CallEndCoordinator: async def observe(self, frame) -> None: if isinstance(frame, BotStartedSpeakingFrame): self._speaking = True + self._speech_stopped.clear() self._response_speech_started = True elif isinstance(frame, BotStoppedSpeakingFrame) and self._speaking: self._speaking = False + self._speech_stopped.set() if self._tracked_speeches > 0: self._tracked_speeches -= 1 completion = self._tracked_speech_completions.popleft() diff --git a/backend/services/pipecat/pipeline.py b/backend/services/pipecat/pipeline.py index fe4eb5d..a22d1ce 100644 --- a/backend/services/pipecat/pipeline.py +++ b/backend/services/pipecat/pipeline.py @@ -8,6 +8,7 @@ import asyncio import base64 +from collections.abc import Awaitable, Callable from io import BytesIO from typing import Any @@ -106,13 +107,19 @@ ON_DEMAND_KNOWLEDGE_SYSTEM_HINT = ( ) -async def _interrupt_pipeline_output( - source: FrameProcessor, +async def _wait_for_interrupted_output( worker: PipelineWorker, + *, + wait_until_stopped: Callable[[], Awaitable[None]] | None = None, ) -> None: - """Broadcast an interruption and wait until it crosses the media pipeline.""" - await source.broadcast_interruption() - await worker.flush_pipeline(timeout=1.0) + """Wait until an in-band interruption crosses pipeline and playback.""" + if not await worker.flush_pipeline(timeout=2.0): + raise RuntimeError("输出中断帧未能及时穿过媒体管线") + if wait_until_stopped is not None: + try: + await asyncio.wait_for(wait_until_stopped(), timeout=2.0) + except TimeoutError as exc: + raise RuntimeError("等待客户端语音停止超时") from exc def _compact_knowledge_metadata(value: str, max_length: int) -> str: @@ -744,9 +751,15 @@ async def run_pipeline( current_enable_interrupt = enable_interrupt current_turn_config = normalized - async def interrupt_output() -> None: - """Stop active output through the same boundary as text/voice input.""" - await _interrupt_pipeline_output(user_input, worker) + async def wait_for_output_stopped() -> None: + """Keep workflow continuation behind the interrupted output.""" + wait_until_stopped = ( + call_end.wait_until_silent if call_end.speaking else None + ) + await _wait_for_interrupted_output( + worker, + wait_until_stopped=wait_until_stopped, + ) def set_system_prompt(text: str) -> None: @@ -781,7 +794,7 @@ async def run_pipeline( set_vision_scope=lambda scope: workflow_vision_scope.update(scope), vision_function=workflow_vision_function, set_input_enabled=lambda enabled: input_state.__setitem__("enabled", enabled), - interrupt_output=interrupt_output, + wait_for_output_stopped=wait_for_output_stopped, apply_turn_config=apply_workflow_turn_config, flow_global_functions=flow_global_functions, ), diff --git a/backend/tests/test_brains.py b/backend/tests/test_brains.py index 8b1d601..761e9aa 100644 --- a/backend/tests/test_brains.py +++ b/backend/tests/test_brains.py @@ -1541,8 +1541,8 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase): ): events.append("message_completed") - async def interrupt_output(): - events.append("interrupted") + async def wait_for_output_stopped(): + events.append("output_stopped") class OrderedCallEnd(FakeCallEnd): def __init__(self): @@ -1580,7 +1580,7 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase): call_end=call_end, client_tools=client_tools, set_input_enabled=input_states.append, - interrupt_output=interrupt_output, + wait_for_output_stopped=wait_for_output_stopped, ) brain._message_stages.set_client_tools(client_tools) @@ -1598,6 +1598,7 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase): 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 @@ -1605,7 +1606,7 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(result.action, "confirmed") self.assertFalse(call_end.playback_completion.done()) self.assertEqual(input_states, [False, True]) - self.assertEqual(events[-2:], ["interrupted", "message_completed"]) + self.assertEqual(events[-2:], ["output_stopped", "message_completed"]) async def test_speech_only_message_waits_for_transport_playback(self): brain = WorkflowBrain( diff --git a/backend/tests/test_call_lifecycle.py b/backend/tests/test_call_lifecycle.py index 8a9ebc9..f725a3c 100644 --- a/backend/tests/test_call_lifecycle.py +++ b/backend/tests/test_call_lifecycle.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import unittest from pipecat.frames.frames import BotStartedSpeakingFrame, BotStoppedSpeakingFrame @@ -36,6 +37,20 @@ class CallEndCoordinatorTest(unittest.IsolatedAsyncioTestCase): self.assertEqual(self.reasons, ["prompt_end_call"]) + async def test_wait_until_silent_tracks_transport_boundary(self): + self.assertFalse(self.coordinator.speaking) + await self.coordinator.wait_until_silent() + + await self.coordinator.observe(BotStartedSpeakingFrame()) + self.assertTrue(self.coordinator.speaking) + wait_task = asyncio.create_task(self.coordinator.wait_until_silent()) + await asyncio.sleep(0) + self.assertFalse(wait_task.done()) + + await self.coordinator.observe(BotStoppedSpeakingFrame()) + await wait_task + self.assertFalse(self.coordinator.speaking) + async def test_tool_only_end_call_finishes_without_waiting(self): self.coordinator.begin_response() self.coordinator.begin("tool_only") diff --git a/backend/tests/test_client_tools.py b/backend/tests/test_client_tools.py index 622b137..b513fd1 100644 --- a/backend/tests/test_client_tools.py +++ b/backend/tests/test_client_tools.py @@ -138,6 +138,51 @@ class ClientToolExecutorTests(unittest.IsolatedAsyncioTestCase): class ClientToolBrokerTests(unittest.IsolatedAsyncioTestCase): + async def test_interrupts_before_resolving_configured_result(self): + broker = ClientToolBroker() + outbound = [] + observed_future_states = [] + + async def push_frame(frame, direction=FrameDirection.DOWNSTREAM): + outbound.append((frame, direction)) + + async def broadcast_interruption(): + observed_future_states.append( + [pending.future.done() for pending in broker._pending.values()] + ) + + broker.push_frame = push_frame + broker.broadcast_interruption = broadcast_interruption + call = asyncio.create_task( + broker.call( + "show_message", + {}, + timeout_seconds=1, + response_wait_mode="session", + interrupt_on_result=True, + ) + ) + await asyncio.sleep(0) + message = outbound[0][0].message + + 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(observed_future_states, [[False]]) + self.assertEqual( + await call, + {"status": "ok", "data": {"action": "confirmed"}}, + ) + async def test_correlates_result_and_times_out(self): broker = ClientToolBroker() outbound = [] diff --git a/backend/tests/test_pipeline_events.py b/backend/tests/test_pipeline_events.py index cf1872c..6e6b576 100644 --- a/backend/tests/test_pipeline_events.py +++ b/backend/tests/test_pipeline_events.py @@ -10,7 +10,7 @@ from pipecat.frames.frames import ( OutputTransportMessageUrgentFrame, ) from services.pipecat.pipeline_events import bind_cascade_pipeline_events -from services.pipecat.pipeline import _interrupt_pipeline_output +from services.pipecat.pipeline import _wait_for_interrupted_output class _EventSource: @@ -81,24 +81,31 @@ class _Brain: class PipelineEventTest(unittest.IsolatedAsyncioTestCase): - async def test_output_interruption_broadcasts_before_flush_barrier(self): + async def test_interrupted_output_waits_for_flush_and_stop(self): events = [] - source = SimpleNamespace( - broadcast_interruption=AsyncMock( - side_effect=lambda: events.append("broadcast") - ) - ) + + async def wait_until_stopped(): + events.append("stopped") + worker = SimpleNamespace( flush_pipeline=AsyncMock( - side_effect=lambda **_kwargs: events.append("flush") + side_effect=lambda **_kwargs: events.append("flush") or True ) ) - await _interrupt_pipeline_output(source, worker) + await _wait_for_interrupted_output( + worker, + wait_until_stopped=wait_until_stopped, + ) - self.assertEqual(events, ["broadcast", "flush"]) - source.broadcast_interruption.assert_awaited_once_with() - worker.flush_pipeline.assert_awaited_once_with(timeout=1.0) + self.assertEqual(events, ["flush", "stopped"]) + worker.flush_pipeline.assert_awaited_once_with(timeout=2.0) + + async def test_interrupted_output_rejects_flush_timeout(self): + worker = SimpleNamespace(flush_pipeline=AsyncMock(return_value=False)) + + with self.assertRaisesRegex(RuntimeError, "中断帧"): + await _wait_for_interrupted_output(worker) async def test_greeting_keeps_playback_timestamp_until_client_ready(self): transport = _EventSource()