Rename UserTurnCompletedFrame to UserTurnInferenceCompletedFrame

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`.
This commit is contained in:
Aleix Conchillo Flaqué
2026-05-06 11:17:56 -07:00
parent 952dddca8b
commit b78cecf7b2
8 changed files with 29 additions and 29 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -1005,7 +1005,7 @@ class UserSpeakingFrame(SystemFrame):
@dataclass @dataclass
class UserTurnCompletedFrame(SystemFrame): class UserTurnInferenceCompletedFrame(SystemFrame):
"""Frame indicating that the user turn is semantically complete. """Frame indicating that the user turn is semantically complete.
Emitted by any component that can judge conversational turn Emitted by any component that can judge conversational turn

View File

@@ -4,21 +4,21 @@
# SPDX-License-Identifier: BSD 2-Clause License # 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.types import ProcessFrameResult
from pipecat.turns.user_stop.base_user_turn_stop_strategy import BaseUserTurnStopStrategy from pipecat.turns.user_stop.base_user_turn_stop_strategy import BaseUserTurnStopStrategy
class ExternalUserTurnCompletionStopStrategy(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 Generic stop strategy for pipelines where some external component
(LLM with completion markers, STT with built-in turn detection, a (LLM with completion markers, STT with built-in turn detection, a
dedicated end-of-turn classifier, custom user code, etc.) judges dedicated end-of-turn classifier, custom user code, etc.) judges
when a turn is semantically complete and emits 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 Pair this with one or more ``deferred(...)``-wrapped detector
strategies that drive ``on_user_turn_inference_triggered`` but strategies that drive ``on_user_turn_inference_triggered`` but
@@ -34,14 +34,14 @@ class ExternalUserTurnCompletionStopStrategy(BaseUserTurnStopStrategy):
instead, which additionally pushes the ``LLMUpdateSettingsFrame`` instead, which additionally pushes the ``LLMUpdateSettingsFrame``
that enables the marker protocol on the LLM. 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 controller's ``user_turn_stop_timeout`` watchdog finalizes the
turn after no activity. Tune that timeout if your producer can turn after no activity. Tune that timeout if your producer can
take longer than the default to respond. take longer than the default to respond.
""" """
async def process_frame(self, frame: Frame) -> ProcessFrameResult: async def process_frame(self, frame: Frame) -> ProcessFrameResult:
"""Fire ``on_user_turn_stopped`` whenever ``UserTurnCompletedFrame`` is seen.""" """Fire ``on_user_turn_stopped`` whenever ``UserTurnInferenceCompletedFrame`` is seen."""
if isinstance(frame, UserTurnCompletedFrame): if isinstance(frame, UserTurnInferenceCompletedFrame):
await self.trigger_user_turn_finalized() await self.trigger_user_turn_finalized()
return ProcessFrameResult.CONTINUE return ProcessFrameResult.CONTINUE

View File

@@ -29,7 +29,7 @@ class LLMTurnCompletionUserTurnStopStrategy(ExternalUserTurnCompletionStopStrate
Finalization itself is inherited: when the LLM service's Finalization itself is inherited: when the LLM service's
:class:`~pipecat.turns.user_turn_completion_mixin.UserTurnCompletionLLMServiceMixin` :class:`~pipecat.turns.user_turn_completion_mixin.UserTurnCompletionLLMServiceMixin`
detects a ``✓`` marker, it broadcasts a 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 base class fires ``on_user_turn_stopped``. On
``incomplete_short`` / ``incomplete_long`` markers the mixin ``incomplete_short`` / ``incomplete_long`` markers the mixin
re-prompts internally and no completion frame is emitted, so the re-prompts internally and no completion frame is emitted, so the

View File

@@ -26,7 +26,7 @@ from pipecat.frames.frames import (
LLMMessagesAppendFrame, LLMMessagesAppendFrame,
LLMRunFrame, LLMRunFrame,
LLMTextFrame, LLMTextFrame,
UserTurnCompletedFrame, UserTurnInferenceCompletedFrame,
) )
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
@@ -404,7 +404,7 @@ class UserTurnCompletionLLMServiceMixin(FrameProcessor):
) )
self._turn_suppressed = True 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 # explicitly not complete. The re-prompt path is driven by
# this mixin's own timeout. # this mixin's own timeout.
@@ -427,7 +427,7 @@ class UserTurnCompletionLLMServiceMixin(FrameProcessor):
# LLMTurnCompletionUserTurnStopStrategy) can fire # LLMTurnCompletionUserTurnStopStrategy) can fire
# `on_user_turn_stopped`. Must fire before the marker so # `on_user_turn_stopped`. Must fire before the marker so
# downstream consumers see the signal before the response. # 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 # Push the marker as a sideband signal that the assistant
# aggregator will prepend to the upcoming aggregated text, # aggregator will prepend to the upcoming aggregated text,

View File

@@ -570,7 +570,7 @@ class TestLLMUserAggregator(unittest.IsolatedAsyncioTestCase):
async def test_inference_triggered_event_fires_on_default_strategies(self): async def test_inference_triggered_event_fires_on_default_strategies(self):
"""Default flow fires inference-triggered before stopped, both with the same strategy.""" """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() context = LLMContext()
user_aggregator = LLMUserAggregator( user_aggregator = LLMUserAggregator(
@@ -649,8 +649,8 @@ class TestLLMUserAggregator(unittest.IsolatedAsyncioTestCase):
self.assertIsInstance(stop_strategies[1], LLMTurnCompletionUserTurnStopStrategy) self.assertIsInstance(stop_strategies[1], LLMTurnCompletionUserTurnStopStrategy)
async def test_llm_completion_strategy_finalizes_on_complete_marker(self): async def test_llm_completion_strategy_finalizes_on_complete_marker(self):
"""LLMTurnCompletionUserTurnStopStrategy finalizes only on UserTurnCompletedFrame(complete).""" """LLMTurnCompletionUserTurnStopStrategy finalizes only on UserTurnInferenceCompletedFrame(complete)."""
from pipecat.frames.frames import UserTurnCompletedFrame from pipecat.frames.frames import UserTurnInferenceCompletedFrame
from pipecat.turns.user_stop import LLMTurnCompletionUserTurnStopStrategy, deferred from pipecat.turns.user_stop import LLMTurnCompletionUserTurnStopStrategy, deferred
gating = LLMTurnCompletionUserTurnStopStrategy() gating = LLMTurnCompletionUserTurnStopStrategy()
@@ -678,7 +678,7 @@ class TestLLMUserAggregator(unittest.IsolatedAsyncioTestCase):
pipeline = Pipeline([user_aggregator]) pipeline = Pipeline([user_aggregator])
# Drive the pipeline. Inference fires after the upstream # 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"). # arrives (producer absence == "not yet complete").
frames_to_send = [ frames_to_send = [
VADUserStartedSpeakingFrame(), VADUserStartedSpeakingFrame(),
@@ -687,7 +687,7 @@ class TestLLMUserAggregator(unittest.IsolatedAsyncioTestCase):
VADUserStoppedSpeakingFrame(), VADUserStoppedSpeakingFrame(),
SleepFrame(sleep=TRANSCRIPTION_TIMEOUT + 0.1), SleepFrame(sleep=TRANSCRIPTION_TIMEOUT + 0.1),
# At this point inference_triggered should have fired but NOT stopped. # At this point inference_triggered should have fired but NOT stopped.
UserTurnCompletedFrame(), UserTurnInferenceCompletedFrame(),
SleepFrame(), SleepFrame(),
] ]
await run_test(pipeline, frames_to_send=frames_to_send) 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 and the conversation context should reflect the full user
utterance, not just the segment from the last inference. 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 from pipecat.turns.user_stop import LLMTurnCompletionUserTurnStopStrategy, deferred
gating = LLMTurnCompletionUserTurnStopStrategy() gating = LLMTurnCompletionUserTurnStopStrategy()
@@ -747,8 +747,8 @@ class TestLLMUserAggregator(unittest.IsolatedAsyncioTestCase):
VADUserStoppedSpeakingFrame(), VADUserStoppedSpeakingFrame(),
SleepFrame(sleep=TRANSCRIPTION_TIMEOUT + 0.1), SleepFrame(sleep=TRANSCRIPTION_TIMEOUT + 0.1),
# Second inference fired here. Now the LLM returns ✓ and the # Second inference fired here. Now the LLM returns ✓ and the
# turn finalizes via UserTurnCompletedFrame. # turn finalizes via UserTurnInferenceCompletedFrame.
UserTurnCompletedFrame(), UserTurnInferenceCompletedFrame(),
SleepFrame(), SleepFrame(),
] ]
await run_test(pipeline, frames_to_send=frames_to_send) await run_test(pipeline, frames_to_send=frames_to_send)

View File

@@ -12,7 +12,7 @@ from pipecat.frames.frames import (
LLMFullResponseEndFrame, LLMFullResponseEndFrame,
LLMMarkerFrame, LLMMarkerFrame,
LLMTextFrame, LLMTextFrame,
UserTurnCompletedFrame, UserTurnInferenceCompletedFrame,
) )
from pipecat.processors.frame_processor import FrameProcessor from pipecat.processors.frame_processor import FrameProcessor
from pipecat.services.llm_service import LLMService 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.assertEqual(marker_frames[0].marker, USER_TURN_COMPLETE_MARKER)
self.assertFalse(marker_frames[0].append_to_context_immediately) self.assertFalse(marker_frames[0].append_to_context_immediately)
# UserTurnCompletedFrame broadcast in both directions. # UserTurnInferenceCompletedFrame broadcast in both directions.
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), 2) self.assertEqual(len(completed), 2)
async def test_incomplete_short_marker_suppresses_text(self): 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.assertEqual(marker_frames[0].marker, USER_TURN_INCOMPLETE_SHORT_MARKER)
self.assertTrue(marker_frames[0].append_to_context_immediately) self.assertTrue(marker_frames[0].append_to_context_immediately)
# Incomplete markers do not emit UserTurnCompletedFrame. # Incomplete markers do not emit UserTurnInferenceCompletedFrame.
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) self.assertEqual(len(completed), 0)
async def test_incomplete_long_marker_suppresses_text(self): 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.assertEqual(marker_frames[0].marker, USER_TURN_INCOMPLETE_LONG_MARKER)
self.assertTrue(marker_frames[0].append_to_context_immediately) 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) self.assertEqual(len(completed), 0)
async def test_text_buffered_until_marker_found(self): 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?") await processor._push_turn_text(f" {USER_TURN_COMPLETE_MARKER} How are you?")
# One LLMTextFrame for the spoken portion; one LLMMarkerFrame for # 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)] text_frames = [f for f in pushed_frames if isinstance(f, LLMTextFrame)]
self.assertEqual(len(text_frames), 1) self.assertEqual(len(text_frames), 1)
marker_frames = [f for f in pushed_frames if isinstance(f, LLMMarkerFrame)] marker_frames = [f for f in pushed_frames if isinstance(f, LLMMarkerFrame)]