Add RealtimeServiceModeConfig to LLMContextAggregatorPair
Decouple context management from turn frames and transcripts when a
realtime LLM service drives the conversation. Three problems with today's
behavior:
- Some realtime services (Gemini Live, AWS Nova Sonic, Ultravox) emit
no UserStarted/StoppedSpeakingFrame at all, so the aggregator — which
writes user messages on those frames — doesn't write to context
correctly without them.
- The workaround (local VAD on the aggregator) generates turn
boundaries that don't match the provider's server-side ground truth,
and the per-service "do I need it?" rule is hard to keep straight.
- When local turn detection is the intended driver, turn-end strategies
still wait for transcripts on the latency critical path.
Add a realtime_service_mode: RealtimeServiceModeConfig | None = None
kwarg on LLMContextAggregatorPair. When set, the pair switches both
halves to trailing context writes: user messages are flushed on the first
assistant content frame, assistant messages on the next user transcript,
both halves on EndFrame. Turn-end strategies stop waiting for transcripts
by default. Two fine-grained boolean fields (context_writes_await_turns,
turns_await_transcripts) let callers dial back to cascade-style behavior
selectively; their invalid combination is rejected in __post_init__.
The bifurcation is dispatch-only: seven branch points across the two
halves, each at method entry, each delegating to a mode-pure private
method. Cross-half coordination uses an asyncio.Lock and a back-reference
shared by both halves; the assistant signals user.flush() on
LLMFullResponseStartFrame, and the user signals assistant.flush() on the
first new transcript after the assistant turn. The mechanism reuses the
existing push_aggregation() — no parallel write path.
Two new events fire when messages are flushed to context:
on_user_message_added and on_assistant_message_added. In cascade mode
they coincide with the existing turn-stopped events; in realtime mode
(where the turn-stopped event fires before the message is finalized)
they're the canonical way to subscribe to "context just updated, here's
the text."
UserTurnStoppedMessage.content is now typed str | None to reflect that
realtime mode fires the event with None.
When a RealtimeServiceMetadataFrame arrives and realtime_service_mode is
None, the aggregator logs a one-time INFO recommendation pointing users
at the option.
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import unittest
|
||||
|
||||
@@ -33,6 +34,7 @@ from pipecat.frames.frames import (
|
||||
LLMThoughtEndFrame,
|
||||
LLMThoughtStartFrame,
|
||||
LLMThoughtTextFrame,
|
||||
RealtimeServiceMetadataFrame,
|
||||
SpeechControlParamsFrame,
|
||||
StartFrame,
|
||||
TextFrame,
|
||||
@@ -55,6 +57,8 @@ from pipecat.processors.aggregators.llm_response_universal import (
|
||||
LLMContextAggregatorPair,
|
||||
LLMUserAggregator,
|
||||
LLMUserAggregatorParams,
|
||||
RealtimeServiceModeConfig,
|
||||
UserTurnStoppedMessage,
|
||||
)
|
||||
from pipecat.processors.frame_processor import FrameDirection
|
||||
from pipecat.tests.utils import SleepFrame, run_test
|
||||
@@ -63,6 +67,10 @@ from pipecat.turns.user_mute import (
|
||||
FunctionCallUserMuteStrategy,
|
||||
MuteUntilFirstBotCompleteUserMuteStrategy,
|
||||
)
|
||||
from pipecat.turns.user_start import (
|
||||
TranscriptionUserTurnStartStrategy,
|
||||
VADUserTurnStartStrategy,
|
||||
)
|
||||
from pipecat.turns.user_stop import SpeechTimeoutUserTurnStopStrategy
|
||||
from pipecat.turns.user_turn_strategies import (
|
||||
FilterIncompleteUserTurnStrategies,
|
||||
@@ -1651,5 +1659,314 @@ class TestToolChangeMessages(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertFalse(pair.assistant()._add_tool_change_messages)
|
||||
|
||||
|
||||
class TestRealtimeServiceModeConfig(unittest.TestCase):
|
||||
def test_default_fields_are_realtime(self):
|
||||
cfg = RealtimeServiceModeConfig()
|
||||
self.assertFalse(cfg.context_writes_await_turns)
|
||||
self.assertFalse(cfg.turns_await_transcripts)
|
||||
|
||||
def test_keep_transcripts_keep_writes_on_turn(self):
|
||||
cfg = RealtimeServiceModeConfig(
|
||||
turns_await_transcripts=True, context_writes_await_turns=True
|
||||
)
|
||||
self.assertTrue(cfg.context_writes_await_turns)
|
||||
self.assertTrue(cfg.turns_await_transcripts)
|
||||
|
||||
def test_keep_transcripts_trailing_writes(self):
|
||||
# Valid third row: turns wait on transcripts but context writes
|
||||
# are trailing. The plan calls this out as the explicit fine-grained
|
||||
# case (downstream consumers of user-turn frames want transcripts).
|
||||
cfg = RealtimeServiceModeConfig(turns_await_transcripts=True)
|
||||
self.assertFalse(cfg.context_writes_await_turns)
|
||||
self.assertTrue(cfg.turns_await_transcripts)
|
||||
|
||||
def test_invalid_combination_rejected(self):
|
||||
# turns fire early but context writes wait → incomplete messages.
|
||||
with self.assertRaises(ValueError):
|
||||
RealtimeServiceModeConfig(
|
||||
turns_await_transcripts=False, context_writes_await_turns=True
|
||||
)
|
||||
|
||||
|
||||
class TestRealtimeServiceModeAggregator(unittest.IsolatedAsyncioTestCase):
|
||||
"""End-to-end tests for the trailing-write realtime mode."""
|
||||
|
||||
def _build_pair(
|
||||
self,
|
||||
*,
|
||||
realtime_service_mode: RealtimeServiceModeConfig | None = None,
|
||||
user_params: LLMUserAggregatorParams | None = None,
|
||||
) -> tuple[LLMContext, LLMContextAggregatorPair]:
|
||||
context = LLMContext()
|
||||
pair = LLMContextAggregatorPair(
|
||||
context,
|
||||
user_params=user_params,
|
||||
realtime_service_mode=realtime_service_mode,
|
||||
)
|
||||
return context, pair
|
||||
|
||||
async def test_pair_propagates_realtime_mode_to_halves(self):
|
||||
_, pair = self._build_pair(realtime_service_mode=RealtimeServiceModeConfig())
|
||||
# The pair wires shared state into both halves.
|
||||
self.assertIs(pair.user()._paired_half, pair.assistant())
|
||||
self.assertIs(pair.assistant()._paired_half, pair.user())
|
||||
self.assertIs(pair.user()._pair_lock, pair.assistant()._pair_lock)
|
||||
self.assertFalse(pair.user()._context_writes_await_turns)
|
||||
self.assertFalse(pair.user()._turns_await_transcripts)
|
||||
self.assertFalse(pair.assistant()._context_writes_await_turns)
|
||||
self.assertFalse(pair.assistant()._turns_await_transcripts)
|
||||
|
||||
async def test_pair_omits_realtime_wiring_when_unset(self):
|
||||
_, pair = self._build_pair()
|
||||
# Backreferences are still created (harmless), but no shared lock
|
||||
# is allocated when the realtime config is absent.
|
||||
self.assertIsNone(pair.user()._pair_lock)
|
||||
self.assertIsNone(pair.assistant()._pair_lock)
|
||||
self.assertTrue(pair.user()._context_writes_await_turns)
|
||||
self.assertTrue(pair.assistant()._context_writes_await_turns)
|
||||
|
||||
async def test_realtime_strategy_mutations_with_defaults(self):
|
||||
_, pair = self._build_pair(realtime_service_mode=RealtimeServiceModeConfig())
|
||||
# The mutated strategies live on the UserTurnController owned by
|
||||
# the user aggregator.
|
||||
strategies = pair.user()._user_turn_controller._user_turn_strategies
|
||||
# TranscriptionUserTurnStartStrategy is dropped.
|
||||
for s in strategies.start:
|
||||
self.assertNotIsInstance(s, TranscriptionUserTurnStartStrategy)
|
||||
# VAD start strategy is preserved.
|
||||
self.assertTrue(any(isinstance(s, VADUserTurnStartStrategy) for s in strategies.start))
|
||||
# Stop strategies that expose wait_for_transcript have it flipped.
|
||||
for s in strategies.stop:
|
||||
if hasattr(s, "wait_for_transcript"):
|
||||
self.assertFalse(s.wait_for_transcript)
|
||||
|
||||
async def test_realtime_strategy_mutations_skipped_when_turns_await_transcripts(self):
|
||||
_, pair = self._build_pair(
|
||||
realtime_service_mode=RealtimeServiceModeConfig(turns_await_transcripts=True),
|
||||
)
|
||||
strategies = pair.user()._user_turn_controller._user_turn_strategies
|
||||
# When turns still wait for transcripts, the transcript start
|
||||
# strategy stays in the chain.
|
||||
self.assertTrue(
|
||||
any(isinstance(s, TranscriptionUserTurnStartStrategy) for s in strategies.start)
|
||||
)
|
||||
|
||||
async def test_trailing_write_user_then_assistant_then_user(self):
|
||||
_, pair = self._build_pair(realtime_service_mode=RealtimeServiceModeConfig())
|
||||
user, assistant = pair
|
||||
|
||||
user_msg_added: list[UserTurnStoppedMessage] = []
|
||||
assistant_msg_added: list[AssistantTurnStoppedMessage] = []
|
||||
|
||||
@user.event_handler("on_user_message_added")
|
||||
async def _on_um(_a, msg):
|
||||
user_msg_added.append(msg)
|
||||
|
||||
@assistant.event_handler("on_assistant_message_added")
|
||||
async def _on_am(_a, msg):
|
||||
assistant_msg_added.append(msg)
|
||||
|
||||
context = user.context
|
||||
|
||||
# Sequence: user transcript, assistant response starts (flushes
|
||||
# user), assistant response ends (parks pending), new user
|
||||
# transcript (flushes assistant), then EndFrame flushes the new
|
||||
# user message.
|
||||
frames_to_send = [
|
||||
TranscriptionFrame(text="Hello!", user_id="", timestamp="now"),
|
||||
SleepFrame(),
|
||||
LLMFullResponseStartFrame(),
|
||||
LLMTextFrame("Hi "),
|
||||
LLMTextFrame("there!"),
|
||||
LLMFullResponseEndFrame(),
|
||||
SleepFrame(),
|
||||
TranscriptionFrame(text="How are you?", user_id="", timestamp="now"),
|
||||
SleepFrame(),
|
||||
]
|
||||
await run_test(
|
||||
Pipeline([user, assistant]),
|
||||
frames_to_send=frames_to_send,
|
||||
)
|
||||
|
||||
# Context should contain: user("Hello!"), assistant("Hi there!"),
|
||||
# user("How are you?").
|
||||
messages = context.get_messages()
|
||||
roles_contents = [(m["role"], m["content"]) for m in messages]
|
||||
self.assertEqual(
|
||||
roles_contents,
|
||||
[
|
||||
("user", "Hello!"),
|
||||
("assistant", "Hi there!"),
|
||||
("user", "How are you?"),
|
||||
],
|
||||
)
|
||||
self.assertEqual([m.content for m in user_msg_added], ["Hello!", "How are you?"])
|
||||
self.assertEqual([m.content for m in assistant_msg_added], ["Hi there!"])
|
||||
for msg in assistant_msg_added:
|
||||
self.assertFalse(msg.interrupted)
|
||||
|
||||
async def test_interruption_writes_assistant_immediately(self):
|
||||
_, pair = self._build_pair(realtime_service_mode=RealtimeServiceModeConfig())
|
||||
user, assistant = pair
|
||||
|
||||
assistant_messages: list[AssistantTurnStoppedMessage] = []
|
||||
|
||||
@assistant.event_handler("on_assistant_message_added")
|
||||
async def _on_am(_a, msg):
|
||||
assistant_messages.append(msg)
|
||||
|
||||
context = user.context
|
||||
|
||||
frames_to_send = [
|
||||
TranscriptionFrame(text="Hi!", user_id="", timestamp="now"),
|
||||
LLMFullResponseStartFrame(),
|
||||
LLMTextFrame("Hello "),
|
||||
SleepFrame(),
|
||||
InterruptionFrame(),
|
||||
]
|
||||
await run_test(
|
||||
Pipeline([user, assistant]),
|
||||
frames_to_send=frames_to_send,
|
||||
)
|
||||
|
||||
roles_contents = [(m["role"], m["content"]) for m in context.get_messages()]
|
||||
# User message written when assistant started; assistant message
|
||||
# written immediately on interruption with interrupted=True.
|
||||
self.assertEqual(roles_contents, [("user", "Hi!"), ("assistant", "Hello")])
|
||||
self.assertEqual(len(assistant_messages), 1)
|
||||
self.assertTrue(assistant_messages[0].interrupted)
|
||||
|
||||
async def test_user_turn_stopped_in_realtime_mode_has_none_content(self):
|
||||
# When VAD turn frames fire in realtime mode, the user-turn-stop
|
||||
# message carries content=None — the message isn't finalized yet.
|
||||
_, pair = self._build_pair(
|
||||
realtime_service_mode=RealtimeServiceModeConfig(),
|
||||
user_params=LLMUserAggregatorParams(
|
||||
user_turn_strategies=UserTurnStrategies(
|
||||
stop=[
|
||||
SpeechTimeoutUserTurnStopStrategy(
|
||||
user_speech_timeout=TRANSCRIPTION_TIMEOUT,
|
||||
)
|
||||
],
|
||||
),
|
||||
user_turn_stop_timeout=USER_TURN_STOP_TIMEOUT,
|
||||
),
|
||||
)
|
||||
user, assistant = pair
|
||||
|
||||
stop_messages: list[UserTurnStoppedMessage] = []
|
||||
|
||||
@user.event_handler("on_user_turn_stopped")
|
||||
async def _on_stop(_a, _s, msg):
|
||||
stop_messages.append(msg)
|
||||
|
||||
frames_to_send = [
|
||||
VADUserStartedSpeakingFrame(),
|
||||
TranscriptionFrame(text="hey", user_id="", timestamp="now"),
|
||||
VADUserStoppedSpeakingFrame(),
|
||||
SleepFrame(sleep=TRANSCRIPTION_TIMEOUT + 0.05),
|
||||
]
|
||||
await run_test(
|
||||
Pipeline([user, assistant]),
|
||||
frames_to_send=frames_to_send,
|
||||
)
|
||||
self.assertEqual(len(stop_messages), 1)
|
||||
self.assertIsNone(stop_messages[0].content)
|
||||
|
||||
async def test_realtime_metadata_recommendation_log_when_unconfigured(self):
|
||||
# Cascade pair receiving a RealtimeServiceMetadataFrame logs the
|
||||
# one-time recommendation. The user half records the fact via
|
||||
# _realtime_recommendation_logged.
|
||||
_, pair = self._build_pair()
|
||||
user = pair.user()
|
||||
|
||||
frames_to_send = [
|
||||
RealtimeServiceMetadataFrame(
|
||||
service_name="FakeRealtimeLLM", emits_user_turn_frames=False
|
||||
),
|
||||
]
|
||||
await run_test(Pipeline([pair.user(), pair.assistant()]), frames_to_send=frames_to_send)
|
||||
self.assertTrue(user._realtime_recommendation_logged)
|
||||
|
||||
async def test_realtime_metadata_no_log_when_configured(self):
|
||||
# When realtime mode is opted in, the metadata frame is consumed
|
||||
# without firing the recommendation log (we still flag the
|
||||
# one-shot bookkeeping).
|
||||
_, pair = self._build_pair(realtime_service_mode=RealtimeServiceModeConfig())
|
||||
user = pair.user()
|
||||
|
||||
frames_to_send = [
|
||||
RealtimeServiceMetadataFrame(
|
||||
service_name="FakeRealtimeLLM", emits_user_turn_frames=False
|
||||
),
|
||||
]
|
||||
await run_test(Pipeline([pair.user(), pair.assistant()]), frames_to_send=frames_to_send)
|
||||
self.assertTrue(user._realtime_recommendation_logged)
|
||||
|
||||
async def test_realtime_mode_requires_paired_half(self):
|
||||
# Direct construction of a half with realtime mode set but no
|
||||
# paired_half raises at StartFrame validation. We call the
|
||||
# validation directly so the error isn't swallowed by the
|
||||
# pipeline's exception handler.
|
||||
context = LLMContext()
|
||||
cfg = RealtimeServiceModeConfig()
|
||||
user = LLMUserAggregator(context, _realtime_service_mode=cfg)
|
||||
with self.assertRaises(RuntimeError):
|
||||
user._validate_realtime_pairing()
|
||||
assistant = LLMAssistantAggregator(context, _realtime_service_mode=cfg)
|
||||
with self.assertRaises(RuntimeError):
|
||||
assistant._validate_realtime_pairing()
|
||||
|
||||
async def test_realtime_mode_rejects_mismatched_halves(self):
|
||||
# If a user code path constructs halves with mismatched configs,
|
||||
# StartFrame validation catches it.
|
||||
context = LLMContext()
|
||||
lock = asyncio.Lock()
|
||||
user = LLMUserAggregator(
|
||||
context,
|
||||
_realtime_service_mode=RealtimeServiceModeConfig(),
|
||||
_pair_lock=lock,
|
||||
)
|
||||
assistant = LLMAssistantAggregator(
|
||||
context,
|
||||
_realtime_service_mode=RealtimeServiceModeConfig(turns_await_transcripts=True),
|
||||
_pair_lock=lock,
|
||||
)
|
||||
user._paired_half = assistant
|
||||
assistant._paired_half = user
|
||||
with self.assertRaises(RuntimeError):
|
||||
user._validate_realtime_pairing()
|
||||
|
||||
async def test_function_call_no_context_push_in_realtime_mode(self):
|
||||
# Realtime services consume function results directly via
|
||||
# FunctionCallResultFrame, so the aggregator should not push
|
||||
# LLMContextFrame upstream after a function call result.
|
||||
_, pair = self._build_pair(realtime_service_mode=RealtimeServiceModeConfig())
|
||||
assistant = pair.assistant()
|
||||
frames_to_send = [
|
||||
FunctionCallInProgressFrame(
|
||||
function_name="get_weather",
|
||||
tool_call_id="1",
|
||||
arguments={"location": "Los Angeles"},
|
||||
cancel_on_interruption=True,
|
||||
),
|
||||
SleepFrame(),
|
||||
FunctionCallResultFrame(
|
||||
function_name="get_weather",
|
||||
tool_call_id="1",
|
||||
arguments={"location": "Los Angeles"},
|
||||
result={"conditions": "Sunny"},
|
||||
),
|
||||
SleepFrame(),
|
||||
]
|
||||
_, up_frames = await run_test(
|
||||
assistant,
|
||||
frames_to_send=frames_to_send,
|
||||
)
|
||||
# No LLMContextFrame should have been pushed upstream in
|
||||
# realtime mode (cascade would push one to re-run inference).
|
||||
self.assertFalse(any(isinstance(f, LLMContextFrame) for f in up_frames))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user