Pydantic AI gives you a clean, typed agent: define an Agent, hand it tools, call agent.run(...), and it loops — model call, tool call, model call — until it returns a validated result. The typed ergonomics are great. What the quickstart doesn't spell out is what the model receives on each pass of that loop.
I read the run graph (pydantic_ai_slim/pydantic_ai/_agent_graph.py on main) to find out.
One list, appended twice per turn#
Each run holds a single mutable conversation list on its state:
message_history: list[_messages.ModelMessage] = dataclasses.field(...)
On every model step the graph appends to it — first the outgoing request, then the response:
ctx.state.message_history.append(self.request)
ctx.state.message_history.append(response)
Nothing is removed. The list only grows: request, response, request, response — with tool calls and, crucially, tool outputs riding inside those messages.
The full list is re-sent every step#
When the graph builds the input for the next model call, it takes the entire accumulated history:
messages = ctx.state.message_history[:]
So step 1 sends your prompt; step 2 sends prompt + step 1's request + response; step 5 sends all of that plus steps 2–4. The payload you pay for grows every step, and the heaviest passengers are usually the tool outputs — the search results and file contents you least want re-uploaded five times.
A run of n steps sends roughly 1 + 2 + … + n copies of history — O(n²) cumulative tokens in the step count. The run still succeeds, your tests still pass; the only artifact is a bigger number on the invoice.
The knob you actually have#
Pydantic AI hands the full transcript back (result.all_messages()) and every run method takes a message_history parameter — "History of the conversation so far." That's the lever: across a multi-turn conversation you decide what prior history to replay, so you can pass a trimmed or summarized history into the next run. Inside a single deep tool-loop the re-send is inherent to tool-calling (true of every framework) — which is exactly why the move is to measure it, not assume it's free.
Measure it before you argue about it#
Put a number on it — the token cost of a run, priced in dollars. That's what @wartzar-bee/tokenscope does (npm i @wartzar-bee/tokenscope): it prices each bucket — input, output, cache-write (~1.25×), cache-read (~0.1×) — into an actual per-run dollar figure.
And if it runs in CI, gate it: wartzar-bee/ci-guardrail is an Apache-2.0 GitHub Action (built on tokenscope) that fails the check when a run crosses an absolute max-usd ceiling — so the quadratic step doesn't reach production as a silent 4×.
Full write-up with the code excerpts: https://dev.to/wartzarbee/pydantic-ai-keeps-one-growing-message-list-per-run-and-re-sends-the-whole-thing-every-step-4o7b
Comments
No comments yet. Start the conversation.