"""`SummarizingCompaction` -- LLM-powered summarization of older messages."""
from __future__ import annotations
from collections.abc import Callable, Sequence
from dataclasses import dataclass, field, replace
from typing import TYPE_CHECKING
from pydantic_ai._run_context import AgentDepsT
from pydantic_ai.capabilities import AbstractCapability
from pydantic_ai.messages import (
ModelMessage,
ModelRequest,
ModelResponse,
SystemPromptPart,
TextContent,
TextPart,
ToolCallPart,
ToolReturnPart,
UserPromptPart,
)
from pydantic_ai.models.fallback import FallbackModel
from pydantic_ai.tools import RunContext
from pydantic_ai_harness.compaction._context_window import DEFAULT_CONTEXT_WINDOW
from pydantic_ai_harness.compaction._pinning import is_pinned, reinject_pinned
from pydantic_ai_harness.compaction._receipts import (
ReceiptInfo,
discover_transcript_handle,
format_receipt,
is_receipt_part,
make_receipt_part,
record_receipt,
)
from pydantic_ai_harness.compaction._shared import (
compact_with_span,
context_for_request,
estimate_token_count,
exceeds,
find_first_user_message,
find_safe_cutoff,
find_token_cutoff,
resolve_token_trigger,
validate_token_trigger,
)
if TYPE_CHECKING:
from pydantic_ai.messages import ModelRequestPart, UserContent
from pydantic_ai.models import Model, ModelRequestContext
_DEFAULT_SUMMARY_PROMPT = """\
You are a context summarization assistant. The conversation below will be replaced by \
your summary, so it must carry everything needed to continue the task.
Write the summary under these exact section headings, omitting a section only if it has \
no content:
## Intent
The user's overall goal and any standing constraints or preferences.
## Key decisions
Choices made and the reasoning, so they are not relitigated.
## Artifacts
Files, paths, identifiers, commands, and APIs touched -- quote exact names.
## Current state
What is done and what is in progress right now.
## Next steps
The immediate actions still required to finish the task.
## Open questions
Unresolved questions or blockers.
Focus on results, not a replay of completed actions. Respond ONLY with the summary -- no \
preamble, no markdown fences.
{messages}
\
"""
_SUMMARY_PREFIX = 'Summary of previous conversation:\n\n'
# Anchored-incremental update instruction (opencode mechanism): the previous summary is fed
# back as an anchor to update in place rather than a summary to re-summarize, which avoids
# summary-of-summary decay. Wording is minimal/neutral and flagged pending the eval-rig pass.
_INCREMENTAL_UPDATE_INSTRUCTION = (
'An anchored summary from earlier compaction is provided below in . '
'Update it using the conversation above: preserve still-true details, remove stale '
'details, and merge in new facts. Keep the same section structure.'
)
# Cross-model bridge prefix (Codex prior art): when the summarizer's model family differs from
# the family that produced the history, prefix the summary with a neutral one-liner so the
# resuming model treats it as a handoff from a different model. Wording flagged pending eval-rig.
_BRIDGE_PREFIX = 'This summary was produced by a different model than the one continuing the task.'
_KEPT_USER_MESSAGE_METADATA = 'pydantic-ai-harness.compaction.kept-user-message.v1'
"""Model-request metadata marking a user turn retained by `keep_user_messages`."""
def _model_name(model: str | Model | None) -> str | None:
"""Best-effort model-name string from a model spec or object."""
if model is None: # pragma: no cover - Pydantic AI always supplies the running model
return None
if isinstance(model, str):
return model
return getattr(model, 'model_name', None)
def _history_model_name(messages: Sequence[ModelMessage]) -> str | None:
"""Return the most recent response's ``model_name`` -- the family that produced the history."""
for msg in reversed(messages):
if isinstance(msg, ModelResponse) and msg.model_name:
return msg.model_name
return None
def _is_receipt_message(msg: ModelMessage) -> bool:
"""Return True if *msg* is a request made up entirely of receipt parts."""
return isinstance(msg, ModelRequest) and bool(msg.parts) and all(is_receipt_part(p) for p in msg.parts)
def _model_family(model: str | Model | None) -> str | None:
"""Reduce a model name to a coarse family token (e.g. ``openai:gpt-4o`` -> ``gpt``).
A neutral structural heuristic: drop any ``provider:`` prefix, then take the leading token
before the first ``-`` or ``/``. Good enough to tell ``gpt`` from ``claude``; the exact
family taxonomy is left to the eval-rig pass.
"""
if isinstance(model, FallbackModel):
return _model_family(model.models[0])
name = _model_name(model)
if not name: # pragma: no cover - empty model identifiers fail before compaction
return None
tail = name.split(':')[-1]
for sep in ('/', '-'):
tail = tail.split(sep)[0]
return tail or None
def _format_messages(messages: Sequence[ModelMessage], *, skip_previous_summary: bool = False) -> str:
"""Render messages into a human-readable string for summarization."""
lines: list[str] = []
for msg in messages:
if isinstance(msg, ModelRequest):
if _is_kept_user_message(msg):
continue
for part in msg.parts:
if isinstance(part, UserPromptPart):
lines.append(f'User: {_user_prompt_text(part)}')
elif isinstance(part, SystemPromptPart) and not (
skip_previous_summary and part.content.startswith(_SUMMARY_PREFIX)
):
lines.append(f'System: {part.content}')
elif isinstance(part, ToolReturnPart):
content_str = str(part.content)[:500]
if len(str(part.content)) > 500:
content_str += '...'
lines.append(f'Tool [{part.tool_name}]: {content_str}')
else:
for part in msg.parts:
if isinstance(part, TextPart):
lines.append(f'Assistant: {part.content}')
elif isinstance(part, ToolCallPart):
lines.append(f'Tool Call [{part.tool_name}]: {part.args}')
return '\n'.join(lines)
def _user_prompt_text(part: UserPromptPart) -> str:
"""Extract text content from a user prompt part."""
if isinstance(part.content, str):
return part.content
texts: list[str] = []
for item in part.content:
if isinstance(item, str):
texts.append(item)
elif isinstance(item, TextContent):
texts.append(item.content)
return ' '.join(texts) if texts else ''
def _extract_system_prompts(messages: list[ModelMessage]) -> list[SystemPromptPart]:
"""Extract leading system-prompt parts from the conversation."""
parts: list[SystemPromptPart] = []
for msg in messages:
if not isinstance(msg, ModelRequest):
break
for part in msg.parts:
if isinstance(part, SystemPromptPart) and not part.content.startswith(_SUMMARY_PREFIX):
parts.append(part)
elif is_pinned(part) or is_receipt_part(part):
continue
else:
return parts
return parts
def _extract_previous_summary(messages: list[ModelMessage]) -> str | None:
"""Extract the most recent compaction summary from the message history.
Looks for a ``SystemPromptPart`` whose content starts with the summary prefix,
which indicates it was produced by a prior compaction pass.
"""
for msg in reversed(messages):
if not isinstance(msg, ModelRequest):
continue
for part in reversed(msg.parts):
if isinstance(part, SystemPromptPart) and part.content.startswith(_SUMMARY_PREFIX):
return _without_bridge_prefix(part.content[len(_SUMMARY_PREFIX) :])
return None
def _without_bridge_prefix(summary: str) -> str:
"""Remove the bridge notice before an incremental summary becomes the next anchor."""
return summary.removeprefix(f'{_BRIDGE_PREFIX}\n\n')
def _is_kept_user_message(message: ModelRequest) -> bool:
"""Return whether *message* is an earlier `keep_user_messages` retention copy."""
return message.metadata is not None and message.metadata.get(_KEPT_USER_MESSAGE_METADATA) is True
@dataclass
class SummarizingCompaction(AbstractCapability[AgentDepsT]):
"""LLM-powered conversation compaction.
When the conversation exceeds a configurable threshold, older messages are
summarized using a dedicated model call and replaced with a compact, structured
summary message, preserving recent context and tool-call integrity.
This is the expensive tier -- summarization turns input tokens into (pricier) output
tokens -- so it is best used behind cheaper passes (see `TieredCompaction`).
The summary call's usage is folded into the parent run's usage (it counts as a real
request), so cost accounting stays honest; note this also increments the run's request
count, which a request-count limiter would see.
Example:
```python
from pydantic_ai import Agent
from pydantic_ai_harness.compaction import SummarizingCompaction
agent = Agent(
'openai:gpt-4o',
capabilities=[SummarizingCompaction(
model='openai:gpt-4o-mini',
max_messages=60,
keep_messages=20,
)],
)
```
"""
model: str | Model | None = None
"""Model used to generate summaries.
When `None`, inherits the model the request being compacted is going to. Core starts
that as the run's model, so the two differ only where a capability replaced
`ModelRequestContext.model`; set this explicitly to pin the summarizer regardless.
"""
max_messages: int | None = None
"""Trigger compaction when message count exceeds this value."""
max_tokens: int | None = None
"""Trigger compaction when estimated token count exceeds this value."""
max_fraction: float | None = field(default=None, kw_only=True)
"""Trigger when estimated tokens exceed this fraction of the model's context window.
Resolved per request from the request's model, so one setting behaves correctly on any
model. Mutually exclusive with `max_tokens`."""
context_window: int | None = field(default=None, kw_only=True)
"""Window override in tokens. `None` resolves it from the request's model.
Unlike `fallback_context_window`, this applies whether or not resolution succeeds. Reach
for it when the registry is confidently wrong: a beta- or tier-gated window it records as
the maximum, or a self-hosted endpoint whose model id describes someone else's
deployment. Only consulted alongside `max_fraction`."""
fallback_context_window: int = field(default=DEFAULT_CONTEXT_WINDOW, kw_only=True)
"""Window assumed when the request's model is not in the pricing registry.
Only consulted alongside `max_fraction`. Supply the real number for a deployment the
registry cannot resolve."""
keep_messages: int = 20
"""Number of tail messages to preserve after compaction (message-count trigger)."""
keep_tokens: int | None = None
"""Target token budget to preserve after compaction (token-count trigger).
When ``None``, falls back to ``keep_messages``.
"""
summary_prompt: str = _DEFAULT_SUMMARY_PROMPT
"""Prompt template for generating summaries.
Must contain a ``{messages}`` placeholder.
"""
tokenizer: Callable[[str], int] | None = None
"""Optional tokenizer for accurate token counting.
A callable that returns the token count for a given string.
When ``None``, uses a ~4 characters-per-token heuristic.
"""
preserve_first_user_message: bool = True
"""When ``True``, the first ``ModelRequest`` containing a ``UserPromptPart``
is always kept after compaction, in addition to system prompts.
"""
incremental: bool = True
"""When ``True``, feed any existing summary from a prior compaction back as an anchored
```` block with an update instruction (preserve still-true, remove stale,
merge new) so it is updated in place rather than re-summarized -- avoiding
summary-of-summary decay.
"""
bridge_prefix: bool = False
"""When ``True`` and the summarizer's model family differs from the family that produced
the history, prepend a neutral one-line note marking the summary as a cross-model handoff
(Codex prior art, anti-confabulation). Only fires on a genuine family mismatch, so it is
cheap and off in the common same-model case; the note's wording is flagged pending eval-rig.
"""
keep_user_messages: bool = False
"""When ``True``, preserve recent summarized user messages (each truncated to
``keep_user_messages_max_chars``) alongside the summary. Retained messages consume the
``keep_messages`` tail budget, keeping compaction bounded. Supersedes
``preserve_first_user_message``.
"""
keep_user_messages_max_chars: int = 20_000
"""Per-message character cap for ``keep_user_messages``; oversized messages are truncated
with an explicit marker (the shared truncation-marker convention)."""
receipts: bool = False
"""When ``True``, append a deterministic compaction receipt after the summary noting how
much history was summarized, that the summary is secondhand, and -- when a
``TranscriptHandleProvider`` capability is attached -- a persisted-run handle.
Opt-in for now: the receipt text is content, so defaulting it on is deferred to the
benchmark eval-rig pass. The mechanism itself is structural.
"""
def __post_init__(self) -> None:
if self.max_messages is None and self.max_tokens is None and self.max_fraction is None:
raise ValueError('At least one of max_messages, max_tokens, or max_fraction must be set.')
if self.max_messages is not None and self.max_messages < 1:
raise ValueError('max_messages must be positive.')
validate_token_trigger(self.max_tokens, self.max_fraction, self.fallback_context_window, self.context_window)
if self.keep_messages < 0:
raise ValueError('keep_messages must be non-negative.')
if self.keep_tokens is not None and self.keep_tokens < 0:
raise ValueError('keep_tokens must be non-negative.')
if self.keep_user_messages_max_chars < 1:
raise ValueError('keep_user_messages_max_chars must be positive.')
def with_focus(self, focus: str) -> SummarizingCompaction[AgentDepsT]:
"""Return a copy whose summary prompt prioritizes `focus`.
Used by `compact_now` so a user-invoked compaction can say what the summary must not
lose. The prompt is later run through `str.format`, so braces in a user- or
model-supplied focus are escaped to survive it.
"""
escaped = focus.replace('{', '{{').replace('}', '}}')
return replace(self, summary_prompt=f'{self.summary_prompt}\n\nGive particular weight to: {escaped}')
async def compact(
self,
messages: list[ModelMessage],
ctx: RunContext[AgentDepsT],
) -> list[ModelMessage]:
"""Summarize older messages, replacing them with a single summary message."""
if self.keep_tokens is not None:
cutoff = find_token_cutoff(messages, self.keep_tokens, self.tokenizer)
else:
cutoff = find_safe_cutoff(messages, self.keep_messages)
if cutoff <= 0:
return messages
system_parts = _extract_system_prompts(messages)
to_summarize = messages[:cutoff]
preserved = messages[cutoff:]
previous_summary = _extract_previous_summary(messages) if self.incremental else None
summary = await self._summarize(to_summarize, ctx, previous_summary=previous_summary)
summary = self._maybe_bridge_prefix(summary, messages, ctx)
summary_part = SystemPromptPart(content=f'{_SUMMARY_PREFIX}{summary}')
summary_message = ModelRequest(parts=[*system_parts, summary_part])
extra: list[ModelMessage] = []
if self.keep_user_messages:
extra = self._kept_user_messages(to_summarize)
extra = extra[-self.keep_messages :] if self.keep_messages else []
token_tail_budget = self.keep_tokens
if token_tail_budget is not None:
retained: list[ModelMessage] = []
for message in reversed(extra):
tokens = estimate_token_count([message], self.tokenizer)
if tokens <= token_tail_budget:
retained.append(message)
token_tail_budget -= tokens
else:
break
extra = list(reversed(retained))
retained_tail_slots = self.keep_messages - len(extra)
if token_tail_budget is not None:
if token_tail_budget == 0:
preserved = []
else:
token_tail = preserved[find_token_cutoff(preserved, token_tail_budget, self.tokenizer) :]
preserved = (
token_tail if estimate_token_count(token_tail, self.tokenizer) <= token_tail_budget else []
)
if len(preserved) > retained_tail_slots:
preserved = preserved[find_safe_cutoff(preserved, retained_tail_slots) :]
elif self.preserve_first_user_message:
first_user_msg = find_first_user_message(messages)
if first_user_msg is not None:
idx = messages.index(first_user_msg)
if idx < cutoff and first_user_msg not in preserved:
extra = [first_user_msg]
result: list[ModelMessage] = [summary_message, *extra, *preserved]
result = reinject_pinned(messages, result)
if self.receipts:
result = self._insert_receipt(summary_message, to_summarize, result, ctx)
return result
def _kept_user_messages(self, to_summarize: list[ModelMessage]) -> list[ModelMessage]:
"""Rebuild each summarized user turn as a preserved, size-bounded user message."""
out: list[ModelMessage] = []
for msg in to_summarize:
if not isinstance(msg, ModelRequest):
continue
if _is_kept_user_message(msg):
out.append(msg)
continue
user_parts = [
part
for part in msg.parts
if isinstance(part, UserPromptPart) and not is_pinned(part) and not is_receipt_part(part)
]
if not user_parts:
continue
kept: list[ModelRequestPart] = []
for part in user_parts:
if isinstance(part.content, str):
truncated = self._truncate(part.content)
kept.append(part if truncated == part.content else replace(part, content=truncated))
else:
bounded, changed = self._bound_sequence(part.content)
kept.append(replace(part, content=bounded) if changed else part)
metadata = {**(msg.metadata or {}), _KEPT_USER_MESSAGE_METADATA: True}
out.append(replace(msg, parts=kept, metadata=metadata))
return out
def _truncate(self, text: str, max_chars: int | None = None) -> str:
from pydantic_ai_harness.tool_output_limits import TruncationStrategy
from pydantic_ai_harness.tool_output_limits._payload import truncate_text
limit = self.keep_user_messages_max_chars if max_chars is None else max_chars
truncated = truncate_text(text, limit, TruncationStrategy.head)
if len(truncated) <= limit:
return truncated
marker = '[...]'
if limit <= len(marker):
return marker[:limit]
return f'{text[: limit - len(marker)]}{marker}'
def _bound_sequence(self, content: Sequence[UserContent]) -> tuple[list[UserContent], bool]:
"""Apply the same per-part character budget to a sequence-shaped user prompt.
`keep_user_messages_max_chars` bounds the whole part, so the budget is shared across the
sequence's text-bearing items rather than granted to each one. Non-text items (images,
audio, cache points) pass through: they carry no characters to cut, and rewriting them
would change what the model sees rather than shrink it.
"""
remaining = self.keep_user_messages_max_chars
bounded: list[UserContent] = []
changed = False
for item in content:
text = item if isinstance(item, str) else item.content if isinstance(item, TextContent) else None
if text is None:
bounded.append(item)
continue
if remaining == 0:
changed = True
continue
truncated = self._truncate(text, remaining)
remaining -= len(truncated)
if truncated == text:
bounded.append(item)
continue
changed = True
bounded.append(truncated if isinstance(item, str) else replace(item, content=truncated))
return bounded, changed
def _maybe_bridge_prefix(self, summary: str, messages: list[ModelMessage], ctx: RunContext[AgentDepsT]) -> str:
"""Prefix *summary* with a cross-model handoff note when families differ."""
if not self.bridge_prefix:
return summary
run_family = _model_family(_history_model_name(messages) or ctx.model)
summarizer_family = _model_family(self.model if self.model is not None else ctx.model)
if run_family and summarizer_family and run_family != summarizer_family:
return f'{_BRIDGE_PREFIX}\n\n{_without_bridge_prefix(summary)}'
return summary
def _insert_receipt(
self,
summary_message: ModelRequest,
to_summarize: list[ModelMessage],
result: list[ModelMessage],
ctx: RunContext[AgentDepsT],
) -> list[ModelMessage]:
"""Insert a deterministic receipt right after the summary, de-accumulating old ones."""
deduped = [msg for msg in result if not _is_receipt_message(msg)]
dropped_tokens = estimate_token_count(to_summarize, self.tokenizer)
handle = discover_transcript_handle(ctx)
summarizer = _model_family(self.model if self.model is not None else ctx.model)
by = summarizer or 'the summarizer model'
record_receipt(
ReceiptInfo(
strategy='SummarizingCompaction',
dropped_messages=len(to_summarize),
dropped_tokens=dropped_tokens,
by=by,
handle=handle,
)
)
text = format_receipt(
dropped_messages=len(to_summarize),
dropped_tokens=dropped_tokens,
by=by,
handle=handle,
has_summary=True,
)
receipt_message = ModelRequest(parts=[make_receipt_part(text)])
anchor = deduped.index(summary_message)
return [*deduped[: anchor + 1], receipt_message, *deduped[anchor + 1 :]]
async def before_model_request(
self,
ctx: RunContext[AgentDepsT],
request_context: ModelRequestContext,
) -> ModelRequestContext:
"""Summarize older messages when the threshold is exceeded."""
messages: list[ModelMessage] = list(request_context.messages)
request_ctx = context_for_request(ctx, request_context)
token_trigger = resolve_token_trigger(
self.max_tokens, self.max_fraction, request_ctx.model, self.fallback_context_window, self.context_window
)
if not exceeds(messages, self.max_messages, token_trigger, self.tokenizer):
return request_context
request_context.messages = await compact_with_span(
request_ctx,
strategy='SummarizingCompaction',
messages=messages,
compact=lambda: self.compact(messages, request_ctx),
tokenizer=self.tokenizer,
)
return request_context
async def _summarize(
self,
messages: list[ModelMessage],
ctx: RunContext[AgentDepsT],
*,
previous_summary: str | None = None,
) -> str:
"""Generate a summary for the given messages using the configured model."""
from pydantic_ai import Agent
formatted = _format_messages(messages, skip_previous_summary=previous_summary is not None)
prompt = self.summary_prompt.format(messages=formatted)
if previous_summary is not None:
prompt = (
f'{prompt}\n\n{_INCREMENTAL_UPDATE_INSTRUCTION}\n\n'
f'\n{previous_summary}\n'
)
model = self.model if self.model is not None else ctx.model
agent: Agent[None, str] = Agent(
model,
instructions='You are a context summarization assistant. Extract the most important information from conversations.',
)
result = await agent.run(prompt, usage=ctx.usage)
return result.output.strip()