From 4b61fd2d7d893abfe0f382d076abde519bf68a6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleix=20Conchillo=20Flaqu=C3=A9?= Date: Thu, 8 Jan 2026 11:49:12 -0800 Subject: [PATCH 01/11] LLMUserAggregator: add user turn stopped message argument It is now possible to get the user aggregation when a `on_user_turn_stopped` event is emitted. --- .../aggregators/llm_response_universal.py | 44 +++++++++++++++---- tests/test_context_aggregators_universal.py | 16 ++++--- 2 files changed, 46 insertions(+), 14 deletions(-) diff --git a/src/pipecat/processors/aggregators/llm_response_universal.py b/src/pipecat/processors/aggregators/llm_response_universal.py index fb5fff914..7b29fb8e1 100644 --- a/src/pipecat/processors/aggregators/llm_response_universal.py +++ b/src/pipecat/processors/aggregators/llm_response_universal.py @@ -102,6 +102,23 @@ class LLMAssistantAggregatorParams: expect_stripped_words: bool = True +@dataclass +class UserTurnStoppedMessage: + """A user turn stopped message containing a user transcript update. + + A message in a conversation transcript containing the user content. This is + the aggregated transcript that is then used in the context. + + Parameters: + content: The message content/text. + user_id: Optional identifier for the user. + + """ + + content: str + user_id: Optional[str] = None + + class LLMContextAggregator(FrameProcessor): """Base LLM aggregator that uses an LLMContext for conversation storage. @@ -205,8 +222,12 @@ class LLMContextAggregator(FrameProcessor): self._aggregation = [] @abstractmethod - async def push_aggregation(self): - """Push the current aggregation downstream.""" + async def push_aggregation(self) -> str: + """Push the current aggregation downstream. + + Returns: + The pushed aggregation. + """ pass def aggregation_string(self) -> str: @@ -243,7 +264,7 @@ class LLMUserAggregator(LLMContextAggregator): ... @aggregator.event_handler("on_user_turn_stopped") - async def on_user_turn_stopped(aggregator, strategy: BaseUserTurnStopStrategy): + async def on_user_turn_stopped(aggregator, strategy: BaseUserTurnStopStrategy, message: UserTurnStoppedMessage): ... @aggregator.event_handler("on_user_turn_stop_timeout") @@ -348,16 +369,18 @@ class LLMUserAggregator(LLMContextAggregator): await self._user_turn_controller.process_frame(frame) - async def push_aggregation(self): + async def push_aggregation(self) -> str: """Push the current aggregation.""" if len(self._aggregation) == 0: - return + return "" aggregation = self.aggregation_string() await self.reset() self._context.add_message({"role": self.role, "content": aggregation}) await self.push_context_frame() + return aggregation + async def _start(self, frame: StartFrame): await self._user_turn_controller.setup(self.task_manager) @@ -493,9 +516,10 @@ class LLMUserAggregator(LLMContextAggregator): await self.broadcast_frame(UserStoppedSpeakingFrame) # Always push context frame. - await self.push_aggregation() + aggregation = await self.push_aggregation() - await self._call_event_handler("on_user_turn_stopped", strategy) + message = UserTurnStoppedMessage(content=aggregation) + await self._call_event_handler("on_user_turn_stopped", strategy, message) async def _on_user_turn_stop_timeout(self, controller): await self._call_event_handler("on_user_turn_stop_timeout") @@ -633,10 +657,10 @@ class LLMAssistantAggregator(LLMContextAggregator): else: await self.push_frame(frame, direction) - async def push_aggregation(self): + async def push_aggregation(self) -> str: """Push the current assistant aggregation with timestamp.""" if not self._aggregation: - return + return "" aggregation = self.aggregation_string() await self.reset() @@ -651,6 +675,8 @@ class LLMAssistantAggregator(LLMContextAggregator): timestamp_frame = LLMContextAssistantTimestampFrame(timestamp=time_now_iso8601()) await self.push_frame(timestamp_frame) + return aggregation + async def _handle_llm_run(self, frame: LLMRunFrame): await self.push_context_frame(FrameDirection.UPSTREAM) diff --git a/tests/test_context_aggregators_universal.py b/tests/test_context_aggregators_universal.py index 415ef26ea..94acf383a 100644 --- a/tests/test_context_aggregators_universal.py +++ b/tests/test_context_aggregators_universal.py @@ -143,6 +143,7 @@ class TestLLMUserAggregator(unittest.IsolatedAsyncioTestCase): should_start = None should_stop = None + stop_message = None @user_aggregator.event_handler("on_user_turn_started") async def on_user_turn_started(aggregator, strategy): @@ -150,9 +151,10 @@ class TestLLMUserAggregator(unittest.IsolatedAsyncioTestCase): should_start = True @user_aggregator.event_handler("on_user_turn_stopped") - async def on_user_turn_stopped(aggregator, strategy): - nonlocal should_stop + async def on_user_turn_stopped(aggregator, strategy, message): + nonlocal should_stop, stop_message should_stop = True + stop_message = message pipeline = Pipeline([user_aggregator]) @@ -177,6 +179,7 @@ class TestLLMUserAggregator(unittest.IsolatedAsyncioTestCase): ) self.assertTrue(should_start) self.assertTrue(should_stop) + self.assertEqual(stop_message.content, "Hello!") async def test_user_turn_stop_timeout_no_transcription(self): context = LLMContext() @@ -196,7 +199,7 @@ class TestLLMUserAggregator(unittest.IsolatedAsyncioTestCase): should_start = True @user_aggregator.event_handler("on_user_turn_stopped") - async def on_user_turn_stopped(aggregator, strategy): + async def on_user_turn_stopped(aggregator, strategy, message): nonlocal should_stop should_stop = True @@ -236,6 +239,7 @@ class TestLLMUserAggregator(unittest.IsolatedAsyncioTestCase): should_start = None should_stop = None + stop_message = None timeout = None @user_aggregator.event_handler("on_user_turn_started") @@ -244,9 +248,10 @@ class TestLLMUserAggregator(unittest.IsolatedAsyncioTestCase): should_start = True @user_aggregator.event_handler("on_user_turn_stopped") - async def on_user_turn_stopped(aggregator, strategy): - nonlocal should_stop + async def on_user_turn_stopped(aggregator, strategy, message): + nonlocal should_stop, stop_message should_stop = True + stop_message = message @user_aggregator.event_handler("on_user_turn_stop_timeout") async def on_user_turn_stop_timeout(aggregator): @@ -271,6 +276,7 @@ class TestLLMUserAggregator(unittest.IsolatedAsyncioTestCase): # The transcription strategy should kick-in before the user turn end timeout. self.assertTrue(should_start) self.assertTrue(should_stop) + self.assertEqual(stop_message.content, "Hello!") self.assertFalse(timeout) async def test_user_mute_strategies(self): From cdb1074e1167b8db3f9cddaa6a8b14892b7a254b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleix=20Conchillo=20Flaqu=C3=A9?= Date: Thu, 8 Jan 2026 16:30:22 -0800 Subject: [PATCH 02/11] LLMAssistantAggregator: no need to use BotStoppedSpeakingFrame The end of turn is already handle with interruptions or with LLMFullResponseEndFrame. LLMFullResponseEndFrame should never be blocked, otherwise the assistant would not work. --- src/pipecat/processors/aggregators/llm_response_universal.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/pipecat/processors/aggregators/llm_response_universal.py b/src/pipecat/processors/aggregators/llm_response_universal.py index 7b29fb8e1..3965acbdb 100644 --- a/src/pipecat/processors/aggregators/llm_response_universal.py +++ b/src/pipecat/processors/aggregators/llm_response_universal.py @@ -23,7 +23,6 @@ from loguru import logger from pipecat.adapters.schemas.tools_schema import ToolsSchema from pipecat.frames.frames import ( AssistantImageRawFrame, - BotStoppedSpeakingFrame, CancelFrame, EndFrame, Frame, @@ -651,9 +650,6 @@ class LLMAssistantAggregator(LLMContextAggregator): await self._handle_user_image_frame(frame) elif isinstance(frame, AssistantImageRawFrame): await self._handle_assistant_image_frame(frame) - elif isinstance(frame, BotStoppedSpeakingFrame): - await self.push_aggregation() - await self.push_frame(frame, direction) else: await self.push_frame(frame, direction) From 38d354c4edb563639e15269bca285b4871ce71c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleix=20Conchillo=20Flaqu=C3=A9?= Date: Thu, 8 Jan 2026 16:45:32 -0800 Subject: [PATCH 03/11] LLMAssistantAggregator: add assistant turn and thought events --- .../aggregators/llm_response_universal.py | 81 +++++++++++++++++-- 1 file changed, 76 insertions(+), 5 deletions(-) diff --git a/src/pipecat/processors/aggregators/llm_response_universal.py b/src/pipecat/processors/aggregators/llm_response_universal.py index 3965acbdb..83395ee98 100644 --- a/src/pipecat/processors/aggregators/llm_response_universal.py +++ b/src/pipecat/processors/aggregators/llm_response_universal.py @@ -118,6 +118,38 @@ class UserTurnStoppedMessage: user_id: Optional[str] = None +@dataclass +class AssistantTurnStoppedMessage: + """An assistant turn stopped message containing an assistant transcript update. + + A message in a conversation transcript containing the assistant + content. This is the aggregated transcript that is then used in the context. + + Parameters: + content: The message content/text. + + """ + + content: str + + +@dataclass +class AssistantThoughtMessage: + """An assistant thought message containing an assistant thought update. + + A message in a conversation transcript containing the assistant thought + content. + + Parameters: + content: The message content/text. + timestamp: When the thought was started. + + """ + + content: str + timestamp: str + + class LLMContextAggregator(FrameProcessor): """Base LLM aggregator that uses an LLMContext for conversation storage. @@ -537,6 +569,27 @@ class LLMAssistantAggregator(LLMContextAggregator): The aggregator manages function calls in progress and coordinates between text generation and tool execution phases of LLM responses. + + Event handlers available: + + - on_assistant_turn_started: Called when the assistant turn starts + - on_assistant_turn_stopped: Called when the assistant turn ends + - on_assistant_thought: Called when an assistant thought is available + + Example:: + + @aggregator.event_handler("on_assistant_turn_started") + async def on_assistant_turn_started(aggregator): + ... + + @aggregator.event_handler("on_assistant_turn_stopped") + async def on_assistant_turn_stopped(aggregator, message: AssistantTurnStoppedMessage): + ... + + @aggregator.event_handler("on_assistant_thought") + async def on_assistant_thought(aggregator, message: AssistantThoughtMessage): + ... + """ def __init__( @@ -583,6 +636,11 @@ class LLMAssistantAggregator(LLMContextAggregator): self._thought_aggregation_enabled = False self._thought_llm: str = "" self._thought_aggregation: List[TextPartForConcatenation] = [] + self._thought_start_time: str = "" + + self._register_event_handler("on_assistant_turn_started") + self._register_event_handler("on_assistant_turn_stopped") + self._register_event_handler("on_assistant_thought") @property def has_function_calls_in_progress(self) -> bool: @@ -661,8 +719,7 @@ class LLMAssistantAggregator(LLMContextAggregator): aggregation = self.aggregation_string() await self.reset() - if aggregation: - self._context.add_message({"role": "assistant", "content": aggregation}) + self._context.add_message({"role": "assistant", "content": aggregation}) # Push context frame await self.push_context_frame() @@ -687,7 +744,7 @@ class LLMAssistantAggregator(LLMContextAggregator): await self.push_context_frame(FrameDirection.UPSTREAM) async def _handle_interruptions(self, frame: InterruptionFrame): - await self.push_aggregation() + await self._trigger_assistant_turn_stopped() self._started = 0 await self.reset() @@ -810,7 +867,7 @@ class LLMAssistantAggregator(LLMContextAggregator): text=frame.text, ) - await self.push_aggregation() + await self._trigger_assistant_turn_stopped() await self.push_context_frame(FrameDirection.UPSTREAM) async def _handle_assistant_image_frame(self, frame: AssistantImageRawFrame): @@ -833,10 +890,11 @@ class LLMAssistantAggregator(LLMContextAggregator): async def _handle_llm_start(self, _: LLMFullResponseStartFrame): self._started += 1 + await self._trigger_assistant_turn_started() async def _handle_llm_end(self, _: LLMFullResponseEndFrame): self._started -= 1 - await self.push_aggregation() + await self._trigger_assistant_turn_stopped() async def _handle_text(self, frame: TextFrame): if not self._started or not frame.append_to_context: @@ -859,6 +917,7 @@ class LLMAssistantAggregator(LLMContextAggregator): await self._reset_thought_aggregation() self._thought_aggregation_enabled = frame.append_to_context self._thought_llm = frame.llm + self._thought_start_time = time_now_iso8601() async def _handle_thought_text(self, frame: LLMThoughtTextFrame): if not self._started or not self._thought_aggregation_enabled: @@ -893,9 +952,21 @@ class LLMAssistantAggregator(LLMContextAggregator): ) ) + message = AssistantThoughtMessage(content=thought, timestamp=self._thought_start_time) + await self._call_event_handler("on_assistant_thought", message) + def _context_updated_task_finished(self, task: asyncio.Task): self._context_updated_tasks.discard(task) + async def _trigger_assistant_turn_started(self): + await self._call_event_handler("on_assistant_turn_started") + + async def _trigger_assistant_turn_stopped(self): + aggregation = await self.push_aggregation() + if aggregation: + message = AssistantTurnStoppedMessage(content=aggregation) + await self._call_event_handler("on_assistant_turn_stopped", message) + class LLMContextAggregatorPair: """Pair of LLM context aggregators for updating context with user and assistant messages.""" From 119fab2996d19129df6d42b967c06ba5fb8e8939 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleix=20Conchillo=20Flaqu=C3=A9?= Date: Thu, 8 Jan 2026 19:06:03 -0800 Subject: [PATCH 04/11] LLMAssistantAggregator: allow thought aggregation without appending to context --- .../aggregators/llm_response_universal.py | 31 ++++++++++--------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/src/pipecat/processors/aggregators/llm_response_universal.py b/src/pipecat/processors/aggregators/llm_response_universal.py index 83395ee98..ca8870f60 100644 --- a/src/pipecat/processors/aggregators/llm_response_universal.py +++ b/src/pipecat/processors/aggregators/llm_response_universal.py @@ -633,7 +633,7 @@ class LLMAssistantAggregator(LLMContextAggregator): self._function_calls_in_progress: Dict[str, Optional[FunctionCallInProgressFrame]] = {} self._context_updated_tasks: Set[asyncio.Task] = set() - self._thought_aggregation_enabled = False + self._thought_append_to_context = False self._thought_llm: str = "" self._thought_aggregation: List[TextPartForConcatenation] = [] self._thought_start_time: str = "" @@ -658,7 +658,7 @@ class LLMAssistantAggregator(LLMContextAggregator): async def _reset_thought_aggregation(self): """Reset the thought aggregation state.""" - self._thought_aggregation_enabled = False + self._thought_append_to_context = False self._thought_llm = "" self._thought_aggregation = [] @@ -915,12 +915,12 @@ class LLMAssistantAggregator(LLMContextAggregator): return await self._reset_thought_aggregation() - self._thought_aggregation_enabled = frame.append_to_context + self._thought_append_to_context = frame.append_to_context self._thought_llm = frame.llm self._thought_start_time = time_now_iso8601() async def _handle_thought_text(self, frame: LLMThoughtTextFrame): - if not self._started or not self._thought_aggregation_enabled: + if not self._started: return # Make sure we really have text (spaces count, too!) @@ -934,23 +934,24 @@ class LLMAssistantAggregator(LLMContextAggregator): ) async def _handle_thought_end(self, frame: LLMThoughtEndFrame): - if not self._started or not self._thought_aggregation_enabled: + if not self._started: return thought = concatenate_aggregated_text(self._thought_aggregation) - llm = self._thought_llm await self._reset_thought_aggregation() - self._context.add_message( - LLMSpecificMessage( - llm=llm, - message={ - "type": "thought", - "text": thought, - "signature": frame.signature, - }, + if self._thought_append_to_context: + llm = self._thought_llm + self._context.add_message( + LLMSpecificMessage( + llm=llm, + message={ + "type": "thought", + "text": thought, + "signature": frame.signature, + }, + ) ) - ) message = AssistantThoughtMessage(content=thought, timestamp=self._thought_start_time) await self._call_event_handler("on_assistant_thought", message) From 5cbb21afb2a8fad6429ce3743bad1e3717af84ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleix=20Conchillo=20Flaqu=C3=A9?= Date: Thu, 8 Jan 2026 16:46:20 -0800 Subject: [PATCH 05/11] deprecate TranscriptProcessor and related dataclasses and frames --- changelog/3385.deprecated.md | 1 + src/pipecat/frames/frames.py | 79 ++++++++++++++++++- .../processors/transcript_processor.py | 14 ++++ 3 files changed, 91 insertions(+), 3 deletions(-) create mode 100644 changelog/3385.deprecated.md diff --git a/changelog/3385.deprecated.md b/changelog/3385.deprecated.md new file mode 100644 index 000000000..e810af08a --- /dev/null +++ b/changelog/3385.deprecated.md @@ -0,0 +1 @@ +- `TranscriptProcessor` and related data classes and frames (`TranscriptionMessage`, `ThoughtTranscriptionMessage`, `TranscriptionUpdateFrame`) are deprecated. Use `LLMUserAggregator`'s and `LLMAssistantAggregator`'s new events (`on_user_turn_stopped` and `on_assistant_turn_stopped`) instead. diff --git a/src/pipecat/frames/frames.py b/src/pipecat/frames/frames.py index f2993ea74..fb0f8243b 100644 --- a/src/pipecat/frames/frames.py +++ b/src/pipecat/frames/frames.py @@ -536,6 +536,10 @@ class TranscriptionMessage: content: The message content/text. user_id: Optional identifier for the user. timestamp: Optional timestamp when the message was created. + + .. deprecated:: 0.0.99 + `TranscriptionMessage` is deprecated and will be removed in a future version. + Use `LLMUserAggregator`'s and `LLMAssistantAggregator`'s new events instead. """ role: Literal["user", "assistant"] @@ -543,15 +547,44 @@ class TranscriptionMessage: user_id: Optional[str] = None timestamp: Optional[str] = None + def __post_init__(self): + import warnings + + with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn( + "TranscriptionMessage is deprecated and will be removed in a future version. " + "Use `LLMUserAggregator`'s and `LLMAssistantAggregator`'s new events instead.", + DeprecationWarning, + stacklevel=2, + ) + @dataclass class ThoughtTranscriptionMessage: - """An LLM thought message in a conversation transcript.""" + """An LLM thought message in a conversation transcript. + + .. deprecated:: 0.0.99 + `ThoughtTranscriptionMessage` is deprecated and will be removed in a future version. + Use `LLMAssistantAggregator`'s new events instead. + """ role: Literal["assistant"] = field(default="assistant", init=False) content: str timestamp: Optional[str] = None + def __post_init__(self): + import warnings + + with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn( + "ThoughtTranscriptionMessage is deprecated and will be removed in a future version. " + "Use `LLMAssistantAggregator`'s new events instead.", + DeprecationWarning, + stacklevel=2, + ) + @dataclass class TranscriptionUpdateFrame(DataFrame): @@ -595,10 +628,28 @@ class TranscriptionUpdateFrame(DataFrame): Parameters: messages: List of new transcript messages that were added. + + .. deprecated:: 0.0.99 + `TranscriptionUpdateFrame` is deprecated and will be removed in a future version. + Use `LLMUserAggregator`'s and `LLMAssistantAggregator`'s new events instead. """ messages: List[TranscriptionMessage | ThoughtTranscriptionMessage] + def __post_init__(self): + super().__post_init__() + + import warnings + + with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn( + "TranscriptionUpdateFrame is deprecated and will be removed in a future version. " + "Use `LLMUserAggregator`'s and `LLMAssistantAggregator`'s new events instead.", + DeprecationWarning, + stacklevel=2, + ) + def __str__(self): pts = format_pts(self.pts) return f"{self.name}(pts: {pts}, messages: {len(self.messages)})" @@ -1160,7 +1211,18 @@ class EmulateUserStartedSpeakingFrame(SystemFrame): This frame is deprecated and will be removed in a future version. """ - pass + def __post_init__(self): + super().__post_init__() + + import warnings + + with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn( + "EmulateUserStartedSpeakingFrame is deprecated and will be removed in a future version.", + DeprecationWarning, + stacklevel=2, + ) @dataclass @@ -1174,7 +1236,18 @@ class EmulateUserStoppedSpeakingFrame(SystemFrame): This frame is deprecated and will be removed in a future version. """ - pass + def __post_init__(self): + super().__post_init__() + + import warnings + + with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn( + "EmulateUserStoppedSpeakingFrame is deprecated and will be removed in a future version.", + DeprecationWarning, + stacklevel=2, + ) @dataclass diff --git a/src/pipecat/processors/transcript_processor.py b/src/pipecat/processors/transcript_processor.py index be59bd9ef..2c9fe2917 100644 --- a/src/pipecat/processors/transcript_processor.py +++ b/src/pipecat/processors/transcript_processor.py @@ -269,6 +269,10 @@ class TranscriptProcessor: @transcript.event_handler("on_transcript_update") async def handle_update(processor, frame): print(f"New messages: {frame.messages}") + + .. deprecated:: 0.0.99 + `TranscriptProcessor` is deprecated and will be removed in a future version. + Use `LLMUserAggregator`'s and `LLMAssistantAggregator`'s new events instead. """ def __init__(self, *, process_thoughts: bool = False): @@ -283,6 +287,16 @@ class TranscriptProcessor: self._assistant_processor = None self._event_handlers = {} + import warnings + + with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn( + "`TranscriptProcessor` is deprecated and will be removed in a future version. " + "Use `LLMUserAggregator`'s and `LLMAssistantAggregator`'s new events instead.", + DeprecationWarning, + ) + def user(self, **kwargs) -> UserTranscriptProcessor: """Get the user transcript processor. From 5f9e95038e6a14cf1c459a06822d3eee2ec16b73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleix=20Conchillo=20Flaqu=C3=A9?= Date: Thu, 8 Jan 2026 16:46:38 -0800 Subject: [PATCH 06/11] BaseObject: improve logging messages --- src/pipecat/utils/base_object.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/pipecat/utils/base_object.py b/src/pipecat/utils/base_object.py index 6a8ff0681..f6e4c47de 100644 --- a/src/pipecat/utils/base_object.py +++ b/src/pipecat/utils/base_object.py @@ -96,7 +96,7 @@ class BaseObject(ABC): """ if self._event_tasks: event_names, tasks = zip(*self._event_tasks) - logger.debug(f"{self} waiting on event handlers to finish {list(event_names)}...") + logger.debug(f"{self}: waiting on event handlers to finish {list(event_names)}...") await asyncio.wait(tasks) def event_handler(self, event_name: str): @@ -126,7 +126,7 @@ class BaseObject(ABC): if event_name in self._event_handlers: self._event_handlers[event_name].handlers.append(handler) else: - logger.warning(f"Event handler {event_name} not registered") + logger.warning(f"{self}: event handler {event_name} not registered") def _register_event_handler(self, event_name: str, sync: bool = False): """Register an event handler type. @@ -140,7 +140,7 @@ class BaseObject(ABC): name=event_name, handlers=[], is_sync=sync ) else: - logger.warning(f"Event handler {event_name} already registered") + logger.warning(f"{self}: event handler {event_name} already registered") async def _call_event_handler(self, event_name: str, *args, **kwargs): """Call all registered handlers for the specified event. @@ -191,7 +191,7 @@ class BaseObject(ABC): tb = traceback.extract_tb(e.__traceback__) last = tb[-1] logger.error( - f"Uncaught exception in event handler '{event_name}' ({last.filename}:{last.lineno}): {e}" + f"{self}: uncaught exception in event handler '{event_name}' ({last.filename}:{last.lineno}): {e}" ) def _event_task_finished(self, task: asyncio.Task): From 24a52375c72698342a60ac772f57a67c6fdc364b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleix=20Conchillo=20Flaqu=C3=A9?= Date: Thu, 8 Jan 2026 18:25:58 -0800 Subject: [PATCH 07/11] tests: added LLMAssistantAggregator unit tests --- tests/test_context_aggregators_universal.py | 179 ++++++++++++++++++++ 1 file changed, 179 insertions(+) diff --git a/tests/test_context_aggregators_universal.py b/tests/test_context_aggregators_universal.py index 94acf383a..6aa38d362 100644 --- a/tests/test_context_aggregators_universal.py +++ b/tests/test_context_aggregators_universal.py @@ -13,10 +13,17 @@ from pipecat.frames.frames import ( FunctionCallResultFrame, FunctionCallsStartedFrame, InterruptionFrame, + LLMContextAssistantTimestampFrame, LLMContextFrame, + LLMFullResponseEndFrame, + LLMFullResponseStartFrame, LLMMessagesAppendFrame, LLMMessagesUpdateFrame, LLMRunFrame, + LLMTextFrame, + LLMThoughtEndFrame, + LLMThoughtStartFrame, + LLMThoughtTextFrame, TranscriptionFrame, UserStartedSpeakingFrame, UserStoppedSpeakingFrame, @@ -26,6 +33,9 @@ from pipecat.frames.frames import ( from pipecat.pipeline.pipeline import Pipeline from pipecat.processors.aggregators.llm_context import LLMContext from pipecat.processors.aggregators.llm_response_universal import ( + AssistantThoughtMessage, + AssistantTurnStoppedMessage, + LLMAssistantAggregator, LLMUserAggregator, LLMUserAggregatorParams, ) @@ -333,3 +343,172 @@ class TestLLMUserAggregator(unittest.IsolatedAsyncioTestCase): # The user mute strategies should have muted the user. self.assertFalse(user_turn) + + +class TestLLMAssistantAggregator(unittest.IsolatedAsyncioTestCase): + async def test_empty(self): + context = LLMContext() + + aggregator = LLMAssistantAggregator(context) + + should_start = None + should_stop = None + stop_message = None + + @aggregator.event_handler("on_assistant_turn_started") + async def on_assistant_turn_started(aggregator): + nonlocal should_start + should_start = True + + @aggregator.event_handler("on_assistant_turn_stopped") + async def on_assistant_turn_stopped(aggregator, message: AssistantTurnStoppedMessage): + nonlocal should_stop, stop_message + should_stop = True + stop_message = message + + frames_to_send = [LLMFullResponseStartFrame(), LLMFullResponseEndFrame()] + await run_test(aggregator, frames_to_send=frames_to_send) + self.assertTrue(should_start) + self.assertIsNone(should_stop) + self.assertIsNone(stop_message) + + async def test_simple(self): + context = LLMContext() + + aggregator = LLMAssistantAggregator(context) + + should_start = None + should_stop = None + stop_message = None + + @aggregator.event_handler("on_assistant_turn_started") + async def on_assistant_turn_started(aggregator): + nonlocal should_start + should_start = True + + @aggregator.event_handler("on_assistant_turn_stopped") + async def on_assistant_turn_stopped(aggregator, message: AssistantTurnStoppedMessage): + nonlocal should_stop, stop_message + should_stop = True + stop_message = message + + frames_to_send = [ + LLMFullResponseStartFrame(), + LLMTextFrame("Hello from Pipecat!"), + LLMFullResponseEndFrame(), + ] + expected_down_frames = [LLMContextFrame, LLMContextAssistantTimestampFrame] + await run_test( + aggregator, + frames_to_send=frames_to_send, + expected_down_frames=expected_down_frames, + ) + self.assertTrue(should_start) + self.assertTrue(should_stop) + self.assertEqual(stop_message.content, "Hello from Pipecat!") + + async def test_multiple(self): + context = LLMContext() + + aggregator = LLMAssistantAggregator(context) + + should_start = None + should_stop = None + stop_message = None + + @aggregator.event_handler("on_assistant_turn_started") + async def on_assistant_turn_started(aggregator): + nonlocal should_start + should_start = True + + @aggregator.event_handler("on_assistant_turn_stopped") + async def on_assistant_turn_stopped(aggregator, message: AssistantTurnStoppedMessage): + nonlocal should_stop, stop_message + should_stop = True + stop_message = message + + frames_to_send = [ + LLMFullResponseStartFrame(), + LLMTextFrame("Hello "), + LLMTextFrame("from "), + LLMTextFrame("Pipecat!"), + LLMFullResponseEndFrame(), + ] + expected_down_frames = [LLMContextFrame, LLMContextAssistantTimestampFrame] + await run_test( + aggregator, + frames_to_send=frames_to_send, + expected_down_frames=expected_down_frames, + ) + self.assertTrue(should_start) + self.assertTrue(should_stop) + self.assertEqual(stop_message.content, "Hello from Pipecat!") + + async def test_interruption(self): + context = LLMContext() + + aggregator = LLMAssistantAggregator(context) + + should_start = 0 + should_stop = 0 + stop_messages = [] + + @aggregator.event_handler("on_assistant_turn_started") + async def on_assistant_turn_started(aggregator): + nonlocal should_start + should_start += 1 + + @aggregator.event_handler("on_assistant_turn_stopped") + async def on_assistant_turn_stopped(aggregator, message: AssistantTurnStoppedMessage): + nonlocal should_stop, stop_messages + should_stop += 1 + stop_messages.append(message) + + frames_to_send = [ + LLMFullResponseStartFrame(), + LLMTextFrame("Hello "), + SleepFrame(), + InterruptionFrame(), + LLMFullResponseStartFrame(), + LLMTextFrame("Hello "), + LLMTextFrame("there!"), + LLMFullResponseEndFrame(), + ] + expected_down_frames = [ + LLMContextFrame, + LLMContextAssistantTimestampFrame, + InterruptionFrame, + LLMContextFrame, + LLMContextAssistantTimestampFrame, + ] + await run_test( + aggregator, + frames_to_send=frames_to_send, + expected_down_frames=expected_down_frames, + ) + self.assertEqual(should_start, 2) + self.assertEqual(should_stop, 2) + self.assertEqual(stop_messages[0].content, "Hello") + self.assertEqual(stop_messages[1].content, "Hello there!") + + async def test_thought(self): + context = LLMContext() + + aggregator = LLMAssistantAggregator(context) + + thought_message = None + + @aggregator.event_handler("on_assistant_thought") + async def on_assistant_thought(aggregator, message: AssistantThoughtMessage): + nonlocal thought_message + thought_message = message + + frames_to_send = [ + LLMFullResponseStartFrame(), + LLMThoughtStartFrame(), + LLMThoughtTextFrame(text="I'm thinking!"), + LLMThoughtEndFrame(), + LLMFullResponseEndFrame(), + ] + await run_test(aggregator, frames_to_send=frames_to_send) + self.assertEqual(thought_message.content, "I'm thinking!") From dafcd0448fd7b64461013a94836490d65168c517 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleix=20Conchillo=20Flaqu=C3=A9?= Date: Thu, 8 Jan 2026 17:57:31 -0800 Subject: [PATCH 08/11] added changelog for new assistant turn events --- changelog/3385.added.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog/3385.added.md diff --git a/changelog/3385.added.md b/changelog/3385.added.md new file mode 100644 index 000000000..b79a1d584 --- /dev/null +++ b/changelog/3385.added.md @@ -0,0 +1,4 @@ +- `LLMAssistantAggregator` now exposes the following events: + - `on_assistant_turn_started`: triggered when the assistant turn starts + - `on_assistant_turn_stopped`: triggered when the assistant turn ends + - `on_assistant_thought`: triggered when there's an assistant thought available From c16801e524a2b2e5e62c8818607179f29b61149a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleix=20Conchillo=20Flaqu=C3=A9?= Date: Thu, 8 Jan 2026 18:44:42 -0800 Subject: [PATCH 09/11] examples(foundational): update 49 series with on_assistant_thought --- .../foundational/49a-thinking-anthropic.py | 26 +++++++----------- examples/foundational/49b-thinking-google.py | 27 +++++++------------ .../49c-thinking-functions-anthropic.py | 27 +++++++------------ .../49d-thinking-functions-google.py | 23 +++++++--------- 4 files changed, 39 insertions(+), 64 deletions(-) diff --git a/examples/foundational/49a-thinking-anthropic.py b/examples/foundational/49a-thinking-anthropic.py index 45066527e..80477ff14 100644 --- a/examples/foundational/49a-thinking-anthropic.py +++ b/examples/foundational/49a-thinking-anthropic.py @@ -12,16 +12,16 @@ from loguru import logger from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer from pipecat.audio.vad.vad_analyzer import VADParams -from pipecat.frames.frames import LLMRunFrame, ThoughtTranscriptionMessage, TranscriptionMessage +from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner from pipecat.pipeline.task import PipelineParams, PipelineTask from pipecat.processors.aggregators.llm_context import LLMContext from pipecat.processors.aggregators.llm_response_universal import ( + AssistantThoughtMessage, LLMContextAggregatorPair, LLMUserAggregatorParams, ) -from pipecat.processors.transcript_processor import TranscriptProcessor from pipecat.runner.types import RunnerArguments from pipecat.runner.utils import create_transport from pipecat.services.anthropic.llm import AnthropicLLMService @@ -74,8 +74,6 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): ), ) - transcript = TranscriptProcessor(process_thoughts=True) - messages = [ { "role": "system", @@ -93,17 +91,18 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): ), ) + user_aggregator = context_aggregator.user() + assistant_aggregator = context_aggregator.assistant() + pipeline = Pipeline( [ transport.input(), # Transport user input stt, - transcript.user(), # User transcripts - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - transcript.assistant(), # Assistant transcripts (including thoughts) - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -143,14 +142,9 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): logger.info(f"Client disconnected") await task.cancel() - # Register event handler for transcript updates - @transcript.event_handler("on_transcript_update") - async def on_transcript_update(processor, frame): - for msg in frame.messages: - if isinstance(msg, (ThoughtTranscriptionMessage, TranscriptionMessage)): - timestamp = f"[{msg.timestamp}] " if msg.timestamp else "" - role = "THOUGHT" if isinstance(msg, ThoughtTranscriptionMessage) else msg.role - logger.info(f"Transcript: {timestamp}{role}: {msg.content}") + @assistant_aggregator.event_handler("on_assistant_thought") + async def on_assistant_thought(aggregator, message: AssistantThoughtMessage): + logger.info(f"Thought (timestamp: {message.timestamp}): {message.content}") runner = PipelineRunner(handle_sigint=runner_args.handle_sigint) diff --git a/examples/foundational/49b-thinking-google.py b/examples/foundational/49b-thinking-google.py index d3aa6134f..e0c9dcd6e 100644 --- a/examples/foundational/49b-thinking-google.py +++ b/examples/foundational/49b-thinking-google.py @@ -9,20 +9,19 @@ import os from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.base_smart_turn import SmartTurnParams from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer from pipecat.audio.vad.vad_analyzer import VADParams -from pipecat.frames.frames import LLMRunFrame, ThoughtTranscriptionMessage, TranscriptionMessage +from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner from pipecat.pipeline.task import PipelineParams, PipelineTask from pipecat.processors.aggregators.llm_context import LLMContext from pipecat.processors.aggregators.llm_response_universal import ( + AssistantThoughtMessage, LLMContextAggregatorPair, LLMUserAggregatorParams, ) -from pipecat.processors.transcript_processor import TranscriptProcessor from pipecat.runner.types import RunnerArguments from pipecat.runner.utils import create_transport from pipecat.services.cartesia.tts import CartesiaTTSService @@ -80,8 +79,6 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): ), ) - transcript = TranscriptProcessor(process_thoughts=True) - messages = [ { "role": "system", @@ -99,17 +96,18 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): ), ) + user_aggregator = context_aggregator.user() + assistant_aggregator = context_aggregator.assistant() + pipeline = Pipeline( [ transport.input(), # Transport user input stt, - transcript.user(), # User transcripts - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - transcript.assistant(), # Assistant transcripts (including thoughts) - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -150,14 +148,9 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): logger.info(f"Client disconnected") await task.cancel() - # Register event handler for transcript updates - @transcript.event_handler("on_transcript_update") - async def on_transcript_update(processor, frame): - for msg in frame.messages: - if isinstance(msg, (ThoughtTranscriptionMessage, TranscriptionMessage)): - timestamp = f"[{msg.timestamp}] " if msg.timestamp else "" - role = "THOUGHT" if isinstance(msg, ThoughtTranscriptionMessage) else msg.role - logger.info(f"Transcript: {timestamp}{role}: {msg.content}") + @assistant_aggregator.event_handler("on_assistant_thought") + async def on_assistant_thought(aggregator, message: AssistantThoughtMessage): + logger.info(f"Thought (timestamp: {message.timestamp}): {message.content}") runner = PipelineRunner(handle_sigint=runner_args.handle_sigint) diff --git a/examples/foundational/49c-thinking-functions-anthropic.py b/examples/foundational/49c-thinking-functions-anthropic.py index 93fc33524..6480c7390 100644 --- a/examples/foundational/49c-thinking-functions-anthropic.py +++ b/examples/foundational/49c-thinking-functions-anthropic.py @@ -10,20 +10,19 @@ from dotenv import load_dotenv from loguru import logger from pipecat.adapters.schemas.tools_schema import ToolsSchema -from pipecat.audio.turn.smart_turn.base_smart_turn import SmartTurnParams from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer from pipecat.audio.vad.vad_analyzer import VADParams -from pipecat.frames.frames import LLMRunFrame, ThoughtTranscriptionMessage, TranscriptionMessage +from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner from pipecat.pipeline.task import PipelineParams, PipelineTask from pipecat.processors.aggregators.llm_context import LLMContext from pipecat.processors.aggregators.llm_response_universal import ( + AssistantThoughtMessage, LLMContextAggregatorPair, LLMUserAggregatorParams, ) -from pipecat.processors.transcript_processor import TranscriptProcessor from pipecat.runner.types import RunnerArguments from pipecat.runner.utils import create_transport from pipecat.services.anthropic.llm import AnthropicLLMService @@ -101,8 +100,6 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tools = ToolsSchema(standard_tools=[check_flight_status, book_taxi]) - transcript = TranscriptProcessor(process_thoughts=True) - messages = [ { "role": "system", @@ -120,17 +117,17 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): ), ) + user_aggregator = context_aggregator.user() + assistant_aggregator = context_aggregator.assistant() + pipeline = Pipeline( [ transport.input(), # Transport user input stt, - transcript.user(), # User transcripts - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS - transport.output(), # Transport bot output - transcript.assistant(), # Assistant transcripts (including thoughts) - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -169,13 +166,9 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): logger.info(f"Client disconnected") await task.cancel() - @transcript.event_handler("on_transcript_update") - async def on_transcript_update(processor, frame): - for msg in frame.messages: - if isinstance(msg, (ThoughtTranscriptionMessage, TranscriptionMessage)): - timestamp = f"[{msg.timestamp}] " if msg.timestamp else "" - role = "THOUGHT" if isinstance(msg, ThoughtTranscriptionMessage) else msg.role - logger.info(f"Transcript: {timestamp}{role}: {msg.content}") + @assistant_aggregator.event_handler("on_assistant_thought") + async def on_assistant_thought(aggregator, message: AssistantThoughtMessage): + logger.info(f"Thought (timestamp: {message.timestamp}): {message.content}") runner = PipelineRunner(handle_sigint=runner_args.handle_sigint) diff --git a/examples/foundational/49d-thinking-functions-google.py b/examples/foundational/49d-thinking-functions-google.py index 356fe806b..794d4e4f7 100644 --- a/examples/foundational/49d-thinking-functions-google.py +++ b/examples/foundational/49d-thinking-functions-google.py @@ -10,7 +10,6 @@ from dotenv import load_dotenv from loguru import logger from pipecat.adapters.schemas.tools_schema import ToolsSchema -from pipecat.audio.turn.smart_turn.base_smart_turn import SmartTurnParams from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer from pipecat.audio.vad.vad_analyzer import VADParams @@ -20,6 +19,7 @@ from pipecat.pipeline.runner import PipelineRunner from pipecat.pipeline.task import PipelineParams, PipelineTask from pipecat.processors.aggregators.llm_context import LLMContext from pipecat.processors.aggregators.llm_response_universal import ( + AssistantThoughtMessage, LLMContextAggregatorPair, LLMUserAggregatorParams, ) @@ -106,8 +106,6 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tools = ToolsSchema(standard_tools=[check_flight_status, book_taxi]) - transcript = TranscriptProcessor(process_thoughts=True) - messages = [ { "role": "system", @@ -125,17 +123,18 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): ), ) + user_aggregator = context_aggregator.user() + assistant_aggregator = context_aggregator.assistant() + pipeline = Pipeline( [ transport.input(), # Transport user input stt, - transcript.user(), # User transcripts - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - transcript.assistant(), # Assistant transcripts (including thoughts) - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -174,13 +173,9 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): logger.info(f"Client disconnected") await task.cancel() - @transcript.event_handler("on_transcript_update") - async def on_transcript_update(processor, frame): - for msg in frame.messages: - if isinstance(msg, (ThoughtTranscriptionMessage, TranscriptionMessage)): - timestamp = f"[{msg.timestamp}] " if msg.timestamp else "" - role = "THOUGHT" if isinstance(msg, ThoughtTranscriptionMessage) else msg.role - logger.info(f"Transcript: {timestamp}{role}: {msg.content}") + @assistant_aggregator.event_handler("on_assistant_thought") + async def on_assistant_thought(aggregator, message: AssistantThoughtMessage): + logger.info(f"Thought (timestamp: {message.timestamp}): {message.content}") runner = PipelineRunner(handle_sigint=runner_args.handle_sigint) From 8f47c569f97773c93f441bf086dab030a7eae4dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleix=20Conchillo=20Flaqu=C3=A9?= Date: Thu, 8 Jan 2026 20:13:44 -0800 Subject: [PATCH 10/11] examples(foundational): add 28-user-assistant-turns.py --- changelog/3385.other.md | 1 + .../foundational/28-user-assistant-turns.py | 217 ++++++++++++++++++ 2 files changed, 218 insertions(+) create mode 100644 changelog/3385.other.md create mode 100644 examples/foundational/28-user-assistant-turns.py diff --git a/changelog/3385.other.md b/changelog/3385.other.md new file mode 100644 index 000000000..69f612908 --- /dev/null +++ b/changelog/3385.other.md @@ -0,0 +1 @@ +- Added a new foundational example `28-user-assistant-turns.py` that shows how to use the new `LLMUserAggregator` and `LLMAssistantAggregator` events to gather a conversation transcript. diff --git a/examples/foundational/28-user-assistant-turns.py b/examples/foundational/28-user-assistant-turns.py new file mode 100644 index 000000000..bb8f5df47 --- /dev/null +++ b/examples/foundational/28-user-assistant-turns.py @@ -0,0 +1,217 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +import os +from typing import Optional + +from dotenv import load_dotenv +from loguru import logger + +from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 +from pipecat.audio.vad.silero import SileroVADAnalyzer +from pipecat.audio.vad.vad_analyzer import VADParams +from pipecat.frames.frames import LLMRunFrame +from pipecat.pipeline.pipeline import Pipeline +from pipecat.pipeline.runner import PipelineRunner +from pipecat.pipeline.task import PipelineParams, PipelineTask +from pipecat.processors.aggregators.llm_context import LLMContext +from pipecat.processors.aggregators.llm_response_universal import ( + AssistantTurnStoppedMessage, + LLMContextAggregatorPair, + LLMUserAggregatorParams, + UserTurnStoppedMessage, +) +from pipecat.runner.types import RunnerArguments +from pipecat.runner.utils import create_transport +from pipecat.services.cartesia.tts import CartesiaTTSService +from pipecat.services.deepgram.stt import DeepgramSTTService +from pipecat.services.openai.llm import OpenAILLMService +from pipecat.transports.base_transport import BaseTransport, TransportParams +from pipecat.transports.daily.transport import DailyParams +from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams +from pipecat.turns.user_stop import TurnAnalyzerUserTurnStopStrategy +from pipecat.turns.user_turn_strategies import UserTurnStrategies +from pipecat.utils.time import time_now_iso8601 + +load_dotenv(override=True) + + +class TranscriptHandler: + """Handles real-time transcript processing and output. + + Maintains a list of conversation messages and outputs them either to a log + or to a file as they are received. Each message includes its timestamp and role. + + Attributes: + messages: List of all processed transcript messages + output_file: Optional path to file where transcript is saved. If None, outputs to log only. + """ + + def __init__(self, output_file: Optional[str] = None): + """Initialize handler with optional file output. + + Args: + output_file: Path to output file. If None, outputs to log only. + """ + self.output_file: Optional[str] = output_file + logger.debug( + f"TranscriptHandler initialized {'with output_file=' + output_file if output_file else 'with log output only'}" + ) + + async def save_message(self, role: str, content: str): + """Save a single transcript message. + + Outputs the message to the log and optionally to a file. + + Args: + role: Who generated this transcript + content: The transcript to save + """ + timestamp = time_now_iso8601() + line = f"[{timestamp}] {role}: {content}" + + # Always log the message + logger.info(f"Transcript: {line}") + + # Optionally write to file + if self.output_file: + try: + with open(self.output_file, "a", encoding="utf-8") as f: + f.write(line + "\n\n") + except Exception as e: + logger.error(f"Error saving transcript message to file: {e}") + + async def on_user_transcript(self, message: UserTurnStoppedMessage): + """Handle new user transcript message. + + Args: + message: The new user message + """ + logger.debug(f"Received user transcript update") + await self.save_message("user", message.content) + + async def on_assistant_transcript(self, message: AssistantTurnStoppedMessage): + """Handle new assistant transcript message. + + Args: + message: The new assistant message + """ + logger.debug(f"Received assistant transcript update") + await self.save_message("assistant", message.content) + + +# We store functions so objects (e.g. SileroVADAnalyzer) don't get +# instantiated. The function will be called when the desired transport gets +# selected. +transport_params = { + "daily": lambda: DailyParams( + audio_in_enabled=True, + audio_out_enabled=True, + vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), + ), + "twilio": lambda: FastAPIWebsocketParams( + audio_in_enabled=True, + audio_out_enabled=True, + vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), + ), + "webrtc": lambda: TransportParams( + audio_in_enabled=True, + audio_out_enabled=True, + vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), + ), +} + + +async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): + logger.info(f"Starting bot") + + stt = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY")) + + tts = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ) + + llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY")) + + messages = [ + { + "role": "system", + "content": "You are a helpful LLM in a WebRTC call. Your goal is to demonstrate your capabilities in a succinct way. Your output will be spoken aloud, so avoid special characters that can't easily be spoken, such as emojis or bullet points. Respond to what the user said in a creative, helpful, and brief way. Say hello.", + }, + ] + + context = LLMContext(messages) + context_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams( + user_turn_strategies=UserTurnStrategies( + stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] + ), + ), + ) + + user_aggregator = context_aggregator.user() + assistant_aggregator = context_aggregator.assistant() + + # Create transcript processor and handler + transcript_handler = TranscriptHandler() # Output to log only + # transcript_handler = TranscriptHandler(output_file="transcript.txt") # Output to file and log + + pipeline = Pipeline( + [ + transport.input(), # Transport user input + stt, # STT + user_aggregator, # User responses + llm, # LLM + tts, # TTS + transport.output(), # Transport bot output + assistant_aggregator, # Assistant spoken responses + ] + ) + + task = PipelineTask( + pipeline, + params=PipelineParams( + enable_metrics=True, + enable_usage_metrics=True, + ), + idle_timeout_secs=runner_args.pipeline_idle_timeout_secs, + ) + + @transport.event_handler("on_client_connected") + async def on_client_connected(transport, client): + logger.info(f"Client connected") + # Start conversation - empty prompt to let LLM follow system instructions + await task.queue_frames([LLMRunFrame()]) + + @transport.event_handler("on_client_disconnected") + async def on_client_disconnected(transport, client): + logger.info(f"Client disconnected") + await task.cancel() + + @user_aggregator.event_handler("on_user_turn_stopped") + async def on_user_turn_stopped(aggregator, strategy, message: UserTurnStoppedMessage): + await transcript_handler.on_user_transcript(message) + + @assistant_aggregator.event_handler("on_assistant_turn_stopped") + async def on_assistant_turn_stopped(aggregator, message: AssistantTurnStoppedMessage): + await transcript_handler.on_assistant_transcript(message) + + runner = PipelineRunner(handle_sigint=runner_args.handle_sigint) + await runner.run(task) + + +async def bot(runner_args: RunnerArguments): + """Main bot entry point compatible with Pipecat Cloud.""" + transport = await create_transport(runner_args, transport_params) + await run_bot(transport, runner_args) + + +if __name__ == "__main__": + from pipecat.runner.run import main + + main() From 25f6ba76d68cc7a50961704a86931d24c13039db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleix=20Conchillo=20Flaqu=C3=A9?= Date: Thu, 8 Jan 2026 21:24:21 -0800 Subject: [PATCH 11/11] add start timestamp to user and assistant turn messages --- .../foundational/28-user-assistant-turns.py | 8 +++---- .../aggregators/llm_response_universal.py | 24 ++++++++++++++++--- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/examples/foundational/28-user-assistant-turns.py b/examples/foundational/28-user-assistant-turns.py index bb8f5df47..da405088f 100644 --- a/examples/foundational/28-user-assistant-turns.py +++ b/examples/foundational/28-user-assistant-turns.py @@ -34,7 +34,6 @@ from pipecat.transports.daily.transport import DailyParams from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams from pipecat.turns.user_stop import TurnAnalyzerUserTurnStopStrategy from pipecat.turns.user_turn_strategies import UserTurnStrategies -from pipecat.utils.time import time_now_iso8601 load_dotenv(override=True) @@ -61,7 +60,7 @@ class TranscriptHandler: f"TranscriptHandler initialized {'with output_file=' + output_file if output_file else 'with log output only'}" ) - async def save_message(self, role: str, content: str): + async def save_message(self, role: str, content: str, timestamp: str): """Save a single transcript message. Outputs the message to the log and optionally to a file. @@ -70,7 +69,6 @@ class TranscriptHandler: role: Who generated this transcript content: The transcript to save """ - timestamp = time_now_iso8601() line = f"[{timestamp}] {role}: {content}" # Always log the message @@ -91,7 +89,7 @@ class TranscriptHandler: message: The new user message """ logger.debug(f"Received user transcript update") - await self.save_message("user", message.content) + await self.save_message("user", message.content, message.timestamp) async def on_assistant_transcript(self, message: AssistantTurnStoppedMessage): """Handle new assistant transcript message. @@ -100,7 +98,7 @@ class TranscriptHandler: message: The new assistant message """ logger.debug(f"Received assistant transcript update") - await self.save_message("assistant", message.content) + await self.save_message("assistant", message.content, message.timestamp) # We store functions so objects (e.g. SileroVADAnalyzer) don't get diff --git a/src/pipecat/processors/aggregators/llm_response_universal.py b/src/pipecat/processors/aggregators/llm_response_universal.py index ca8870f60..c47579fba 100644 --- a/src/pipecat/processors/aggregators/llm_response_universal.py +++ b/src/pipecat/processors/aggregators/llm_response_universal.py @@ -110,11 +110,13 @@ class UserTurnStoppedMessage: Parameters: content: The message content/text. + timestamp: When the user turn started. user_id: Optional identifier for the user. """ content: str + timestamp: str user_id: Optional[str] = None @@ -127,10 +129,12 @@ class AssistantTurnStoppedMessage: Parameters: content: The message content/text. + timestamp: When the assistant turn started. """ content: str + timestamp: str @dataclass @@ -142,7 +146,7 @@ class AssistantThoughtMessage: Parameters: content: The message content/text. - timestamp: When the thought was started. + timestamp: When the thought started. """ @@ -328,6 +332,7 @@ class LLMUserAggregator(LLMContextAggregator): user_turn_strategies = self._params.user_turn_strategies or UserTurnStrategies() self._user_is_muted = False + self._user_turn_start_timestamp = "" self._user_turn_controller = UserTurnController( user_turn_strategies=user_turn_strategies, @@ -527,6 +532,8 @@ class LLMUserAggregator(LLMContextAggregator): ): logger.debug(f"{self}: User started speaking (user turn start strategy: {strategy})") + self._user_turn_start_timestamp = time_now_iso8601() + if params.enable_user_speaking_frames: await self.broadcast_frame(UserStartedSpeakingFrame) @@ -549,8 +556,11 @@ class LLMUserAggregator(LLMContextAggregator): # Always push context frame. aggregation = await self.push_aggregation() - message = UserTurnStoppedMessage(content=aggregation) + message = UserTurnStoppedMessage( + content=aggregation, timestamp=self._user_turn_start_timestamp + ) await self._call_event_handler("on_user_turn_stopped", strategy, message) + self._user_turn_start_timestamp = "" async def _on_user_turn_stop_timeout(self, controller): await self._call_event_handler("on_user_turn_stop_timeout") @@ -633,6 +643,8 @@ class LLMAssistantAggregator(LLMContextAggregator): self._function_calls_in_progress: Dict[str, Optional[FunctionCallInProgressFrame]] = {} self._context_updated_tasks: Set[asyncio.Task] = set() + self._assistant_turn_start_timestamp = "" + self._thought_append_to_context = False self._thought_llm: str = "" self._thought_aggregation: List[TextPartForConcatenation] = [] @@ -960,14 +972,20 @@ class LLMAssistantAggregator(LLMContextAggregator): self._context_updated_tasks.discard(task) async def _trigger_assistant_turn_started(self): + self._assistant_turn_start_timestamp = time_now_iso8601() + await self._call_event_handler("on_assistant_turn_started") async def _trigger_assistant_turn_stopped(self): aggregation = await self.push_aggregation() if aggregation: - message = AssistantTurnStoppedMessage(content=aggregation) + message = AssistantTurnStoppedMessage( + content=aggregation, timestamp=self._assistant_turn_start_timestamp + ) await self._call_event_handler("on_assistant_turn_stopped", message) + self._assistant_turn_start_timestamp = "" + class LLMContextAggregatorPair: """Pair of LLM context aggregators for updating context with user and assistant messages."""