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
class UserTurnCompletedFrame(SystemFrame):
class UserTurnInferenceCompletedFrame(SystemFrame):
"""Frame indicating that the user turn is semantically complete.
Emitted by any component that can judge conversational turn

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)]