Compare commits

...

2 Commits

Author SHA1 Message Date
Xin Wang
4a07abd7fc fix(llm): adapt async tool results for compatible APIs 2026-08-06 10:22:05 +08:00
Xin Wang
aaca128f82 fix(workflow): track fixed speech at transport output 2026-08-06 10:21:36 +08:00
8 changed files with 442 additions and 47 deletions

View File

@@ -3,14 +3,17 @@
from __future__ import annotations
from collections.abc import Awaitable
from typing import Any
from typing import TYPE_CHECKING, Any
from pipecat.frames.frames import OutputTransportMessageUrgentFrame, TTSSpeakFrame
from pipecat.utils.time import time_now_iso8601
from services.brains.base import BrainRuntime
from services.pipecat.call_lifecycle import playback_marker_for
from services.runtime_variables import DynamicVariableStore
if TYPE_CHECKING:
from services.brains.base import BrainRuntime
FIXED_SPEECH_CONTEXT_MARKER = "[会话事实:以下固定消息已向用户播报]"
@@ -79,6 +82,10 @@ class FixedSpeechOutput:
await self._runtime.queue_frame(
TTSSpeakFrame(content, append_to_context=False)
)
playback_marker = playback_marker_for(playback_completion)
if playback_marker is not None:
await self._runtime.queue_frame(playback_marker)
playback_marker.completion.mark_queued()
return playback_completion
async def emit(self, message: dict[str, Any]) -> None:

View File

@@ -3,14 +3,66 @@
from __future__ import annotations
import asyncio
from collections import deque
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from loguru import logger
from pipecat.frames.frames import BotStartedSpeakingFrame, BotStoppedSpeakingFrame
from pipecat.frames.frames import (
BotStartedSpeakingFrame,
BotStoppedSpeakingFrame,
DataFrame,
InterruptionFrame,
)
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
class SpeechPlaybackCompletion:
"""Awaitable completed by its exact marker at the transport output."""
def __init__(self, coordinator: CallEndCoordinator):
self._coordinator = coordinator
self._future: asyncio.Future[None] = (
asyncio.get_running_loop().create_future()
)
self._queued = False
def __await__(self):
return self._future.__await__()
def done(self) -> bool:
return self._future.done()
@property
def queued(self) -> bool:
return self._queued
def mark_queued(self) -> None:
self._queued = True
async def mark_played(self) -> None:
await self._coordinator.complete_tracked_speech(self)
def _resolve(self) -> None:
if not self._future.done():
self._future.set_result(None)
@dataclass
class FixedSpeechPlaybackMarkerFrame(DataFrame):
"""Ordered frame that identifies one fixed utterance at transport output."""
completion: SpeechPlaybackCompletion
def playback_marker_for(
completion: Awaitable[None] | None,
) -> FixedSpeechPlaybackMarkerFrame | None:
"""Build a marker only for the production call-end coordinator."""
if not isinstance(completion, SpeechPlaybackCompletion):
return None
return FixedSpeechPlaybackMarkerFrame(completion=completion)
class CallEndCoordinator:
"""End immediately or after the currently armed closing speech finishes."""
@@ -22,17 +74,10 @@ class CallEndCoordinator:
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()
self._tracked_speech_completions: set[SpeechPlaybackCompletion] = set()
self._finish_after_tracked_speech = False
self._finished = False
self._reason = "completed"
# Only BotStoppedSpeakingFrame that follows a tracked BotStartedSpeakingFrame
# decrements the tracked-speech counter. This prevents a stale or cross-talk
# stop frame (e.g. from a preceding LLM utterance) from consuming the counter
# meant for a fixed end-node message.
self._pending_tracked_starts = 0
self._current_speech_is_tracked = False
@property
def ending(self) -> bool:
@@ -59,18 +104,32 @@ class CallEndCoordinator:
"""Wait for the next observed bot speech to finish."""
self._armed = True
def track_speech(self) -> Awaitable[None]:
def track_speech(self) -> SpeechPlaybackCompletion:
"""Register fixed speech and return its transport completion signal."""
completion = asyncio.get_running_loop().create_future()
self._tracked_speech_completions.append(completion)
self._tracked_speeches += 1
self._pending_tracked_starts += 1
completion = SpeechPlaybackCompletion(self)
self._tracked_speech_completions.add(completion)
return completion
async def complete_tracked_speech(
self,
completion: SpeechPlaybackCompletion,
) -> None:
"""Complete one fixed utterance when its marker reaches output."""
if completion not in self._tracked_speech_completions:
return
self._tracked_speech_completions.remove(completion)
completion._resolve()
if (
self._finish_after_tracked_speech
and not self._tracked_speech_completions
):
logger.info("所有工作流结束语播报完毕,挂断通话")
await self.finish()
async def arm_after_tracked_speech(self) -> None:
"""Finish after every already queued fixed utterance has played."""
self._finish_after_tracked_speech = True
if self._tracked_speeches == 0:
if not self._tracked_speech_completions:
await self.finish()
async def finish_after_current_speech(self, *, has_text: bool) -> None:
@@ -90,30 +149,25 @@ class CallEndCoordinator:
await self._queue_end(self._reason)
async def observe(self, frame) -> None:
if isinstance(frame, BotStartedSpeakingFrame):
if isinstance(frame, InterruptionFrame):
# Pipecat discards queued data frames on interruption, including
# playback markers. Treat already queued fixed speech as stopped so
# an interrupted Message cannot block a later EndNode forever.
interrupted = tuple(
completion
for completion in self._tracked_speech_completions
if completion.queued
)
for completion in interrupted:
await self.complete_tracked_speech(completion)
elif isinstance(frame, BotStartedSpeakingFrame):
self._speaking = True
self._speech_stopped.clear()
self._response_speech_started = True
if self._pending_tracked_starts > 0:
self._pending_tracked_starts -= 1
self._current_speech_is_tracked = True
elif isinstance(frame, BotStoppedSpeakingFrame) and self._speaking:
self._speaking = False
self._speech_stopped.set()
if self._current_speech_is_tracked:
self._current_speech_is_tracked = False
if self._tracked_speeches > 0:
self._tracked_speeches -= 1
completion = self._tracked_speech_completions.popleft()
if not completion.done():
completion.set_result(None)
if (
self._finish_after_tracked_speech
and self._tracked_speeches == 0
):
logger.info("所有工作流结束语播报完毕,挂断通话")
await self.finish()
elif self._armed:
if self._armed:
logger.info("结束语播报完毕,挂断通话")
await self.finish()

