fix: interrupt message output before tool result

This commit is contained in:
Xin Wang
2026-08-04 09:30:52 +08:00
parent d068927b53
commit a16ecd8e01
10 changed files with 152 additions and 37 deletions

View File

@@ -95,7 +95,7 @@ class BrainRuntime:
set_vision_scope: Callable[[dict[str, Any]], None] | None = None set_vision_scope: Callable[[dict[str, Any]], None] | None = None
vision_function: Any = None vision_function: Any = None
set_input_enabled: Callable[[bool], None] | None = 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: ( apply_turn_config: (
Callable[[bool, dict[str, Any]], Awaitable[None]] | None Callable[[bool, dict[str, Any]], Awaitable[None]] | None
) = None ) = None

View File

@@ -1097,12 +1097,12 @@ class WorkflowBrain(BaseBrain):
completion_policy == MESSAGE_CONFIRMATION completion_policy == MESSAGE_CONFIRMATION
and result.action == "confirmed" and result.action == "confirmed"
): ):
if runtime.interrupt_output is None: if runtime.wait_for_output_stopped is None:
raise RuntimeError("当前管线不支持中断 Message 确认语音") raise RuntimeError("当前管线不支持等待 Message 确认语音停止")
# Keep the client-tool result as the confirmation gate, then # ClientToolBroker has already broadcast the interruption
# wait for the pipeline-owned interruption boundary before the # before resolving the confirmation. Keep the next node behind
# next node is allowed to enqueue new output. # the pipeline and transport playback boundaries.
await runtime.interrupt_output() await runtime.wait_for_output_stopped()
await self._emit_trace( await self._emit_trace(
"message_completed", "message_completed",
nodeId=node_id, nodeId=node_id,

View File

@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from dataclasses import dataclass
from typing import Any, Literal, Protocol from typing import Any, Literal, Protocol
from loguru import logger from loguru import logger
@@ -23,6 +24,12 @@ class ClientToolError(RuntimeError):
ClientToolResponseWaitMode = Literal["timeout", "session"] ClientToolResponseWaitMode = Literal["timeout", "session"]
@dataclass(frozen=True)
class _PendingClientToolCall:
future: asyncio.Future[dict[str, Any]]
interrupt_on_result: bool = False
class ClientToolPort(Protocol): class ClientToolPort(Protocol):
async def call( async def call(
self, self,
@@ -32,6 +39,7 @@ class ClientToolPort(Protocol):
timeout_seconds: float, timeout_seconds: float,
wait_for_response: bool = True, wait_for_response: bool = True,
response_wait_mode: ClientToolResponseWaitMode = "timeout", response_wait_mode: ClientToolResponseWaitMode = "timeout",
interrupt_on_result: bool = False,
) -> dict[str, Any]: ... ) -> dict[str, Any]: ...
@@ -40,7 +48,7 @@ class ClientToolBroker(FrameProcessor):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
self._pending: dict[str, asyncio.Future[dict[str, Any]]] = {} self._pending: dict[str, _PendingClientToolCall] = {}
self._closed_message: str | None = None self._closed_message: str | None = None
async def call( async def call(
@@ -51,6 +59,7 @@ class ClientToolBroker(FrameProcessor):
timeout_seconds: float, timeout_seconds: float,
wait_for_response: bool = True, wait_for_response: bool = True,
response_wait_mode: ClientToolResponseWaitMode = "timeout", response_wait_mode: ClientToolResponseWaitMode = "timeout",
interrupt_on_result: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
from uuid import uuid4 from uuid import uuid4
@@ -81,7 +90,10 @@ class ClientToolBroker(FrameProcessor):
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
future: asyncio.Future[dict[str, Any]] = loop.create_future() 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: try:
await self.push_frame( await self.push_frame(
OutputTransportMessageUrgentFrame(message=message) OutputTransportMessageUrgentFrame(message=message)
@@ -125,12 +137,19 @@ class ClientToolBroker(FrameProcessor):
return return
tool_call_id = str(message.get("tool_call_id") or "") tool_call_id = str(message.get("tool_call_id") or "")
future = self._pending.get(tool_call_id) pending = self._pending.get(tool_call_id)
if future is None or future.done(): if pending is None or pending.future.done():
logger.debug(f"忽略未知或过期的客户端工具结果: {tool_call_id}") logger.debug(f"忽略未知或过期的客户端工具结果: {tool_call_id}")
return return
status = str(message.get("status") or "error") 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": if status == "ok":
future.set_result( future.set_result(
{ {
@@ -148,7 +167,8 @@ class ClientToolBroker(FrameProcessor):
) )
def _fail_pending(self, message: str) -> None: 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(): if not future.done():
future.set_exception(ClientToolError(message)) future.set_exception(ClientToolError(message))
self._pending.clear() self._pending.clear()

View File

@@ -187,6 +187,7 @@ class MessageStageRunner:
response_wait_mode=( response_wait_mode=(
"session" if require_confirmation else "timeout" "session" if require_confirmation else "timeout"
), ),
interrupt_on_result=require_confirmation,
) )
except ClientToolError as exc: except ClientToolError as exc:
return MessageStageResult( return MessageStageResult(

View File

@@ -19,6 +19,8 @@ class CallEndCoordinator:
self._ending = False self._ending = False
self._armed = False self._armed = False
self._speaking = False self._speaking = False
self._speech_stopped = asyncio.Event()
self._speech_stopped.set()
self._response_speech_started = False self._response_speech_started = False
self._tracked_speeches = 0 self._tracked_speeches = 0
self._tracked_speech_completions: deque[asyncio.Future[None]] = deque() self._tracked_speech_completions: deque[asyncio.Future[None]] = deque()
@@ -30,6 +32,15 @@ class CallEndCoordinator:
def ending(self) -> bool: def ending(self) -> bool:
return self._ending 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: def begin(self, reason: str) -> None:
self._ending = True self._ending = True
self._reason = reason or "completed" self._reason = reason or "completed"
@@ -74,9 +85,11 @@ class CallEndCoordinator:
async def observe(self, frame) -> None: async def observe(self, frame) -> None:
if isinstance(frame, BotStartedSpeakingFrame): if isinstance(frame, BotStartedSpeakingFrame):
self._speaking = True self._speaking = True
self._speech_stopped.clear()
self._response_speech_started = True self._response_speech_started = True
elif isinstance(frame, BotStoppedSpeakingFrame) and self._speaking: elif isinstance(frame, BotStoppedSpeakingFrame) and self._speaking:
self._speaking = False self._speaking = False
self._speech_stopped.set()
if self._tracked_speeches > 0: if self._tracked_speeches > 0:
self._tracked_speeches -= 1 self._tracked_speeches -= 1
completion = self._tracked_speech_completions.popleft() completion = self._tracked_speech_completions.popleft()

View File

@@ -8,6 +8,7 @@
import asyncio import asyncio
import base64 import base64
from collections.abc import Awaitable, Callable
from io import BytesIO from io import BytesIO
from typing import Any from typing import Any
@@ -106,13 +107,19 @@ ON_DEMAND_KNOWLEDGE_SYSTEM_HINT = (
) )
async def _interrupt_pipeline_output( async def _wait_for_interrupted_output(
source: FrameProcessor,
worker: PipelineWorker, worker: PipelineWorker,
*,
wait_until_stopped: Callable[[], Awaitable[None]] | None = None,
) -> None: ) -> None:
"""Broadcast an interruption and wait until it crosses the media pipeline.""" """Wait until an in-band interruption crosses pipeline and playback."""
await source.broadcast_interruption() if not await worker.flush_pipeline(timeout=2.0):
await worker.flush_pipeline(timeout=1.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: def _compact_knowledge_metadata(value: str, max_length: int) -> str:
@@ -744,9 +751,15 @@ async def run_pipeline(
current_enable_interrupt = enable_interrupt current_enable_interrupt = enable_interrupt
current_turn_config = normalized current_turn_config = normalized
async def interrupt_output() -> None: async def wait_for_output_stopped() -> None:
"""Stop active output through the same boundary as text/voice input.""" """Keep workflow continuation behind the interrupted output."""
await _interrupt_pipeline_output(user_input, worker) 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: 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), set_vision_scope=lambda scope: workflow_vision_scope.update(scope),
vision_function=workflow_vision_function, vision_function=workflow_vision_function,
set_input_enabled=lambda enabled: input_state.__setitem__("enabled", enabled), 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, apply_turn_config=apply_workflow_turn_config,
flow_global_functions=flow_global_functions, flow_global_functions=flow_global_functions,
), ),

View File

@@ -1541,8 +1541,8 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
): ):
events.append("message_completed") events.append("message_completed")
async def interrupt_output(): async def wait_for_output_stopped():
events.append("interrupted") events.append("output_stopped")
class OrderedCallEnd(FakeCallEnd): class OrderedCallEnd(FakeCallEnd):
def __init__(self): def __init__(self):
@@ -1580,7 +1580,7 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
call_end=call_end, call_end=call_end,
client_tools=client_tools, client_tools=client_tools,
set_input_enabled=input_states.append, 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) brain._message_stages.set_client_tools(client_tools)
@@ -1598,6 +1598,7 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(input_states, [False]) self.assertEqual(input_states, [False])
self.assertEqual(client_tools.function_name, "show_message") self.assertEqual(client_tools.function_name, "show_message")
self.assertEqual(client_tools.options["response_wait_mode"], "session") self.assertEqual(client_tools.options["response_wait_mode"], "session")
self.assertTrue(client_tools.options["interrupt_on_result"])
user_confirmed.set() user_confirmed.set()
result = await message_task result = await message_task
@@ -1605,7 +1606,7 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(result.action, "confirmed") self.assertEqual(result.action, "confirmed")
self.assertFalse(call_end.playback_completion.done()) self.assertFalse(call_end.playback_completion.done())
self.assertEqual(input_states, [False, True]) 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): async def test_speech_only_message_waits_for_transport_playback(self):
brain = WorkflowBrain( brain = WorkflowBrain(

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import unittest import unittest
from pipecat.frames.frames import BotStartedSpeakingFrame, BotStoppedSpeakingFrame from pipecat.frames.frames import BotStartedSpeakingFrame, BotStoppedSpeakingFrame
@@ -36,6 +37,20 @@ class CallEndCoordinatorTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(self.reasons, ["prompt_end_call"]) 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): async def test_tool_only_end_call_finishes_without_waiting(self):
self.coordinator.begin_response() self.coordinator.begin_response()
self.coordinator.begin("tool_only") self.coordinator.begin("tool_only")

View File

@@ -138,6 +138,51 @@ class ClientToolExecutorTests(unittest.IsolatedAsyncioTestCase):
class ClientToolBrokerTests(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): async def test_correlates_result_and_times_out(self):
broker = ClientToolBroker() broker = ClientToolBroker()
outbound = [] outbound = []

View File

@@ -10,7 +10,7 @@ from pipecat.frames.frames import (
OutputTransportMessageUrgentFrame, OutputTransportMessageUrgentFrame,
) )
from services.pipecat.pipeline_events import bind_cascade_pipeline_events 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: class _EventSource:
@@ -81,24 +81,31 @@ class _Brain:
class PipelineEventTest(unittest.IsolatedAsyncioTestCase): 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 = [] events = []
source = SimpleNamespace(
broadcast_interruption=AsyncMock( async def wait_until_stopped():
side_effect=lambda: events.append("broadcast") events.append("stopped")
)
)
worker = SimpleNamespace( worker = SimpleNamespace(
flush_pipeline=AsyncMock( 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"]) self.assertEqual(events, ["flush", "stopped"])
source.broadcast_interruption.assert_awaited_once_with() worker.flush_pipeline.assert_awaited_once_with(timeout=2.0)
worker.flush_pipeline.assert_awaited_once_with(timeout=1.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): async def test_greeting_keeps_playback_timestamp_until_client_ready(self):
transport = _EventSource() transport = _EventSource()