mirror of
https://github.com/pydantic/pydantic-ai-harness.git
synced 2026-07-21 02:45:34 +00:00
* refactor: graduate capabilities out of `experimental` (ACP excepted) The `experimental` label suppressed the try->fail->report->improve loop we want from real usage, so drop it from the harness. Ten capabilities move to top-level submodules; ACP stays experimental (it may still be reshaped or moved to core, and can't be generic across agents). - Old `pydantic_ai_harness.experimental.<name>` paths keep working as DeprecationWarning shims (via `warn_moved`), so existing imports don't break. - Renames to match capability names: authoring -> runtime_authoring, overflow -> overflowing_tool_output. - Capabilities stay in individual submodules with no top-level re-export, so importing the root package never pulls in a capability's optional deps. - Docs drop the experimental framing and the "may be removed in any release" language in favor of "API subject to change". * docs: tell capability authors to ask the user when unsure about a name Per PR review: a name is a public commitment once shipped, so ask rather than guess.
295 lines
10 KiB
Python
295 lines
10 KiB
Python
"""`SummarizingCompaction` -- LLM-powered summarization of older messages."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Callable, Sequence
|
|
from dataclasses import dataclass
|
|
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,
|
|
SystemPromptPart,
|
|
TextContent,
|
|
TextPart,
|
|
ToolCallPart,
|
|
ToolReturnPart,
|
|
UserPromptPart,
|
|
)
|
|
from pydantic_ai.tools import RunContext
|
|
|
|
from pydantic_ai_harness.compaction._shared import (
|
|
compact_with_span,
|
|
exceeds,
|
|
find_first_user_message,
|
|
find_safe_cutoff,
|
|
find_token_cutoff,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
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>
|
|
{messages}
|
|
</messages>\
|
|
"""
|
|
|
|
_SUMMARY_PREFIX = 'Summary of previous conversation:\n\n'
|
|
|
|
|
|
def _format_messages(messages: Sequence[ModelMessage]) -> str:
|
|
"""Render messages into a human-readable string for summarization."""
|
|
lines: list[str] = []
|
|
for msg in messages:
|
|
if isinstance(msg, ModelRequest):
|
|
for part in msg.parts:
|
|
if isinstance(part, UserPromptPart):
|
|
lines.append(f'User: {_user_prompt_text(part)}')
|
|
elif isinstance(part, SystemPromptPart):
|
|
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):
|
|
parts.append(part)
|
|
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 messages:
|
|
if not isinstance(msg, ModelRequest):
|
|
continue
|
|
for part in msg.parts:
|
|
if isinstance(part, SystemPromptPart) and part.content.startswith(_SUMMARY_PREFIX):
|
|
return part.content[len(_SUMMARY_PREFIX) :]
|
|
return None
|
|
|
|
|
|
@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 running agent's model."""
|
|
|
|
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."""
|
|
|
|
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``, include any existing summary from a prior compaction in the
|
|
summarization prompt so that it is extended rather than regenerated from scratch.
|
|
"""
|
|
|
|
def __post_init__(self) -> None:
|
|
if self.max_messages is None and self.max_tokens is None:
|
|
raise ValueError('At least one of max_messages or max_tokens must be set.')
|
|
if self.max_messages is not None and self.max_messages < 1:
|
|
raise ValueError('max_messages must be positive.')
|
|
if self.max_tokens is not None and self.max_tokens < 1:
|
|
raise ValueError('max_tokens must be positive.')
|
|
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.')
|
|
|
|
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_part = SystemPromptPart(content=f'{_SUMMARY_PREFIX}{summary}')
|
|
summary_message = ModelRequest(parts=[*system_parts, summary_part])
|
|
|
|
first_user: list[ModelMessage] = []
|
|
if 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:
|
|
first_user = [first_user_msg]
|
|
|
|
return [summary_message, *first_user, *preserved]
|
|
|
|
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)
|
|
if not exceeds(messages, self.max_messages, self.max_tokens, self.tokenizer):
|
|
return request_context
|
|
request_context.messages = await compact_with_span(
|
|
ctx,
|
|
strategy='SummarizingCompaction',
|
|
messages=messages,
|
|
compact=lambda: self.compact(messages, 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)
|
|
prompt = self.summary_prompt.format(messages=formatted)
|
|
|
|
if previous_summary is not None:
|
|
prompt = f'{prompt}\n\n<previous_summary>\n{previous_summary}\n</previous_summary>'
|
|
|
|
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()
|