View File

@@ -117,7 +117,7 @@ def create_llm(cfg: AssistantConfig):
raise ValueError(f"不支持的 LLM 接口类型: {cfg.llm_interface_type}")
extra_body = cfg.llm_values.get("extraBody")
extra = {"extra_body": extra_body} if isinstance(extra_body, dict) else {}
return OpenAILLMService(
service = OpenAILLMService(
api_key=_require(cfg.llm_api_key, "LLM apiKey"),
base_url=_require(cfg.llm_base_url, "LLM apiUrl"),
settings=OpenAILLMService.Settings(
@@ -125,6 +125,15 @@ def create_llm(cfg: AssistantConfig):
extra=extra,
),
)
# Pipecat represents late async-tool results with the newer `developer`
# role. Most OpenAI-compatible providers (including DeepSeek) only accept
# system/assistant/user/tool. This flag uses Pipecat's built-in adapter to
# downgrade developer messages before sending the request. Official or
# otherwise compatible endpoints can opt in through the resource values.
service.supports_developer_role = (
cfg.llm_values.get("supportsDeveloperRole") is True
)
return service
def create_tts(cfg: AssistantConfig):

View File

@@ -14,15 +14,67 @@ from pipecat.transports.base_transport import TransportParams
# WebRTC
from pipecat.transports.smallwebrtc.connection import SmallWebRTCConnection
from pipecat.transports.smallwebrtc.transport import SmallWebRTCTransport
from pipecat.transports.smallwebrtc.transport import (
SmallWebRTCOutputTransport,
SmallWebRTCTransport,
)
# 裸 WS 音频流
from pipecat.transports.websocket.fastapi import (
FastAPIWebsocketOutputTransport,
FastAPIWebsocketTransport,
FastAPIWebsocketParams,
)
from pipecat.serializers.protobuf import ProtobufFrameSerializer
from services.pipecat.call_lifecycle import FixedSpeechPlaybackMarkerFrame
class _PlaybackMarkerOutputMixin:
"""Resolve fixed-speech markers after preceding audio has been sent."""
async def write_transport_frame(self, frame):
if isinstance(frame, FixedSpeechPlaybackMarkerFrame):
await frame.completion.mark_played()
return
await super().write_transport_frame(frame)
class _WebRTCOutputTransport(
_PlaybackMarkerOutputMixin,
SmallWebRTCOutputTransport,
):
pass
class _WebRTCTransport(SmallWebRTCTransport):
def output(self) -> SmallWebRTCOutputTransport:
if not self._output:
self._output = _WebRTCOutputTransport(
self._client,
self._params,
name=self._input_name,
)
return self._output
class _WebsocketOutputTransport(
_PlaybackMarkerOutputMixin,
FastAPIWebsocketOutputTransport,
):
pass
class _WebsocketTransport(FastAPIWebsocketTransport):
def __init__(self, websocket: WebSocket, params: FastAPIWebsocketParams):
super().__init__(websocket=websocket, params=params)
self._output = _WebsocketOutputTransport(
self,
self._client,
self._params,
name=self._output_name,
)
def _base_params(*, video_in_enabled: bool = False) -> dict:
"""两种 transport 共享的音频参数。"""
@@ -41,7 +93,7 @@ def build_webrtc_transport(
*,
video_in_enabled: bool = False,
) -> SmallWebRTCTransport:
return SmallWebRTCTransport(
return _WebRTCTransport(
webrtc_connection=connection,
params=TransportParams(**_base_params(video_in_enabled=video_in_enabled)),
)
@@ -51,7 +103,7 @@ def build_ws_transport(websocket: WebSocket) -> FastAPIWebsocketTransport:
"""裸 WS 输出。序列化用 protobuf(自定义客户端用同款解码);
若对接电话商,把 serializer 换成对应的 TwilioFrameSerializer 等即可。
"""
return FastAPIWebsocketTransport(
return _WebsocketTransport(
websocket=websocket,
params=FastAPIWebsocketParams(
serializer=ProtobufFrameSerializer(),

View File

@@ -23,6 +23,7 @@ from services.message_stage import (
MessageStageRunner,
MessageStageSpec,
)
from services.pipecat.call_lifecycle import playback_marker_for
from services.pipecat.realtime_tools import RealtimeTool, RealtimeToolResult
from services.runtime_variables import DynamicVariableError, DynamicVariableStore
from services.system_tools import state_update_properties, system_tool_kind
@@ -99,6 +100,18 @@ class RealtimeWorkflowOutput(WorkflowOutput):
content,
suppress_transcript=True,
)
playback_marker = playback_marker_for(playback_completion)
if playback_marker is not None:
async def finish_playback_tracking() -> None:
# The provider boundary is emitted after its final audio frame.
# Queueing the marker then places it behind that audio at output.
if provider_completion is not None:
await provider_completion
await self._runtime.queue_frame(playback_marker)
playback_marker.completion.mark_queued()
await playback_completion
return asyncio.create_task(finish_playback_tracking())
# Message playback policy and deterministic continuation must use the
# transport boundary. Provider response.done only means generation
# has finished; audio may still be buffered at the output transport.
@@ -983,12 +996,20 @@ class WorkflowRealtimeController:
return
self._runtime.call_end.begin("workflow_completed")
if message:
self._runtime.call_end.arm_after_speech()
completion = await self._output.speak(
message,
source="workflow-end-speech",
node_id=node_id,
)
arm_tracked = getattr(
self._runtime.call_end,
"arm_after_tracked_speech",
None,
)
if callable(arm_tracked):
await arm_tracked()
else:
self._runtime.call_end.arm_after_speech()
if completion:
await completion
else:

View File

@@ -3,8 +3,15 @@ from __future__ import annotations
import asyncio
import unittest
from pipecat.frames.frames import BotStartedSpeakingFrame, BotStoppedSpeakingFrame
from services.pipecat.call_lifecycle import CallEndCoordinator
from pipecat.frames.frames import (
BotStartedSpeakingFrame,
BotStoppedSpeakingFrame,
InterruptionFrame,
)
from services.pipecat.call_lifecycle import (
CallEndCoordinator,
playback_marker_for,
)
class CallEndCoordinatorTest(unittest.IsolatedAsyncioTestCase):
@@ -65,17 +72,68 @@ class CallEndCoordinatorTest(unittest.IsolatedAsyncioTestCase):
self.coordinator.begin("workflow_completed")
await self.coordinator.arm_after_tracked_speech()
await self.coordinator.observe(BotStartedSpeakingFrame())
await self.coordinator.observe(BotStoppedSpeakingFrame())
first_marker = playback_marker_for(first_completion)
second_marker = playback_marker_for(second_completion)
self.assertIsNotNone(first_marker)
self.assertIsNotNone(second_marker)
await first_marker.completion.mark_played()
self.assertTrue(first_completion.done())
self.assertFalse(second_completion.done())
self.assertEqual(self.reasons, [])
await self.coordinator.observe(BotStartedSpeakingFrame())
await self.coordinator.observe(BotStoppedSpeakingFrame())
await second_marker.completion.mark_played()
self.assertTrue(second_completion.done())
self.assertEqual(self.reasons, ["workflow_completed"])
async def test_previous_speech_stop_does_not_complete_fixed_speech(self):
await self.coordinator.observe(BotStartedSpeakingFrame())
completion = self.coordinator.track_speech()
marker = playback_marker_for(completion)
self.coordinator.begin("workflow_completed")
await self.coordinator.arm_after_tracked_speech()
await self.coordinator.observe(BotStoppedSpeakingFrame())
self.assertFalse(completion.done())
self.assertEqual(self.reasons, [])
await marker.completion.mark_played()
self.assertEqual(self.reasons, ["workflow_completed"])
async def test_delayed_previous_speech_boundary_cannot_claim_marker(self):
completion = self.coordinator.track_speech()
marker = playback_marker_for(completion)
self.coordinator.begin("workflow_completed")
await self.coordinator.arm_after_tracked_speech()
await self.coordinator.observe(BotStartedSpeakingFrame())
await self.coordinator.observe(BotStoppedSpeakingFrame())
self.assertFalse(completion.done())
self.assertEqual(self.reasons, [])
await marker.completion.mark_played()
self.assertEqual(self.reasons, ["workflow_completed"])
async def test_interruption_completes_marker_already_in_output_queue(self):
completion = self.coordinator.track_speech()
marker = playback_marker_for(completion)
marker.completion.mark_queued()
await self.coordinator.observe(InterruptionFrame())
self.assertTrue(completion.done())
async def test_interruption_does_not_complete_marker_not_yet_queued(self):
completion = self.coordinator.track_speech()
marker = playback_marker_for(completion)
await self.coordinator.observe(InterruptionFrame())
self.assertFalse(completion.done())
marker.completion.mark_queued()
await marker.completion.mark_played()
self.assertTrue(completion.done())
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,146 @@
from __future__ import annotations
import asyncio
import unittest
from types import SimpleNamespace
from models import AssistantConfig
from pipecat.frames.frames import (
BotStartedSpeakingFrame,
BotStoppedSpeakingFrame,
TTSSpeakFrame,
)
import services.brains # Initialize the brain registry before realtime imports.
from services.fixed_speech import FixedSpeechOutput
from services.pipecat.call_lifecycle import (
CallEndCoordinator,
FixedSpeechPlaybackMarkerFrame,
)
from services.pipecat.transports import build_ws_transport
from services.runtime_variables import DynamicVariableStore
from services.workflow.realtime import WorkflowRealtimeController
from services.workflow_engine import WorkflowEngine
class FixedSpeechPlaybackTest(unittest.IsolatedAsyncioTestCase):
async def test_pipeline_speech_queues_marker_immediately_after_tts(self):
queued = []
async def queue_end(_reason: str) -> None:
pass
async def queue_frame(frame) -> None:
queued.append(frame)
call_end = CallEndCoordinator(queue_end)
output = FixedSpeechOutput(
DynamicVariableStore({}),
SimpleNamespace(call_end=call_end, queue_frame=queue_frame),
)
completion = await output.speak(
"固定结束语",
source="test",
record_history=False,
)
self.assertIsInstance(queued[0], TTSSpeakFrame)
self.assertIsInstance(queued[1], FixedSpeechPlaybackMarkerFrame)
self.assertIs(queued[1].completion, completion)
async def test_websocket_output_resolves_marker(self):
async def queue_end(_reason: str) -> None:
pass
call_end = CallEndCoordinator(queue_end)
completion = call_end.track_speech()
marker = FixedSpeechPlaybackMarkerFrame(completion=completion)
websocket = SimpleNamespace(headers={})
output = build_ws_transport(websocket).output()
await output.write_transport_frame(marker)
self.assertTrue(completion.done())
async def test_realtime_end_ignores_unrelated_speech_boundaries(self):
graph = {
"specVersion": 3,
"settings": {},
"nodes": [
{"id": "start", "type": "start", "data": {}},
{
"id": "end",
"type": "end",
"data": {"message": "感谢来电,再见。", "scope": "session"},
},
],
"edges": [],
}
reasons = []
queued = []
async def queue_end(reason: str) -> None:
reasons.append(reason)
async def queue_frame(frame) -> None:
queued.append(frame)
class FakeRealtime:
def __init__(self):
self.provider_completion = None
async def update_session(self, _instructions, _tools):
pass
async def speak_fixed(self, _text, *, suppress_transcript=True):
self.provider_completion = (
asyncio.get_running_loop().create_future()
)
return self.provider_completion
call_end = CallEndCoordinator(queue_end)
realtime = FakeRealtime()
controller = WorkflowRealtimeController(
cfg=AssistantConfig(type="workflow", graph=graph),
engine=WorkflowEngine(graph),
store=DynamicVariableStore({}),
runtime=SimpleNamespace(
realtime=realtime,
queue_frame=queue_frame,
call_end=call_end,
session_id="test-session",
client_tools=None,
set_input_enabled=lambda _enabled: None,
capture_image=None,
),
)
end_task = asyncio.create_task(controller._enter_end("end"))
while realtime.provider_completion is None:
await asyncio.sleep(0)
await call_end.observe(BotStartedSpeakingFrame())
await call_end.observe(BotStoppedSpeakingFrame())
self.assertEqual(reasons, [])
self.assertFalse(end_task.done())
realtime.provider_completion.set_result(None)
marker = None
while marker is None:
await asyncio.sleep(0)
marker = next(
(
frame
for frame in queued
if isinstance(frame, FixedSpeechPlaybackMarkerFrame)
),
None,
)
await marker.completion.mark_played()
await end_task
self.assertEqual(reasons, ["workflow_completed"])
if __name__ == "__main__":
unittest.main()

View File

@@ -4,13 +4,61 @@ import unittest
from unittest.mock import patch
from models import AssistantConfig
from pipecat.processors.aggregators.llm_context import LLMContext
from services.pipecat.service_factory import (
HTTP_TTS_STOP_FRAME_TIMEOUT_S,
WEBSOCKET_TTS_STOP_FRAME_TIMEOUT_S,
create_llm,
create_tts,
)
class LLMServiceFactoryTest(unittest.TestCase):
def test_openai_compatible_llm_converts_async_tool_developer_result(self):
service = create_llm(
AssistantConfig(
llm_interface_type="openai-llm",
model="deepseek-chat",
llm_api_key="test-key",
llm_base_url="https://llm.example.test/v1",
)
)
context = LLMContext(
messages=[
{"role": "system", "content": "你是助手"},
{
"role": "developer",
"content": '{"type":"async_tool","status":"finished"}',
},
{"role": "user", "content": "ok"},
]
)
params = service.get_llm_adapter().get_llm_invocation_params(
context,
convert_developer_to_user=not service.supports_developer_role,
)
self.assertEqual(
[message["role"] for message in params["messages"]],
["system", "user", "user"],
)
self.assertNotIn("developer", str(params["messages"]))
def test_provider_can_explicitly_enable_developer_role(self):
service = create_llm(
AssistantConfig(
llm_interface_type="openai-llm",
model="gpt-compatible",
llm_api_key="test-key",
llm_base_url="https://llm.example.test/v1",
llm_values={"supportsDeveloperRole": True},
)
)
self.assertTrue(service.supports_developer_role)
class TTSServiceFactoryTest(unittest.TestCase):
def test_http_tts_keeps_wider_audio_chunk_timeout(self):
config = AssistantConfig(