Skip to main content
Back to integrations
PydanticAI
FrameworkHistory processor

LLMSlim + Pydantic AI

A Pydantic AI history processor that fits the message history into a token budget before every model request. Tool calls and returns stay paired.

llmslim_history_processor(max_tokens=...) returns a Pydantic AI history processor. Before every model request it fits the message history into a token budget: older text is compressed by LLMSlim first, and older tool exchanges and turns are removed only when that is not enough. Tool calls and their returns are always kept or removed together, so the provider never sees an unpaired call or return. It runs locally, with no model call. Python 3.10+.

1. Installation and setup

The extra installs pydantic-ai-slim 1.0 or newer and needs Python 3.10 or newer. llmslim[all] includes it.

terminalbash
pip install "llmslim[pydantic-ai]"   # pydantic-ai-slim 1.0 or newer, Python 3.10+; needs llmslim 0.9.0 or newer

Pydantic AI 2.x (and recent 1.x releases) take the processor in the ProcessHistory capability; earlier 1.x releases take it in history_processors, which recent 1.x releases deprecate and 2.x removed:

agent.pypython
from pydantic_ai import Agent
from pydantic_ai.capabilities import ProcessHistory

from llmslim.integrations.pydantic_ai import llmslim_history_processor

fit = llmslim_history_processor(max_tokens=8_000)
agent = Agent("openai:gpt-5", capabilities=[ProcessHistory(fit)])

# Pydantic AI 1.x without pydantic_ai.capabilities:
agent = Agent("openai:gpt-5", history_processors=[fit])

2. Architecture & execution flow

1

Pass it to an Agent in ProcessHistory(...), or in history_processors on earlier 1.x releases. It runs before each model request.

2

System prompt parts, instructions, tool-availability changes and the most recent user prompt are never changed, and nothing is removed from the last keep_recent messages.

3

Older text is compressed by one shared ratio: user prompts, model text and string tool results. Tool output and model text are untrusted, so imperative wording in them is never force-kept.

4

If that is not enough, older tool exchanges are removed, oldest first (a model response with tool calls together with the request that returns them), then older turns whole, oldest first. The turn with the system prompt is never removed.

5

As a last resort, the text in the last keep_recent messages is compressed, for example a very large tool result that just came back.

6

A message is removed only together with every message that shares a tool call id with it, so the history still alternates request, response, request. Messages that do not change are passed through as the same objects; the input list is not modified.

3. Runnable example and its output

pydantic_ai_agent.pypython
from pydantic_ai import Agent
from pydantic_ai.capabilities import ProcessHistory
from pydantic_ai.messages import ModelResponse, TextPart, ToolCallPart
from pydantic_ai.models.function import AgentInfo, FunctionModel

from llmslim.integrations.pydantic_ai import history_tokens, llmslim_history_processor


def run_tests(suite: str) -> str:
    """Run a test suite and return its output."""
    lines = [f"{suite}/test_api.py::test_case_{i} PASSED" for i in range(300)]
    lines.insert(200, f"{suite}/test_refund.py::test_rounding FAILED - 10.05 != 10.04")
    return "\n".join(lines)


def run(capabilities):
    sent = []  # history tokens in each request the model receives

    def fake_model(messages, info: AgentInfo) -> ModelResponse:
        """Stands in for an LLM: runs three test suites, then answers."""
        sent.append(history_tokens(messages))
        if len(sent) <= 3:
            return ModelResponse(parts=[ToolCallPart("run_tests", {"suite": f"suite{len(sent)}"})])
        return ModelResponse(parts=[TextPart("test_rounding fails in every suite.")])

    agent = Agent(
        FunctionModel(fake_model),
        system_prompt="You are a careful test runner.",
        tools=[run_tests],
        capabilities=capabilities,
    )
    result = agent.run_sync("Run the test suites and tell me what fails.")
    return result, sent


_, plain = run([])
result, slim = run([ProcessHistory(llmslim_history_processor(max_tokens=1_000))])
print(result.output)
print("history tokens per request without LLMSlim:", plain)
print("history tokens per request with LLMSlim:   ", slim)
print()
print(result.all_messages()[2].parts[0].content)

Output (Python 3.12 with pydantic-ai-slim 2.55.0, and Python 3.10 with 1.107.7 passing the processor in history_processors; token counts from tiktoken):

outputtext
test_rounding fails in every suite.
history tokens per request without LLMSlim: [17, 3946, 7875, 11804]
history tokens per request with LLMSlim:    [17, 110, 203, 296]

suite1/test_api.py::test_case_0 PASSED
... [198 similar lines]
suite1/test_api.py::test_case_199 PASSED
suite1/test_refund.py::test_rounding FAILED - 10.05 != 10.04
suite1/test_api.py::test_case_200 PASSED
... [98 similar lines]
suite1/test_api.py::test_case_299 PASSED

An agent runs three test suites, once without LLMSlim and once with it; FunctionModel stands in for the LLM, so the example runs offline. Every tool result stays in the history, paired with its call, and keeps the failure. The history ends far below the 1,000-token budget because runs of near-identical lines collapse into a count; this is a favourable case. Prose is compressed only as far as the budget requires.

4. Options

Keyword arguments, shown as name = default.

max_tokens = required
Token budget for the history.
keep_recent = 2
Most recent messages never removed and compressed only as a last resort (at least 1).
min_tokens = 50
Parts with at most this many tokens are not compressed.
relevance = "lexical"
"lexical", "semantic" (needs llmslim[semantic]) or a callable scorer(query, texts) -> scores.
additional_compress_kwargs = None
Other llmslim.ContextCompressor options, for example mode.

5. Deployment best practices

Pass the processor in ProcessHistory(...) on Pydantic AI 2.x and recent 1.x releases, or in history_processors on earlier 1.x releases. max_tokens counts the text of all message parts with llmslim.count_tokens; the instructions and tool definitions Pydantic AI adds to each request are not part of the history and are not counted. Pydantic AI stores the processed history, so result.all_messages() holds the compressed messages: keep your own copy if you need the originals.

6. Key optimization tips

  • Measure a history with history_tokens(messages); it gives the same count that max_tokens uses.
  • Use fit_messages(messages, max_tokens, ...) for logging or tests: it does the same and also returns the token counts (HistoryFit).
  • Raise keep_recent to protect more of the latest messages: they are never removed and are compressed only as a last resort.
  • Parts with at most min_tokens tokens are not compressed; raise it to leave more short parts untouched.

7. Limitations

  • Needs llmslim 0.9.0 or newer, pydantic-ai-slim 1.0 or newer and Python 3.10 or newer. The 0.9.0 release was tested with pydantic-ai-slim 1.0.0, 1.107.7 and 2.55.0.
  • max_tokens covers the message history only. The instructions and tool definitions Pydantic AI adds to each request are not counted.
  • Pydantic AI stores the processed history, so result.all_messages() holds the compressed messages, not the originals.
  • When compression is not enough, older tool exchanges and turns are removed whole. If the protected parts alone exceed the budget, the processor returns the smallest valid history it can rather than an invalid one.

Frequently asked questions

Troubleshooting

Further reading