From 51a3310e78f63a2ade4d99959b00994b3136f44a Mon Sep 17 00:00:00 2001 From: filipi87 Date: Fri, 27 Feb 2026 18:39:57 -0300 Subject: [PATCH 1/8] Added LLMSummarizeContextFrame: push this frame anywhere in the pipeline to trigger on-demand context summarization (e.g. from a function call tool). --- src/pipecat/frames/frames.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/pipecat/frames/frames.py b/src/pipecat/frames/frames.py index e1d2c37ff..126f3c001 100644 --- a/src/pipecat/frames/frames.py +++ b/src/pipecat/frames/frames.py @@ -43,6 +43,7 @@ if TYPE_CHECKING: from pipecat.processors.aggregators.llm_context import LLMContext, NotGiven from pipecat.processors.frame_processor import FrameProcessor from pipecat.services.settings import ServiceSettings + from pipecat.utils.context.llm_context_summarization import LLMContextSummaryConfig from pipecat.utils.tracing.tracing_context import TracingContext @@ -2000,6 +2001,22 @@ class LLMAssistantPushAggregationFrame(ControlFrame): """ +@dataclass +class LLMSummarizeContextFrame(ControlFrame): + """Frame requesting on-demand context summarization. + + Push this frame into the pipeline to trigger a manual context summarization. + + Parameters: + config: Optional per-request override for summary generation settings + (prompt, token budget, messages to keep). If ``None``, the + summarizer's default :class:`~pipecat.utils.context.llm_context_summarization.LLMContextSummaryConfig` + is used. + """ + + config: Optional["LLMContextSummaryConfig"] = None + + @dataclass class LLMContextSummaryRequestFrame(ControlFrame): """Frame requesting context summarization from an LLM service. From f11d4b694415c625f3c651eb907e6d8afd65df2c Mon Sep 17 00:00:00 2001 From: filipi87 Date: Fri, 27 Feb 2026 18:40:41 -0300 Subject: [PATCH 2/8] Refactored LLMContextSummarizationConfig into two focused classes, LLMContextSummaryConfig and LLMAutoContextSummarizationConfig. --- .../context/llm_context_summarization.py | 132 ++++++++++++++++-- 1 file changed, 119 insertions(+), 13 deletions(-) diff --git a/src/pipecat/utils/context/llm_context_summarization.py b/src/pipecat/utils/context/llm_context_summarization.py index 0bdebb3a2..e68311942 100644 --- a/src/pipecat/utils/context/llm_context_summarization.py +++ b/src/pipecat/utils/context/llm_context_summarization.py @@ -10,7 +10,8 @@ This module provides reusable functionality for automatically compressing conver context when token limits are reached, enabling efficient long-running conversations. """ -from dataclasses import dataclass +import warnings +from dataclasses import dataclass, field from typing import TYPE_CHECKING, List, Optional if TYPE_CHECKING: @@ -54,26 +55,18 @@ The conversation transcript follows. Generate only the summary, no other text."" @dataclass -class LLMContextSummarizationConfig: - """Configuration for context summarization behavior. +class LLMContextSummaryConfig: + """Configuration for summary generation parameters. - Controls when and how conversation context is automatically compressed - to manage token limits in long-running conversations. + Contains settings that control how a summary is generated. Used by both + automatic and manual summarization modes. Parameters: - max_context_tokens: Maximum allowed context size in tokens. When this - limit is reached, summarization is triggered to compress the context. - The tokens are calculated using the industry-standard approximation - of 1 token ≈ 4 characters. target_context_tokens: Maximum token size for the generated summary. This value is passed directly to the LLM as the max_tokens parameter when generating the summary. Should be sized appropriately to allow the summary plus recent preserved messages to fit within reasonable context limits. - max_unsummarized_messages: Maximum number of new messages that can - accumulate since the last summary before triggering a new - summarization. This ensures regular compression even if token - limits are not reached. min_messages_after_summary: Number of recent messages to preserve uncompressed after each summarization. These messages maintain immediate conversational context. @@ -94,6 +87,94 @@ class LLMContextSummarizationConfig: is aborted with an error and future summarizations are unblocked. """ + target_context_tokens: int = 6000 + min_messages_after_summary: int = 4 + summarization_prompt: Optional[str] = None + summary_message_template: str = "Conversation summary: {summary}" + llm: Optional["LLMService"] = None + summarization_timeout: float = DEFAULT_SUMMARIZATION_TIMEOUT + + def __post_init__(self): + """Validate configuration parameters.""" + if self.target_context_tokens <= 0: + raise ValueError("target_context_tokens must be positive") + if self.min_messages_after_summary < 0: + raise ValueError("min_messages_after_summary must be non-negative") + + @property + def summary_prompt(self) -> str: + """Get the summarization prompt to use. + + Returns: + The custom prompt if set, otherwise the default summarization prompt. + """ + return self.summarization_prompt or DEFAULT_SUMMARIZATION_PROMPT + + +@dataclass +class LLMAutoContextSummarizationConfig: + """Configuration for automatic context summarization. + + Controls when conversation context is automatically compressed and how + that summary is generated. Summarization is triggered when either the + token limit or the unsummarized message count threshold is exceeded. + + Parameters: + max_context_tokens: Maximum allowed context size in tokens. When this + limit is reached, summarization is triggered to compress the context. + The tokens are calculated using the industry-standard approximation + of 1 token ≈ 4 characters. + max_unsummarized_messages: Maximum number of new messages that can + accumulate since the last summary before triggering a new + summarization. This ensures regular compression even if token + limits are not reached. + summary_config: Configuration for summary generation parameters + (prompt, token budget, messages to keep). If not provided, uses + default ``LLMContextSummaryConfig`` values. + """ + + max_context_tokens: int = 8000 + max_unsummarized_messages: int = 20 + summary_config: LLMContextSummaryConfig = field(default_factory=LLMContextSummaryConfig) + + def __post_init__(self): + """Validate configuration parameters.""" + if self.max_context_tokens <= 0: + raise ValueError("max_context_tokens must be positive") + if self.max_unsummarized_messages < 1: + raise ValueError("max_unsummarized_messages must be at least 1") + + # Auto-adjust target_context_tokens if it exceeds max_context_tokens + if self.summary_config.target_context_tokens > self.max_context_tokens: + # Use 80% of max_context_tokens as a reasonable default + self.summary_config.target_context_tokens = int(self.max_context_tokens * 0.8) + + +@dataclass +class LLMContextSummarizationConfig: + """Configuration for context summarization behavior. + + .. deprecated:: + Use :class:`LLMAutoContextSummarizationConfig` with a nested + :class:`LLMContextSummaryConfig` instead:: + + LLMAutoContextSummarizationConfig( + max_context_tokens=8000, + max_unsummarized_messages=20, + summary_config=LLMContextSummaryConfig( + target_context_tokens=6000, + min_messages_after_summary=4, + ), + ) + + Parameters: + max_context_tokens: Maximum allowed context size in tokens. + target_context_tokens: Maximum token size for the generated summary. + max_unsummarized_messages: Maximum new messages before triggering summarization. + min_messages_after_summary: Number of recent messages to preserve. + summarization_prompt: Custom prompt for summary generation. + """ + max_context_tokens: int = 8000 target_context_tokens: int = 6000 max_unsummarized_messages: int = 20 @@ -105,6 +186,12 @@ class LLMContextSummarizationConfig: def __post_init__(self): """Validate configuration parameters.""" + warnings.warn( + "LLMContextSummarizationConfig is deprecated. " + "Use LLMAutoContextSummarizationConfig with a nested LLMContextSummaryConfig instead.", + DeprecationWarning, + stacklevel=2, + ) if self.max_context_tokens <= 0: raise ValueError("max_context_tokens must be positive") if self.target_context_tokens <= 0: @@ -129,6 +216,25 @@ class LLMContextSummarizationConfig: """ return self.summarization_prompt or DEFAULT_SUMMARIZATION_PROMPT + def to_auto_config(self) -> LLMAutoContextSummarizationConfig: + """Convert to the new :class:`LLMAutoContextSummarizationConfig`. + + Returns: + An equivalent ``LLMAutoContextSummarizationConfig`` instance. + """ + return LLMAutoContextSummarizationConfig( + max_context_tokens=self.max_context_tokens, + max_unsummarized_messages=self.max_unsummarized_messages, + summary_config=LLMContextSummaryConfig( + target_context_tokens=self.target_context_tokens, + min_messages_after_summary=self.min_messages_after_summary, + summarization_prompt=self.summarization_prompt, + summary_message_template=self.summary_message_template, + llm=self.llm, + summarization_timeout=self.summarization_timeout, + ), + ) + @dataclass class LLMMessagesToSummarize: From 08d93ce9b662fef96faf64007939741cecc9ebd6 Mon Sep 17 00:00:00 2001 From: filipi87 Date: Fri, 27 Feb 2026 18:41:17 -0300 Subject: [PATCH 3/8] Renamed LLMAssistantAggregatorParams fields for clarity. --- .../aggregators/llm_response_universal.py | 77 ++++++++++++++----- 1 file changed, 59 insertions(+), 18 deletions(-) diff --git a/src/pipecat/processors/aggregators/llm_response_universal.py b/src/pipecat/processors/aggregators/llm_response_universal.py index b255748e0..c43cc279d 100644 --- a/src/pipecat/processors/aggregators/llm_response_universal.py +++ b/src/pipecat/processors/aggregators/llm_response_universal.py @@ -79,7 +79,10 @@ from pipecat.turns.user_stop import BaseUserTurnStopStrategy, UserTurnStoppedPar from pipecat.turns.user_turn_completion_mixin import UserTurnCompletionConfig from pipecat.turns.user_turn_controller import UserTurnController from pipecat.turns.user_turn_strategies import ExternalUserTurnStrategies, UserTurnStrategies -from pipecat.utils.context.llm_context_summarization import LLMContextSummarizationConfig +from pipecat.utils.context.llm_context_summarization import ( + LLMAutoContextSummarizationConfig, + LLMContextSummarizationConfig, +) from pipecat.utils.string import TextPartForConcatenation, concatenate_aggregated_text from pipecat.utils.time import time_now_iso8601 @@ -125,18 +128,54 @@ class LLMAssistantAggregatorParams: in text frames by adding spaces between tokens. This parameter is ignored when used with the newer LLMAssistantAggregator, which handles word spacing automatically. - enable_context_summarization: Enable automatic context summarization when token - limits are reached (disabled by default). When enabled, older conversation - messages are automatically compressed into summaries to manage context size. - context_summarization_config: Configuration for context summarization behavior. - Controls thresholds, message preservation, and summarization prompts. If None - and summarization is enabled, uses default configuration values. + enable_auto_context_summarization: Enable automatic context summarization when token + or message-count limits are reached (disabled by default). When enabled, + older conversation messages are automatically compressed into summaries to + manage context size. + auto_context_summarization_config: Configuration for automatic context + summarization. Controls trigger thresholds, message preservation, and + summarization prompts. If None, uses default + ``LLMAutoContextSummarizationConfig`` values. """ expect_stripped_words: bool = True - enable_context_summarization: bool = False + enable_auto_context_summarization: bool = False + auto_context_summarization_config: Optional[LLMAutoContextSummarizationConfig] = None + + # --------------------------------------------------------------------------- + # Deprecated field names — kept for backward compatibility. + # Use enable_auto_context_summarization and auto_context_summarization_config instead. + # --------------------------------------------------------------------------- + enable_context_summarization: Optional[bool] = None context_summarization_config: Optional[LLMContextSummarizationConfig] = None + def __post_init__(self): + if self.enable_context_summarization is not None: + warnings.warn( + "LLMAssistantAggregatorParams.enable_context_summarization is deprecated. " + "Use enable_auto_context_summarization instead.", + DeprecationWarning, + stacklevel=2, + ) + self.enable_auto_context_summarization = self.enable_context_summarization + self.enable_context_summarization = None + + if self.context_summarization_config is not None: + warnings.warn( + "LLMAssistantAggregatorParams.context_summarization_config is deprecated. " + "Use auto_context_summarization_config (LLMAutoContextSummarizationConfig) instead.", + DeprecationWarning, + stacklevel=2, + ) + if isinstance(self.context_summarization_config, LLMContextSummarizationConfig): + self.auto_context_summarization_config = ( + self.context_summarization_config.to_auto_config() + ) + else: + # Accept LLMAutoContextSummarizationConfig passed to the deprecated field + self.auto_context_summarization_config = self.context_summarization_config # type: ignore[assignment] + self.context_summarization_config = None + @dataclass class UserTurnStoppedMessage: @@ -825,16 +864,18 @@ class LLMAssistantAggregator(LLMContextAggregator): self._thought_aggregation: List[TextPartForConcatenation] = [] self._thought_start_time: str = "" - # Context summarization - self._summarizer: Optional[LLMContextSummarizer] = None - if self._params.enable_context_summarization: - self._summarizer = LLMContextSummarizer( - context=self._context, - config=self._params.context_summarization_config, - ) - self._summarizer.add_event_handler( - "on_request_summarization", self._on_request_summarization - ) + # Context summarization — always create the summarizer so that manually + # pushed LLMSummarizeContextFrame frames are always handled. + # Auto-triggering based on thresholds is only enabled when + # enable_auto_context_summarization is True. + self._summarizer: Optional[LLMContextSummarizer] = LLMContextSummarizer( + context=self._context, + config=self._params.auto_context_summarization_config, + auto_trigger=self._params.enable_auto_context_summarization, + ) + self._summarizer.add_event_handler( + "on_request_summarization", self._on_request_summarization + ) self._register_event_handler("on_assistant_turn_started") self._register_event_handler("on_assistant_turn_stopped") From ed7f0a2c08b229996c6d8722591233b9be61e1af Mon Sep 17 00:00:00 2001 From: filipi87 Date: Fri, 27 Feb 2026 18:41:55 -0300 Subject: [PATCH 4/8] Adding support for on-demand summarization --- .../aggregators/llm_context_summarizer.py | 107 +++++++++++++----- 1 file changed, 78 insertions(+), 29 deletions(-) diff --git a/src/pipecat/processors/aggregators/llm_context_summarizer.py b/src/pipecat/processors/aggregators/llm_context_summarizer.py index bfdbbceb0..54879a8bb 100644 --- a/src/pipecat/processors/aggregators/llm_context_summarizer.py +++ b/src/pipecat/processors/aggregators/llm_context_summarizer.py @@ -19,14 +19,16 @@ from pipecat.frames.frames import ( LLMContextSummaryRequestFrame, LLMContextSummaryResultFrame, LLMFullResponseStartFrame, + LLMSummarizeContextFrame, ) from pipecat.processors.aggregators.llm_context import LLMContext, LLMSpecificMessage from pipecat.utils.asyncio.task_manager import BaseTaskManager from pipecat.utils.base_object import BaseObject from pipecat.utils.context.llm_context_summarization import ( DEFAULT_SUMMARIZATION_TIMEOUT, - LLMContextSummarizationConfig, + LLMAutoContextSummarizationConfig, LLMContextSummarizationUtil, + LLMContextSummaryConfig, ) if TYPE_CHECKING: @@ -55,9 +57,20 @@ class SummaryAppliedEvent: class LLMContextSummarizer(BaseObject): """Summarizer for managing LLM context summarization. - This class manages automatic context summarization when token or message - limits are reached. It monitors the LLM context size, triggers - summarization requests, and applies the results to compress conversation history. + This class manages context summarization, either automatically when token or + message limits are reached, or on-demand when an ``LLMSummarizeContextFrame`` + is received. It monitors the LLM context size, triggers summarization requests, + and applies the results to compress conversation history. + + When ``auto_trigger=True`` (the default), summarization is triggered + automatically based on the configured thresholds in + ``LLMAutoContextSummarizationConfig``. When ``auto_trigger=False``, + threshold checks are skipped and summarization only happens when an + ``LLMSummarizeContextFrame`` is explicitly pushed into the pipeline. + + Both modes can coexist: set ``auto_trigger=True`` and also push + ``LLMSummarizeContextFrame`` at any time to force an immediate summarization + (subject to the ``_summarization_in_progress`` guard). Event handlers available: @@ -88,18 +101,26 @@ class LLMContextSummarizer(BaseObject): self, *, context: LLMContext, - config: Optional[LLMContextSummarizationConfig] = None, + config: Optional[LLMAutoContextSummarizationConfig] = None, + auto_trigger: bool = True, ): """Initialize the context summarizer. Args: context: The LLM context to monitor and summarize. - config: Configuration for summarization behavior. If None, uses default config. + config: Auto-summarization configuration controlling both trigger + thresholds and default summary generation parameters. If None, + uses default ``LLMAutoContextSummarizationConfig`` values. + auto_trigger: Whether to automatically trigger summarization when + thresholds are reached. When False, summarization only happens + when an ``LLMSummarizeContextFrame`` is pushed into the pipeline. + Defaults to True. """ super().__init__() self._context = context - self._config = config or LLMContextSummarizationConfig() + self._auto_config = config or LLMAutoContextSummarizationConfig() + self._auto_trigger = auto_trigger self._task_manager: Optional[BaseTaskManager] = None @@ -137,6 +158,8 @@ class LLMContextSummarizer(BaseObject): """ if isinstance(frame, LLMFullResponseStartFrame): await self._handle_llm_response_start(frame) + elif isinstance(frame, LLMSummarizeContextFrame): + await self._handle_manual_summarization_request(frame) elif isinstance(frame, LLMContextSummaryResultFrame): await self._handle_summary_result(frame) elif isinstance(frame, InterruptionFrame): @@ -151,12 +174,24 @@ class LLMContextSummarizer(BaseObject): if self._should_summarize(): await self._request_summarization() - async def _handle_interruption(self): - """Handle interruption by canceling summarization in progress. + async def _handle_manual_summarization_request(self, frame: LLMSummarizeContextFrame): + """Handle an explicit on-demand summarization request. + + Reuses the same ``_request_summarization()`` code path as auto mode, + so bookkeeping (``_summarization_in_progress``, + ``_pending_summary_request_id``) is always updated correctly. Args: - frame: The interruption frame. + frame: The manual summarization request frame, optionally carrying + a per-request :class:`~pipecat.utils.context.llm_context_summarization.LLMContextSummaryConfig`. """ + if self._summarization_in_progress: + logger.debug(f"{self}: Summarization already in progress, ignoring manual request") + return + await self._request_summarization(config_override=frame.config) + + async def _handle_interruption(self): + """Handle interruption by canceling summarization in progress.""" # Reset summarization state to allow new requests. This is necessary because # the request frame (LLMContextSummaryRequestFrame) may have been cancelled # during interruption. We preserve _pending_summary_request_id to handle the @@ -179,13 +214,17 @@ class LLMContextSummarizer(BaseObject): Returns: True if all conditions are met: + - ``auto_trigger`` is enabled - No summarization currently in progress - AND either: - - Token count exceeds max_context_tokens - - OR message count exceeds max_unsummarized_messages since last summary + - Token count exceeds ``max_context_tokens`` + - OR message count exceeds ``max_unsummarized_messages`` since last summary """ logger.trace(f"{self}: Checking if context summarization is needed") + if not self._auto_trigger: + return False + if self._summarization_in_progress: logger.debug(f"{self}: Summarization already in progress") return False @@ -195,20 +234,20 @@ class LLMContextSummarizer(BaseObject): num_messages = len(self._context.messages) # Check if we've reached the token limit - token_limit = self._config.max_context_tokens + token_limit = self._auto_config.max_context_tokens token_limit_exceeded = total_tokens >= token_limit # Check if we've exceeded max unsummarized messages messages_since_summary = len(self._context.messages) - 1 message_threshold_exceeded = ( - messages_since_summary >= self._config.max_unsummarized_messages + messages_since_summary >= self._auto_config.max_unsummarized_messages ) logger.trace( f"{self}: Context has {num_messages} messages, " f"~{total_tokens} tokens (limit: {token_limit}), " f"{messages_since_summary} messages since last summary " - f"(message threshold: {self._config.max_unsummarized_messages})" + f"(message threshold: {self._auto_config.max_unsummarized_messages})" ) # Trigger if either limit is exceeded @@ -223,23 +262,30 @@ class LLMContextSummarizer(BaseObject): reason.append(f"~{total_tokens} tokens (>={token_limit} limit)") if message_threshold_exceeded: reason.append( - f"{messages_since_summary} messages (>={self._config.max_unsummarized_messages} threshold)" + f"{messages_since_summary} messages (>={self._auto_config.max_unsummarized_messages} threshold)" ) logger.debug(f"{self}: ✓ Summarization needed - {', '.join(reason)}") return True - async def _request_summarization(self): + async def _request_summarization( + self, config_override: Optional[LLMContextSummaryConfig] = None + ): """Request context summarization from LLM service. Creates a summarization request frame and either handles it directly using a dedicated LLM (if configured) or emits it via event handler - for the pipeline's primary LLM. Tracks the request ID to match async - responses and prevent race conditions. + for the pipeline's primary LLM. + Tracks the request ID to match async responses and prevent race conditions. + + Args: + config_override: Optional per-request summary configuration. If provided, + overrides the default summary generation settings from + ``self._auto_config.summary_config``. """ # Generate unique request ID request_id = str(uuid.uuid4()) - min_keep = self._config.min_messages_after_summary + summary_config = config_override or self._auto_config.summary_config # Mark summarization in progress self._summarization_in_progress = True @@ -251,16 +297,16 @@ class LLMContextSummarizer(BaseObject): request_frame = LLMContextSummaryRequestFrame( request_id=request_id, context=self._context, - min_messages_to_keep=min_keep, - target_context_tokens=self._config.target_context_tokens, - summarization_prompt=self._config.summary_prompt, - summarization_timeout=self._config.summarization_timeout, + min_messages_to_keep=summary_config.min_messages_after_summary, + target_context_tokens=summary_config.target_context_tokens, + summarization_prompt=summary_config.summary_prompt, + summarization_timeout=summary_config.summarization_timeout, ) - if self._config.llm: + if summary_config.llm: # Use dedicated LLM directly — no need to involve the pipeline self.task_manager.create_task( - self._generate_summary_with_dedicated_llm(self._config.llm, request_frame), + self._generate_summary_with_dedicated_llm(summary_config.llm, request_frame), f"{self}-dedicated-llm-summary", ) else: @@ -323,7 +369,9 @@ class LLMContextSummarizer(BaseObject): """ logger.debug(f"{self}: Received summary result (request_id={frame.request_id})") - # Check if this is the result we're waiting for + # Check if this is the result we're waiting for. Both auto and manual + # summarization set _pending_summary_request_id via _request_summarization(), + # so this check always applies. if frame.request_id != self._pending_summary_request_id: logger.debug(f"{self}: Ignoring stale summary result (request_id={frame.request_id})") return @@ -360,7 +408,7 @@ class LLMContextSummarizer(BaseObject): if last_summarized_index >= len(self._context.messages): return False - min_keep = self._config.min_messages_after_summary + min_keep = self._auto_config.summary_config.min_messages_after_summary remaining = len(self._context.messages) - 1 - last_summarized_index if remaining < min_keep: return False @@ -377,6 +425,7 @@ class LLMContextSummarizer(BaseObject): summary: The generated summary text. last_summarized_index: Index of the last message that was summarized. """ + config = self._auto_config.summary_config messages = self._context.messages # Find the first system message to preserve. LLMSpecificMessage instances are excluded @@ -397,7 +446,7 @@ class LLMContextSummarizer(BaseObject): # Create summary message as a user message (the summary is context # provided *to* the assistant, not something the assistant said) - summary_content = self._config.summary_message_template.format(summary=summary) + summary_content = config.summary_message_template.format(summary=summary) summary_message = {"role": "user", "content": summary_content} # Reconstruct context From dfd0a515f320ae47091713c4a9a071c91fa7eafd Mon Sep 17 00:00:00 2001 From: filipi87 Date: Fri, 27 Feb 2026 18:42:13 -0300 Subject: [PATCH 5/8] Changelog entries for the context summarization improvements. --- changelog/3863.added.2.md | 1 + changelog/3863.added.md | 1 + changelog/3863.changed.md | 1 + changelog/3863.deprecated.md | 1 + 4 files changed, 4 insertions(+) create mode 100644 changelog/3863.added.2.md create mode 100644 changelog/3863.added.md create mode 100644 changelog/3863.changed.md create mode 100644 changelog/3863.deprecated.md diff --git a/changelog/3863.added.2.md b/changelog/3863.added.2.md new file mode 100644 index 000000000..9c0ab90ba --- /dev/null +++ b/changelog/3863.added.2.md @@ -0,0 +1 @@ +- Added `LLMContextSummaryConfig` (summary generation params: `target_context_tokens`, `min_messages_after_summary`, `summarization_prompt`) and `LLMAutoContextSummarizationConfig` (auto-trigger thresholds: `max_context_tokens`, `max_unsummarized_messages`, plus a nested `summary_config`). These replace the monolithic `LLMContextSummarizationConfig`. diff --git a/changelog/3863.added.md b/changelog/3863.added.md new file mode 100644 index 000000000..d6214aed0 --- /dev/null +++ b/changelog/3863.added.md @@ -0,0 +1 @@ +- Added `LLMSummarizeContextFrame` to trigger on-demand context summarization from anywhere in the pipeline (e.g. a function call tool). Accepts an optional `config: LLMContextSummaryConfig` to override summary generation settings per request. diff --git a/changelog/3863.changed.md b/changelog/3863.changed.md new file mode 100644 index 000000000..faf5712d8 --- /dev/null +++ b/changelog/3863.changed.md @@ -0,0 +1 @@ +- ⚠️ Renamed `LLMAssistantAggregatorParams` fields: `enable_context_summarization` → `enable_auto_context_summarization` and `context_summarization_config` → `auto_context_summarization_config` (now accepts `LLMAutoContextSummarizationConfig`). The old names still work with a `DeprecationWarning` for one release cycle. diff --git a/changelog/3863.deprecated.md b/changelog/3863.deprecated.md new file mode 100644 index 000000000..ba2311fbd --- /dev/null +++ b/changelog/3863.deprecated.md @@ -0,0 +1 @@ +- Deprecated `LLMContextSummarizationConfig`. Use `LLMAutoContextSummarizationConfig` with a nested `LLMContextSummaryConfig` instead. The old class emits a `DeprecationWarning`. From 69414e8a5ab8b9569c4e40b17cba7d186081fbdd Mon Sep 17 00:00:00 2001 From: filipi87 Date: Fri, 27 Feb 2026 18:42:23 -0300 Subject: [PATCH 6/8] Added example 54b-context-summarization-manual-openai.py demonstrating on-demand summarization triggered via a function call tool. --- ...54b-context-summarization-manual-openai.py | 179 ++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 examples/foundational/54b-context-summarization-manual-openai.py diff --git a/examples/foundational/54b-context-summarization-manual-openai.py b/examples/foundational/54b-context-summarization-manual-openai.py new file mode 100644 index 000000000..e8acf4bf1 --- /dev/null +++ b/examples/foundational/54b-context-summarization-manual-openai.py @@ -0,0 +1,179 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""Example demonstrating manual context summarization via a function call. + +This example shows how to trigger context summarization on demand rather than +automatically. The user can ask the bot to "summarize the conversation" and the +bot will call a function that pushes an LLMSummarizeContextFrame into the +pipeline, causing the LLM service to compress the conversation history. + +Unlike example 54, automatic summarization is NOT enabled here. Summarization +only happens when the user explicitly requests it through the function call. +""" + +import os + +from dotenv import load_dotenv +from loguru import logger + +from pipecat.adapters.schemas.function_schema import FunctionSchema +from pipecat.adapters.schemas.tools_schema import ToolsSchema +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, LLMSummarizeContextFrame +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 ( + LLMContextAggregatorPair, + LLMUserAggregatorParams, +) +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.llm_service import FunctionCallParams +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 + +load_dotenv(override=True) + +# We use lambdas to defer transport parameter creation until the transport +# type is selected at runtime. +transport_params = { + "daily": lambda: DailyParams( + audio_in_enabled=True, + audio_out_enabled=True, + ), + "twilio": lambda: FastAPIWebsocketParams( + audio_in_enabled=True, + audio_out_enabled=True, + ), + "webrtc": lambda: TransportParams( + audio_in_enabled=True, + audio_out_enabled=True, + ), +} + + +async def summarize_conversation(params: FunctionCallParams): + """Trigger manual context summarization via a pipeline frame.""" + logger.info("Tool called: summarize_conversation") + await params.result_callback({"status": "summarization_requested"}) + await params.llm.queue_frame(LLMSummarizeContextFrame()) + + +async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): + logger.info("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")) + + llm.register_function("summarize_conversation", summarize_conversation) + + summarize_function = FunctionSchema( + name="summarize_conversation", + description=( + "Summarize and compress the conversation history. " + "Call this when the user asks you to summarize the conversation " + "or when you want to free up context space." + ), + properties={}, + required=[], + ) + tools = ToolsSchema(standard_tools=[summarize_function]) + + 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 and helpful way. " + "If the user asks you to summarize the conversation, call the " + "summarize_conversation function. After summarization, briefly acknowledge " + "that the conversation history has been compressed." + ), + }, + ] + + context = LLMContext(messages, tools=tools) + + # Automatic summarization is NOT enabled here (enable_auto_context_summarization + # defaults to False). The summarizer is still created internally so that + # LLMSummarizeContextFrame frames pushed via the function call are handled. + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams( + user_turn_strategies=UserTurnStrategies( + stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] + ), + vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), + ), + ) + + pipeline = Pipeline( + [ + transport.input(), # Transport user input + 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("Client connected") + # Kick off the conversation. + messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + @transport.event_handler("on_client_disconnected") + async def on_client_disconnected(transport, client): + logger.info("Client disconnected") + await task.cancel() + + 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 0839e3813f658998f03ccd750b7084088caecd21 Mon Sep 17 00:00:00 2001 From: filipi87 Date: Fri, 27 Feb 2026 18:42:39 -0300 Subject: [PATCH 7/8] Refactoring the examples to use the new context summarization classes. --- .../54-context-summarization-openai.py | 15 +++++--- .../54a-context-summarization-google.py | 15 +++++--- ...54c-context-summarization-dedicated-llm.py | 35 +++++++++++-------- 3 files changed, 40 insertions(+), 25 deletions(-) diff --git a/examples/foundational/54-context-summarization-openai.py b/examples/foundational/54-context-summarization-openai.py index 45f27854f..ff6701bec 100644 --- a/examples/foundational/54-context-summarization-openai.py +++ b/examples/foundational/54-context-summarization-openai.py @@ -41,7 +41,10 @@ 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.utils.context.llm_context_summarization import LLMContextSummarizationConfig +from pipecat.utils.context.llm_context_summarization import ( + LLMAutoContextSummarizationConfig, + LLMContextSummaryConfig, +) load_dotenv(override=True) @@ -120,14 +123,16 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): vad_analyzer=SileroVADAnalyzer(), ), assistant_params=LLMAssistantAggregatorParams( - enable_context_summarization=True, + enable_auto_context_summarization=True, # Optional: customize context summarization behavior # Using low limits to demonstrate the feature quickly - context_summarization_config=LLMContextSummarizationConfig( + auto_context_summarization_config=LLMAutoContextSummarizationConfig( max_context_tokens=1000, # Trigger summarization at 1000 tokens - target_context_tokens=800, # Target context size for the summarization max_unsummarized_messages=10, # Or when 10 new messages accumulate - min_messages_after_summary=2, # Keep last 2 messages uncompressed + summary_config=LLMContextSummaryConfig( + target_context_tokens=800, # Target context size for the summarization + min_messages_after_summary=2, # Keep last 2 messages uncompressed + ), ), ), ) diff --git a/examples/foundational/54a-context-summarization-google.py b/examples/foundational/54a-context-summarization-google.py index 2ce29e959..7d2a91310 100644 --- a/examples/foundational/54a-context-summarization-google.py +++ b/examples/foundational/54a-context-summarization-google.py @@ -41,7 +41,10 @@ from pipecat.services.llm_service import FunctionCallParams from pipecat.transports.base_transport import BaseTransport, TransportParams from pipecat.transports.daily.transport import DailyParams from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams -from pipecat.utils.context.llm_context_summarization import LLMContextSummarizationConfig +from pipecat.utils.context.llm_context_summarization import ( + LLMAutoContextSummarizationConfig, + LLMContextSummaryConfig, +) load_dotenv(override=True) @@ -120,14 +123,16 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): vad_analyzer=SileroVADAnalyzer(), ), assistant_params=LLMAssistantAggregatorParams( - enable_context_summarization=True, + enable_auto_context_summarization=True, # Optional: customize context summarization behavior # Using low limits to demonstrate the feature quickly - context_summarization_config=LLMContextSummarizationConfig( + auto_context_summarization_config=LLMAutoContextSummarizationConfig( max_context_tokens=1000, # Trigger summarization at 1000 tokens - target_context_tokens=800, # Target context size for the summarization max_unsummarized_messages=10, # Or when 10 new messages accumulate - min_messages_after_summary=2, # Keep last 2 messages uncompressed + summary_config=LLMContextSummaryConfig( + target_context_tokens=800, # Target context size for the summarization + min_messages_after_summary=2, # Keep last 2 messages uncompressed + ), ), ), ) diff --git a/examples/foundational/54c-context-summarization-dedicated-llm.py b/examples/foundational/54c-context-summarization-dedicated-llm.py index 3b2195e80..1dce3890f 100644 --- a/examples/foundational/54c-context-summarization-dedicated-llm.py +++ b/examples/foundational/54c-context-summarization-dedicated-llm.py @@ -44,7 +44,10 @@ 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.utils.context.llm_context_summarization import LLMContextSummarizationConfig +from pipecat.utils.context.llm_context_summarization import ( + LLMAutoContextSummarizationConfig, + LLMContextSummaryConfig, +) load_dotenv(override=True) @@ -147,23 +150,25 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): vad_analyzer=SileroVADAnalyzer(), ), assistant_params=LLMAssistantAggregatorParams( - enable_context_summarization=True, - context_summarization_config=LLMContextSummarizationConfig( + enable_auto_context_summarization=True, + auto_context_summarization_config=LLMAutoContextSummarizationConfig( # Trigger thresholds (low values to demonstrate quickly) max_context_tokens=1000, max_unsummarized_messages=10, - # Summary generation - target_context_tokens=800, - min_messages_after_summary=2, - summarization_prompt=CUSTOM_SUMMARIZATION_PROMPT, - # Custom summary format - wrap in XML tags so the system - # prompt can identify summaries vs. live conversation - summary_message_template="\n{summary}\n", - # Use a dedicated cheap LLM for summarization instead of - # the primary conversation model - llm=summarization_llm, - # Cancel summarization if it takes longer than 60 seconds - summarization_timeout=60.0, + summary_config=LLMContextSummaryConfig( + # Summary generation + target_context_tokens=800, + min_messages_after_summary=2, + summarization_prompt=CUSTOM_SUMMARIZATION_PROMPT, + # Custom summary format - wrap in XML tags so the system + # prompt can identify summaries vs. live conversation + summary_message_template="\n{summary}\n", + # Use a dedicated cheap LLM for summarization instead of + # the primary conversation model + llm=summarization_llm, + # Cancel summarization if it takes longer than 60 seconds + summarization_timeout=60.0, + ), ), ), ) From d077a810ae22b4270abb0791e524298e9e99ab00 Mon Sep 17 00:00:00 2001 From: filipi87 Date: Fri, 27 Feb 2026 18:42:50 -0300 Subject: [PATCH 8/8] Fixing context summarization tests --- tests/test_context_summarization.py | 104 ++++++++++++++--- tests/test_llm_context_summarizer.py | 162 ++++++++++++++++++++++++--- 2 files changed, 232 insertions(+), 34 deletions(-) diff --git a/tests/test_context_summarization.py b/tests/test_context_summarization.py index ca56e7a32..10223a606 100644 --- a/tests/test_context_summarization.py +++ b/tests/test_context_summarization.py @@ -14,8 +14,10 @@ from pipecat.frames.frames import LLMContextSummaryRequestFrame, LLMContextSumma from pipecat.processors.aggregators.llm_context import LLMContext, LLMSpecificMessage from pipecat.services.llm_service import LLMService from pipecat.utils.context.llm_context_summarization import ( + LLMAutoContextSummarizationConfig, LLMContextSummarizationConfig, LLMContextSummarizationUtil, + LLMContextSummaryConfig, ) @@ -167,43 +169,109 @@ class TestContextSummarizationMixin(unittest.TestCase): self.assertIn("USER: First part Second part", transcript) -class TestLLMContextSummarizationConfig(unittest.TestCase): - """Tests for LLMContextSummarizationConfig.""" +class TestLLMContextSummaryConfig(unittest.TestCase): + """Tests for LLMContextSummaryConfig.""" def test_default_config(self): """Test default configuration values.""" - config = LLMContextSummarizationConfig() + config = LLMContextSummaryConfig() - self.assertEqual(config.max_context_tokens, 8000) - self.assertEqual(config.max_unsummarized_messages, 20) + self.assertEqual(config.target_context_tokens, 6000) self.assertEqual(config.min_messages_after_summary, 4) self.assertIsNone(config.summarization_prompt) def test_custom_config(self): """Test custom configuration.""" - config = LLMContextSummarizationConfig( - max_context_tokens=2500, + config = LLMContextSummaryConfig( target_context_tokens=2000, - max_unsummarized_messages=15, min_messages_after_summary=4, summarization_prompt="Custom prompt", ) - self.assertEqual(config.max_context_tokens, 2500) self.assertEqual(config.target_context_tokens, 2000) - self.assertEqual(config.max_unsummarized_messages, 15) self.assertEqual(config.min_messages_after_summary, 4) self.assertEqual(config.summary_prompt, "Custom prompt") def test_summary_prompt_property(self): """Test summary_prompt property uses default when None.""" - config = LLMContextSummarizationConfig() + config = LLMContextSummaryConfig() self.assertIn("summarizing a conversation", config.summary_prompt.lower()) - config_with_custom = LLMContextSummarizationConfig(summarization_prompt="Custom") + config_with_custom = LLMContextSummaryConfig(summarization_prompt="Custom") self.assertEqual(config_with_custom.summary_prompt, "Custom") +class TestLLMAutoContextSummarizationConfig(unittest.TestCase): + """Tests for LLMAutoContextSummarizationConfig.""" + + def test_default_config(self): + """Test default configuration values.""" + config = LLMAutoContextSummarizationConfig() + + self.assertEqual(config.max_context_tokens, 8000) + self.assertEqual(config.max_unsummarized_messages, 20) + self.assertEqual(config.summary_config.target_context_tokens, 6000) + self.assertEqual(config.summary_config.min_messages_after_summary, 4) + + def test_custom_config(self): + """Test custom configuration.""" + config = LLMAutoContextSummarizationConfig( + max_context_tokens=2500, + max_unsummarized_messages=15, + summary_config=LLMContextSummaryConfig( + target_context_tokens=2000, + min_messages_after_summary=4, + summarization_prompt="Custom prompt", + ), + ) + + self.assertEqual(config.max_context_tokens, 2500) + self.assertEqual(config.max_unsummarized_messages, 15) + self.assertEqual(config.summary_config.target_context_tokens, 2000) + self.assertEqual(config.summary_config.min_messages_after_summary, 4) + self.assertEqual(config.summary_config.summary_prompt, "Custom prompt") + + def test_target_tokens_auto_adjusted(self): + """Test that target_context_tokens is auto-adjusted when it exceeds max.""" + config = LLMAutoContextSummarizationConfig( + max_context_tokens=1000, + summary_config=LLMContextSummaryConfig(target_context_tokens=9000), + ) + self.assertLessEqual(config.summary_config.target_context_tokens, config.max_context_tokens) + + +class TestLLMContextSummarizationConfigDeprecated(unittest.TestCase): + """Tests for deprecated LLMContextSummarizationConfig.""" + + def test_emits_deprecation_warning(self): + """Test that instantiating the deprecated config emits a DeprecationWarning.""" + with self.assertWarns(DeprecationWarning): + LLMContextSummarizationConfig() + + def test_to_auto_config(self): + """Test conversion to the new LLMAutoContextSummarizationConfig.""" + import warnings + + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + old_config = LLMContextSummarizationConfig( + max_context_tokens=2500, + target_context_tokens=2000, + max_unsummarized_messages=15, + min_messages_after_summary=4, + summarization_prompt="Custom", + ) + + new_config = old_config.to_auto_config() + + self.assertIsInstance(new_config, LLMAutoContextSummarizationConfig) + self.assertEqual(new_config.max_context_tokens, 2500) + self.assertEqual(new_config.max_unsummarized_messages, 15) + self.assertEqual(new_config.summary_config.target_context_tokens, 2000) + self.assertEqual(new_config.summary_config.min_messages_after_summary, 4) + self.assertEqual(new_config.summary_config.summarization_prompt, "Custom") + + class TestFunctionCallHandling(unittest.TestCase): """Tests for function call handling in summarization.""" @@ -670,10 +738,12 @@ class TestDedicatedLLMSummarization(unittest.IsolatedAsyncioTestCase): {"role": "user", "content": f"Test message {i} that adds tokens to context."} ) - config = LLMContextSummarizationConfig( + config = LLMAutoContextSummarizationConfig( max_context_tokens=50, # Very low to trigger easily - llm=dedicated_llm, - summarization_timeout=5.0, + summary_config=LLMContextSummaryConfig( + llm=dedicated_llm, + summarization_timeout=5.0, + ), ) return context, config @@ -736,7 +806,7 @@ class TestDedicatedLLMSummarization(unittest.IsolatedAsyncioTestCase): dedicated_llm._generate_summary = slow_summary context, config = self._create_context_and_config(dedicated_llm) - config.summarization_timeout = 0.1 # Very short timeout + config.summary_config.summarization_timeout = 0.1 # Very short timeout summarizer = LLMContextSummarizer(context=context, config=config) await summarizer.setup(self.task_manager) @@ -826,7 +896,7 @@ class TestDedicatedLLMSummarization(unittest.IsolatedAsyncioTestCase): {"role": "user", "content": f"Test message {i} that adds tokens to context."} ) - config = LLMContextSummarizationConfig(max_context_tokens=50) + config = LLMAutoContextSummarizationConfig(max_context_tokens=50) summarizer = LLMContextSummarizer(context=context, config=config) await summarizer.setup(self.task_manager) diff --git a/tests/test_llm_context_summarizer.py b/tests/test_llm_context_summarizer.py index 0439d403d..7e8b326f9 100644 --- a/tests/test_llm_context_summarizer.py +++ b/tests/test_llm_context_summarizer.py @@ -12,6 +12,7 @@ from pipecat.frames.frames import ( LLMContextSummaryRequestFrame, LLMContextSummaryResultFrame, LLMFullResponseStartFrame, + LLMSummarizeContextFrame, ) from pipecat.processors.aggregators.llm_context import LLMContext from pipecat.processors.aggregators.llm_context_summarizer import ( @@ -19,7 +20,10 @@ from pipecat.processors.aggregators.llm_context_summarizer import ( SummaryAppliedEvent, ) from pipecat.utils.asyncio.task_manager import TaskManager, TaskManagerParams -from pipecat.utils.context.llm_context_summarization import LLMContextSummarizationConfig +from pipecat.utils.context.llm_context_summarization import ( + LLMAutoContextSummarizationConfig, + LLMContextSummaryConfig, +) class TestLLMContextSummarizer(unittest.IsolatedAsyncioTestCase): @@ -35,7 +39,7 @@ class TestLLMContextSummarizer(unittest.IsolatedAsyncioTestCase): async def test_summarization_triggered_by_token_limit(self): """Test that summarization is triggered when token limit is reached.""" - config = LLMContextSummarizationConfig( + config = LLMAutoContextSummarizationConfig( max_context_tokens=100, # Very low to trigger easily max_unsummarized_messages=100, # High so it doesn't trigger by message count ) @@ -71,7 +75,7 @@ class TestLLMContextSummarizer(unittest.IsolatedAsyncioTestCase): async def test_summarization_triggered_by_message_count(self): """Test that summarization is triggered when message count threshold is reached.""" - config = LLMContextSummarizationConfig( + config = LLMAutoContextSummarizationConfig( max_context_tokens=100000, # Very high so it doesn't trigger by tokens max_unsummarized_messages=5, # Low to trigger easily ) @@ -101,7 +105,7 @@ class TestLLMContextSummarizer(unittest.IsolatedAsyncioTestCase): async def test_summarization_not_triggered_below_thresholds(self): """Test that summarization is not triggered when below thresholds.""" - config = LLMContextSummarizationConfig( + config = LLMAutoContextSummarizationConfig( max_context_tokens=10000, max_unsummarized_messages=20, ) @@ -130,7 +134,7 @@ class TestLLMContextSummarizer(unittest.IsolatedAsyncioTestCase): async def test_summarization_in_progress_prevents_duplicate(self): """Test that a summarization in progress prevents triggering another.""" - config = LLMContextSummarizationConfig( + config = LLMAutoContextSummarizationConfig( max_context_tokens=50, # Very low max_unsummarized_messages=100, ) @@ -161,7 +165,10 @@ class TestLLMContextSummarizer(unittest.IsolatedAsyncioTestCase): async def test_summary_result_handling(self): """Test that summary results are processed and applied correctly.""" - config = LLMContextSummarizationConfig(max_context_tokens=50, min_messages_after_summary=2) + config = LLMAutoContextSummarizationConfig( + max_context_tokens=50, + summary_config=LLMContextSummaryConfig(min_messages_after_summary=2), + ) summarizer = LLMContextSummarizer(context=self.context, config=config) await summarizer.setup(self.task_manager) @@ -208,7 +215,7 @@ class TestLLMContextSummarizer(unittest.IsolatedAsyncioTestCase): async def test_interruption_cancels_summarization(self): """Test that an interruption cancels pending summarization.""" - config = LLMContextSummarizationConfig(max_context_tokens=50) + config = LLMAutoContextSummarizationConfig(max_context_tokens=50) summarizer = LLMContextSummarizer(context=self.context, config=config) await summarizer.setup(self.task_manager) @@ -238,7 +245,10 @@ class TestLLMContextSummarizer(unittest.IsolatedAsyncioTestCase): async def test_stale_summary_result_ignored(self): """Test that stale summary results are ignored.""" - config = LLMContextSummarizationConfig(max_context_tokens=50, min_messages_after_summary=2) + config = LLMAutoContextSummarizationConfig( + max_context_tokens=50, + summary_config=LLMContextSummaryConfig(min_messages_after_summary=2), + ) summarizer = LLMContextSummarizer(context=self.context, config=config) await summarizer.setup(self.task_manager) @@ -294,9 +304,116 @@ class TestLLMContextSummarizer(unittest.IsolatedAsyncioTestCase): await summarizer.cleanup() + async def test_manual_summarization_via_frame(self): + """Test that LLMSummarizeContextFrame triggers summarization on demand.""" + config = LLMAutoContextSummarizationConfig( + max_context_tokens=100000, # High — auto trigger would never fire + max_unsummarized_messages=100, + ) + + summarizer = LLMContextSummarizer( + context=self.context, + config=config, + auto_trigger=False, # Disable auto; only manual requests should work + ) + await summarizer.setup(self.task_manager) + + request_frame = None + + @summarizer.event_handler("on_request_summarization") + async def on_request_summarization(summarizer, frame): + nonlocal request_frame + request_frame = frame + + # Add messages + for i in range(5): + self.context.add_message({"role": "user", "content": f"Message {i}"}) + + # Auto-trigger should NOT fire even on LLMFullResponseStartFrame + await summarizer.process_frame(LLMFullResponseStartFrame()) + self.assertIsNone(request_frame) + + # Manual trigger via LLMSummarizeContextFrame should fire + await summarizer.process_frame(LLMSummarizeContextFrame()) + self.assertIsNotNone(request_frame) + self.assertIsInstance(request_frame, LLMContextSummaryRequestFrame) + + # The request must have a valid request_id and carry the current context + self.assertTrue(request_frame.request_id) + self.assertEqual(request_frame.context, self.context) + + await summarizer.cleanup() + + async def test_manual_summarization_with_config_override(self): + """Test that LLMSummarizeContextFrame can override default summary config.""" + config = LLMAutoContextSummarizationConfig( + max_context_tokens=100000, + summary_config=LLMContextSummaryConfig( + target_context_tokens=6000, + min_messages_after_summary=4, + ), + ) + + summarizer = LLMContextSummarizer(context=self.context, config=config) + await summarizer.setup(self.task_manager) + + request_frame = None + + @summarizer.event_handler("on_request_summarization") + async def on_request_summarization(summarizer, frame): + nonlocal request_frame + request_frame = frame + + for i in range(5): + self.context.add_message({"role": "user", "content": f"Message {i}"}) + + # Push a manual frame with custom config overrides + custom_config = LLMContextSummaryConfig( + target_context_tokens=500, + min_messages_after_summary=1, + ) + await summarizer.process_frame(LLMSummarizeContextFrame(config=custom_config)) + + self.assertIsNotNone(request_frame) + # The request should use the overridden values + self.assertEqual(request_frame.target_context_tokens, 500) + self.assertEqual(request_frame.min_messages_to_keep, 1) + + await summarizer.cleanup() + + async def test_manual_summarization_blocked_when_in_progress(self): + """Test that a second LLMSummarizeContextFrame is ignored while one is in progress.""" + config = LLMAutoContextSummarizationConfig(max_context_tokens=100000) + + summarizer = LLMContextSummarizer(context=self.context, config=config) + await summarizer.setup(self.task_manager) + + request_count = 0 + + @summarizer.event_handler("on_request_summarization") + async def on_request_summarization(summarizer, frame): + nonlocal request_count + request_count += 1 + + for i in range(5): + self.context.add_message({"role": "user", "content": f"Message {i}"}) + + # First manual request + await summarizer.process_frame(LLMSummarizeContextFrame()) + self.assertEqual(request_count, 1) + + # Second manual request while first is in progress — should be ignored + await summarizer.process_frame(LLMSummarizeContextFrame()) + self.assertEqual(request_count, 1) + + await summarizer.cleanup() + async def test_summary_message_role_is_user(self): """Test that the summary message uses the user role.""" - config = LLMContextSummarizationConfig(max_context_tokens=50, min_messages_after_summary=2) + config = LLMAutoContextSummarizationConfig( + max_context_tokens=50, + summary_config=LLMContextSummaryConfig(min_messages_after_summary=2), + ) summarizer = LLMContextSummarizer(context=self.context, config=config) await summarizer.setup(self.task_manager) @@ -335,7 +452,10 @@ class TestLLMContextSummarizer(unittest.IsolatedAsyncioTestCase): async def test_summary_message_default_template(self): """Test that the default summary_message_template is used.""" - config = LLMContextSummarizationConfig(max_context_tokens=50, min_messages_after_summary=2) + config = LLMAutoContextSummarizationConfig( + max_context_tokens=50, + summary_config=LLMContextSummaryConfig(min_messages_after_summary=2), + ) summarizer = LLMContextSummarizer(context=self.context, config=config) await summarizer.setup(self.task_manager) @@ -377,10 +497,12 @@ class TestLLMContextSummarizer(unittest.IsolatedAsyncioTestCase): async def test_summary_message_custom_template(self): """Test that a custom summary_message_template is applied.""" - config = LLMContextSummarizationConfig( + config = LLMAutoContextSummarizationConfig( max_context_tokens=50, - min_messages_after_summary=2, - summary_message_template="\n{summary}\n", + summary_config=LLMContextSummaryConfig( + min_messages_after_summary=2, + summary_message_template="\n{summary}\n", + ), ) summarizer = LLMContextSummarizer(context=self.context, config=config) @@ -420,7 +542,10 @@ class TestLLMContextSummarizer(unittest.IsolatedAsyncioTestCase): async def test_on_summary_applied_event(self): """Test that on_summary_applied event fires with correct data.""" - config = LLMContextSummarizationConfig(max_context_tokens=50, min_messages_after_summary=2) + config = LLMAutoContextSummarizationConfig( + max_context_tokens=50, + summary_config=LLMContextSummaryConfig(min_messages_after_summary=2), + ) summarizer = LLMContextSummarizer(context=self.context, config=config) await summarizer.setup(self.task_manager) @@ -474,7 +599,10 @@ class TestLLMContextSummarizer(unittest.IsolatedAsyncioTestCase): async def test_on_summary_applied_not_fired_on_error(self): """Test that on_summary_applied event is NOT fired when summarization fails.""" - config = LLMContextSummarizationConfig(max_context_tokens=50, min_messages_after_summary=2) + config = LLMAutoContextSummarizationConfig( + max_context_tokens=50, + summary_config=LLMContextSummaryConfig(min_messages_after_summary=2), + ) summarizer = LLMContextSummarizer(context=self.context, config=config) await summarizer.setup(self.task_manager) @@ -515,9 +643,9 @@ class TestLLMContextSummarizer(unittest.IsolatedAsyncioTestCase): async def test_request_frame_includes_timeout(self): """Test that the request frame includes the configured summarization_timeout.""" - config = LLMContextSummarizationConfig( + config = LLMAutoContextSummarizationConfig( max_context_tokens=50, - summarization_timeout=60.0, + summary_config=LLMContextSummaryConfig(summarization_timeout=60.0), ) summarizer = LLMContextSummarizer(context=self.context, config=config)