From b78cecf7b2aba72b0fe7231257803e3dd9bf500c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleix=20Conchillo=20Flaqu=C3=A9?= Date: Wed, 6 May 2026 11:17:56 -0700 Subject: [PATCH] Rename UserTurnCompletedFrame to UserTurnInferenceCompletedFrame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old name overlapped semantically with `UserStoppedSpeakingFrame`: both could be read as "the user's turn is done." They're at different layers — `UserStoppedSpeakingFrame` is the acoustic stop signal, while this frame is the post-judgment "inference about the turn is now complete (turn is semantically final)" signal emitted by the LLM mixin (on ✓), an end-of-turn classifier, or a custom producer. The new name pairs naturally with the existing `on_user_turn_inference_triggered` event vocabulary and removes the ambiguity with `UserStoppedSpeakingFrame`. --- changelog/4405.added.2.md | 2 +- changelog/4405.added.5.md | 2 +- src/pipecat/frames/frames.py | 2 +- ...xternal_user_turn_completion_stop_strategy.py | 14 +++++++------- ...lm_turn_completion_user_turn_stop_strategy.py | 2 +- src/pipecat/turns/user_turn_completion_mixin.py | 6 +++--- tests/test_context_aggregators_universal.py | 16 ++++++++-------- tests/test_user_turn_completion_mixin.py | 14 +++++++------- 8 files changed, 29 insertions(+), 29 deletions(-) diff --git a/changelog/4405.added.2.md b/changelog/4405.added.2.md index 30f49e576..30e86f904 100644 --- a/changelog/4405.added.2.md +++ b/changelog/4405.added.2.md @@ -1 +1 @@ -- Added `LLMTurnCompletionUserTurnStopStrategy` in `pipecat.turns.user_stop`. When installed, the strategy gates `on_user_turn_stopped` on a `UserTurnCompletedFrame` (a new fieldless system frame emitted by any component that can judge turn completeness — e.g. the `UserTurnCompletionLLMServiceMixin` on `✓`). A `finalization_timeout` provides a safety net if no completion frame ever arrives. +- Added `LLMTurnCompletionUserTurnStopStrategy` in `pipecat.turns.user_stop`. When installed, the strategy gates `on_user_turn_stopped` on a `UserTurnInferenceCompletedFrame` (a new fieldless system frame emitted by any component that can judge turn completeness — e.g. the `UserTurnCompletionLLMServiceMixin` on `✓`). A `finalization_timeout` provides a safety net if no completion frame ever arrives. diff --git a/changelog/4405.added.5.md b/changelog/4405.added.5.md index 1b8eadea2..508084e30 100644 --- a/changelog/4405.added.5.md +++ b/changelog/4405.added.5.md @@ -1 +1 @@ -- Added `ExternalUserTurnCompletionStopStrategy` in `pipecat.turns.user_stop` — a generic stop strategy that finalizes the user turn whenever a `UserTurnCompletedFrame` arrives, regardless of which component produced it. `LLMTurnCompletionUserTurnStopStrategy` now extends this base; future producers (Flux, custom end-of-turn classifiers, etc.) can use the base directly or subclass it to add producer-specific setup. +- Added `ExternalUserTurnCompletionStopStrategy` in `pipecat.turns.user_stop` — a generic stop strategy that finalizes the user turn whenever a `UserTurnInferenceCompletedFrame` arrives, regardless of which component produced it. `LLMTurnCompletionUserTurnStopStrategy` now extends this base; future producers (Flux, custom end-of-turn classifiers, etc.) can use the base directly or subclass it to add producer-specific setup. diff --git a/src/pipecat/frames/frames.py b/src/pipecat/frames/frames.py index 569574842..863da578e 100644 --- a/src/pipecat/frames/frames.py +++ b/src/pipecat/frames/frames.py @@ -1005,7 +1005,7 @@ class UserSpeakingFrame(SystemFrame): @dataclass -class UserTurnCompletedFrame(SystemFrame): +class UserTurnInferenceCompletedFrame(SystemFrame): """Frame indicating that the user turn is semantically complete. Emitted by any component that can judge conversational turn diff --git a/src/pipecat/turns/user_stop/external_user_turn_completion_stop_strategy.py b/src/pipecat/turns/user_stop/external_user_turn_completion_stop_strategy.py index c132a5db8..3850c234c 100644 --- a/src/pipecat/turns/user_stop/external_user_turn_completion_stop_strategy.py +++ b/src/pipecat/turns/user_stop/external_user_turn_completion_stop_strategy.py @@ -4,21 +4,21 @@ # SPDX-License-Identifier: BSD 2-Clause License # -"""User turn stop strategy that finalizes on ``UserTurnCompletedFrame``.""" +"""User turn stop strategy that finalizes on ``UserTurnInferenceCompletedFrame``.""" -from pipecat.frames.frames import Frame, UserTurnCompletedFrame +from pipecat.frames.frames import Frame, UserTurnInferenceCompletedFrame from pipecat.turns.types import ProcessFrameResult from pipecat.turns.user_stop.base_user_turn_stop_strategy import BaseUserTurnStopStrategy class ExternalUserTurnCompletionStopStrategy(BaseUserTurnStopStrategy): - """Finalize the user turn whenever a ``UserTurnCompletedFrame`` arrives. + """Finalize the user turn whenever a ``UserTurnInferenceCompletedFrame`` arrives. Generic stop strategy for pipelines where some external component (LLM with completion markers, STT with built-in turn detection, a dedicated end-of-turn classifier, custom user code, etc.) judges when a turn is semantically complete and emits - :class:`~pipecat.frames.frames.UserTurnCompletedFrame`. + :class:`~pipecat.frames.frames.UserTurnInferenceCompletedFrame`. Pair this with one or more ``deferred(...)``-wrapped detector strategies that drive ``on_user_turn_inference_triggered`` but @@ -34,14 +34,14 @@ class ExternalUserTurnCompletionStopStrategy(BaseUserTurnStopStrategy): instead, which additionally pushes the ``LLMUpdateSettingsFrame`` that enables the marker protocol on the LLM. - If the producer never emits ``UserTurnCompletedFrame``, the + If the producer never emits ``UserTurnInferenceCompletedFrame``, the controller's ``user_turn_stop_timeout`` watchdog finalizes the turn after no activity. Tune that timeout if your producer can take longer than the default to respond. """ async def process_frame(self, frame: Frame) -> ProcessFrameResult: - """Fire ``on_user_turn_stopped`` whenever ``UserTurnCompletedFrame`` is seen.""" - if isinstance(frame, UserTurnCompletedFrame): + """Fire ``on_user_turn_stopped`` whenever ``UserTurnInferenceCompletedFrame`` is seen.""" + if isinstance(frame, UserTurnInferenceCompletedFrame): await self.trigger_user_turn_finalized() return ProcessFrameResult.CONTINUE diff --git a/src/pipecat/turns/user_stop/llm_turn_completion_user_turn_stop_strategy.py b/src/pipecat/turns/user_stop/llm_turn_completion_user_turn_stop_strategy.py index 08dea6df1..0f1a97f20 100644 --- a/src/pipecat/turns/user_stop/llm_turn_completion_user_turn_stop_strategy.py +++ b/src/pipecat/turns/user_stop/llm_turn_completion_user_turn_stop_strategy.py @@ -29,7 +29,7 @@ class LLMTurnCompletionUserTurnStopStrategy(ExternalUserTurnCompletionStopStrate Finalization itself is inherited: when the LLM service's :class:`~pipecat.turns.user_turn_completion_mixin.UserTurnCompletionLLMServiceMixin` detects a ``✓`` marker, it broadcasts a - :class:`~pipecat.frames.frames.UserTurnCompletedFrame` and the + :class:`~pipecat.frames.frames.UserTurnInferenceCompletedFrame` and the base class fires ``on_user_turn_stopped``. On ``incomplete_short`` / ``incomplete_long`` markers the mixin re-prompts internally and no completion frame is emitted, so the diff --git a/src/pipecat/turns/user_turn_completion_mixin.py b/src/pipecat/turns/user_turn_completion_mixin.py index 50ead0b82..cf600466d 100644 --- a/src/pipecat/turns/user_turn_completion_mixin.py +++ b/src/pipecat/turns/user_turn_completion_mixin.py @@ -26,7 +26,7 @@ from pipecat.frames.frames import ( LLMMessagesAppendFrame, LLMRunFrame, LLMTextFrame, - UserTurnCompletedFrame, + UserTurnInferenceCompletedFrame, ) from pipecat.processors.frame_processor import FrameDirection, FrameProcessor @@ -404,7 +404,7 @@ class UserTurnCompletionLLMServiceMixin(FrameProcessor): ) self._turn_suppressed = True - # No UserTurnCompletedFrame is broadcast here: the turn is + # No UserTurnInferenceCompletedFrame is broadcast here: the turn is # explicitly not complete. The re-prompt path is driven by # this mixin's own timeout. @@ -427,7 +427,7 @@ class UserTurnCompletionLLMServiceMixin(FrameProcessor): # LLMTurnCompletionUserTurnStopStrategy) can fire # `on_user_turn_stopped`. Must fire before the marker so # downstream consumers see the signal before the response. - await self.broadcast_frame(UserTurnCompletedFrame) + await self.broadcast_frame(UserTurnInferenceCompletedFrame) # Push the marker as a sideband signal that the assistant # aggregator will prepend to the upcoming aggregated text, diff --git a/tests/test_context_aggregators_universal.py b/tests/test_context_aggregators_universal.py index da3023b9b..a8ecace9d 100644 --- a/tests/test_context_aggregators_universal.py +++ b/tests/test_context_aggregators_universal.py @@ -570,7 +570,7 @@ class TestLLMUserAggregator(unittest.IsolatedAsyncioTestCase): async def test_inference_triggered_event_fires_on_default_strategies(self): """Default flow fires inference-triggered before stopped, both with the same strategy.""" - from pipecat.frames.frames import UserTurnCompletedFrame # noqa: F401 + from pipecat.frames.frames import UserTurnInferenceCompletedFrame # noqa: F401 context = LLMContext() user_aggregator = LLMUserAggregator( @@ -649,8 +649,8 @@ class TestLLMUserAggregator(unittest.IsolatedAsyncioTestCase): self.assertIsInstance(stop_strategies[1], LLMTurnCompletionUserTurnStopStrategy) async def test_llm_completion_strategy_finalizes_on_complete_marker(self): - """LLMTurnCompletionUserTurnStopStrategy finalizes only on UserTurnCompletedFrame(complete).""" - from pipecat.frames.frames import UserTurnCompletedFrame + """LLMTurnCompletionUserTurnStopStrategy finalizes only on UserTurnInferenceCompletedFrame(complete).""" + from pipecat.frames.frames import UserTurnInferenceCompletedFrame from pipecat.turns.user_stop import LLMTurnCompletionUserTurnStopStrategy, deferred gating = LLMTurnCompletionUserTurnStopStrategy() @@ -678,7 +678,7 @@ class TestLLMUserAggregator(unittest.IsolatedAsyncioTestCase): pipeline = Pipeline([user_aggregator]) # Drive the pipeline. Inference fires after the upstream - # strategy's timeout. Stop fires only when UserTurnCompletedFrame + # strategy's timeout. Stop fires only when UserTurnInferenceCompletedFrame # arrives (producer absence == "not yet complete"). frames_to_send = [ VADUserStartedSpeakingFrame(), @@ -687,7 +687,7 @@ class TestLLMUserAggregator(unittest.IsolatedAsyncioTestCase): VADUserStoppedSpeakingFrame(), SleepFrame(sleep=TRANSCRIPTION_TIMEOUT + 0.1), # At this point inference_triggered should have fired but NOT stopped. - UserTurnCompletedFrame(), + UserTurnInferenceCompletedFrame(), SleepFrame(), ] await run_test(pipeline, frames_to_send=frames_to_send) @@ -703,7 +703,7 @@ class TestLLMUserAggregator(unittest.IsolatedAsyncioTestCase): and the conversation context should reflect the full user utterance, not just the segment from the last inference. """ - from pipecat.frames.frames import UserTurnCompletedFrame + from pipecat.frames.frames import UserTurnInferenceCompletedFrame from pipecat.turns.user_stop import LLMTurnCompletionUserTurnStopStrategy, deferred gating = LLMTurnCompletionUserTurnStopStrategy() @@ -747,8 +747,8 @@ class TestLLMUserAggregator(unittest.IsolatedAsyncioTestCase): VADUserStoppedSpeakingFrame(), SleepFrame(sleep=TRANSCRIPTION_TIMEOUT + 0.1), # Second inference fired here. Now the LLM returns ✓ and the - # turn finalizes via UserTurnCompletedFrame. - UserTurnCompletedFrame(), + # turn finalizes via UserTurnInferenceCompletedFrame. + UserTurnInferenceCompletedFrame(), SleepFrame(), ] await run_test(pipeline, frames_to_send=frames_to_send) diff --git a/tests/test_user_turn_completion_mixin.py b/tests/test_user_turn_completion_mixin.py index 0947d0a3c..5a4f0c640 100644 --- a/tests/test_user_turn_completion_mixin.py +++ b/tests/test_user_turn_completion_mixin.py @@ -12,7 +12,7 @@ from pipecat.frames.frames import ( LLMFullResponseEndFrame, LLMMarkerFrame, LLMTextFrame, - UserTurnCompletedFrame, + UserTurnInferenceCompletedFrame, ) from pipecat.processors.frame_processor import FrameProcessor from pipecat.services.llm_service import LLMService @@ -61,8 +61,8 @@ class TestUserUserTurnCompletionLLMServiceMixin(unittest.IsolatedAsyncioTestCase self.assertEqual(marker_frames[0].marker, USER_TURN_COMPLETE_MARKER) self.assertFalse(marker_frames[0].append_to_context_immediately) - # UserTurnCompletedFrame broadcast in both directions. - completed = [f for f in pushed_frames if isinstance(f, UserTurnCompletedFrame)] + # UserTurnInferenceCompletedFrame broadcast in both directions. + completed = [f for f in pushed_frames if isinstance(f, UserTurnInferenceCompletedFrame)] self.assertEqual(len(completed), 2) async def test_incomplete_short_marker_suppresses_text(self): @@ -87,8 +87,8 @@ class TestUserUserTurnCompletionLLMServiceMixin(unittest.IsolatedAsyncioTestCase self.assertEqual(marker_frames[0].marker, USER_TURN_INCOMPLETE_SHORT_MARKER) self.assertTrue(marker_frames[0].append_to_context_immediately) - # Incomplete markers do not emit UserTurnCompletedFrame. - completed = [f for f in pushed_frames if isinstance(f, UserTurnCompletedFrame)] + # Incomplete markers do not emit UserTurnInferenceCompletedFrame. + completed = [f for f in pushed_frames if isinstance(f, UserTurnInferenceCompletedFrame)] self.assertEqual(len(completed), 0) async def test_incomplete_long_marker_suppresses_text(self): @@ -112,7 +112,7 @@ class TestUserUserTurnCompletionLLMServiceMixin(unittest.IsolatedAsyncioTestCase self.assertEqual(marker_frames[0].marker, USER_TURN_INCOMPLETE_LONG_MARKER) self.assertTrue(marker_frames[0].append_to_context_immediately) - completed = [f for f in pushed_frames if isinstance(f, UserTurnCompletedFrame)] + completed = [f for f in pushed_frames if isinstance(f, UserTurnInferenceCompletedFrame)] self.assertEqual(len(completed), 0) async def test_text_buffered_until_marker_found(self): @@ -135,7 +135,7 @@ class TestUserUserTurnCompletionLLMServiceMixin(unittest.IsolatedAsyncioTestCase await processor._push_turn_text(f" {USER_TURN_COMPLETE_MARKER} How are you?") # One LLMTextFrame for the spoken portion; one LLMMarkerFrame for - # the marker; UserTurnCompletedFrame broadcast in both directions. + # the marker; UserTurnInferenceCompletedFrame broadcast in both directions. text_frames = [f for f in pushed_frames if isinstance(f, LLMTextFrame)] self.assertEqual(len(text_frames), 1) marker_frames = [f for f in pushed_frames if isinstance(f, LLMMarkerFrame)]