Skip to main content

Your Laravel Agent Re-Sends Every Tool Result It Ever Saw

Context Hygiene cover: a dark cover with five columns of stacked message blocks labelled Turn 1 to Turn 5, the same tall emerald block repeated identically in turns 2, 3 and 4, and collapsed to a thin emerald line in turn 5, illustrating a tool result re-sent on every turn until it is stubbed

Updated October 12, 2026, for laravel/ai 1.2. This post’s runs used laravel/ai 0.11.2. It said you cannot trim inside a run. Since 1.0 you can, with step middleware, and that section now shows how. I also fixed five smaller lines about the store and the summariser’s model. laravel/ai 1.2 doesn’t move the numbers. A replay on 1.2.0 matches the message counts and tool-result estimates exactly, and no turn moves by more than 41 tokens. A fact-check of the original then found claims that misread its own run files, so I corrected those too. See what changed.

You shipped the chat agent from the Livewire post. RemembersConversations on the class, continue() on every message.

History loads itself.

A month later the per-turn cost has tripled. Nobody touched the prompt.

Here’s what turn ten of a real conversation sent.

The user asked for five bullets. No tool call. Nothing new.

246,221 input tokens.

More than two thirds of it was one search result the agent fetched on turn six.

It will send that result again on turn eleven. And twelve. And every turn after, until the row falls out of a window nobody told you about.

The 100-message default is a row count

RemembersConversations gives you two things. It stores every turn, and it loads history back into the prompt.

The loading half is one method:

public function messages(): iterable
{
    return resolve(ConversationStore::class)
        ->getLatestConversationMessages(
            $this->conversationId,
            $this->maxConversationMessages()
        )->all();
}

protected function maxConversationMessages(): int
{
    return 100;
}

One hundred rows. A row is one user message or one assistant turn.

Its size is unbounded.

And an assistant turn that called tools is not a line of text.

The store writes the turn into one steps column, one entry per model round trip. Each entry keeps every result on the call that made it. When it loads the row back it rebuilds every step. An assistant message carries the calls, then a ToolResultMessage carries every result body, verbatim.

I read assistantTurnFrom() in the store. It keeps everything.

So a search result that came back at 700 KB on turn six is a 700 KB block in every later request.

The docs show messages() as “manual history retrieval” with a ->limit(50). They say nothing about tokens.

Here’s the shape, measured. The research agent from the cost-math post, now remembered. Ten scripted turns on one topic, one tool call a turn. Anthropic, Sonnet 4.6, the SDK’s defaults.

TurnMessages in historyHistory sentof which tool results
2523,29717,296
51731,20122,926
62158,95643,527
725243,033217,089
931244,917217,225
1033246,221217,225

History re-sent across the ten turns: 1,154,093 tokens. $5.63 for one conversation.

From turn two on, tool results are 87 to 98 percent of its characters.

The words the user and the assistant exchanged are under 5 percent.

Turns nine and ten called no tool at all. Each still sent about 245,000 tokens of history, and the turn-six result was more than two thirds of it.

The tools post told you to scope tool output at the source. That fix helped exactly one turn.

Every turn after it paid for the unscoped body again.

Trim inside a run with step middleware, not a listener

You’ll want to fix this where the cost-math post measured it, inside the loop, with a StartingStep listener that prunes messages between steps.

It doesn’t work.

The loop keeps its message array in a local variable and hands the event a copy. Your listener trims the copy. The next step sends the original.

Step middleware does work. Since laravel/ai 1.0, agent middleware wraps every generation step, and PendingStep::withMessages() replaces what that step sends.

The loop still builds each step from the full list. So your middleware trims again on every step, and the run’s own record keeps every result whole.

The trim is the same idea as lever two below: keep the call, drop the payload. Every result the model has already read becomes a one-line stub. The newest one goes whole, because the model hasn’t read it yet.

use Closure;
use Laravel\Ai\Gateway\StepResult;
use Laravel\Ai\Messages\ToolResultMessage;
use Laravel\Ai\PendingStep;
use Laravel\Ai\Responses\Data\ToolResult;

final class StubSeenToolResults
{
    public function handle(PendingStep $step, Closure $next): StepResult
    {
        $messages = $step->messages;
        $newest = array_key_last(array_filter($messages, fn ($message) => $message instanceof ToolResultMessage));

        foreach ($messages as $i => $message) {
            if ($message instanceof ToolResultMessage && $i !== $newest) {
                $messages[$i] = new ToolResultMessage(
                    $message->toolResults->map(fn (ToolResult $result) => new ToolResult(
                        id: $result->id,
                        name: $result->name,
                        arguments: $result->arguments,
                        result: "[cleared from context] {$result->name} ran at an earlier step. Call it again if you need the details.",
                        resultId: $result->resultId,
                        denied: $result->denied,
                        failed: $result->failed,
                    ))
                );
            }
        }

        return $next($step->withMessages($messages));
    }
}

Return it from middleware() on an agent that implements HasMiddleware.

The companion’s test runs it through the Anthropic gateway on 1.2, with HTTP faked. Step three sends the first page as a stub and the second page whole. Both tool_use blocks survive. Take the middleware out and the same test fails.

The cost: if the model needs an earlier page again, it spends a step calling the tool again.

Don’t drop the cross-turn policies in here. TokenWindow returns zero messages for a step whose newest result and its call don’t fit the budget together. StubStaleToolResults counts turns by user messages, and no user message falls between steps, so it stubs nothing inside the run.

The SDK still ships no compaction of its own. #669 has asked for it since 2026-05-28, and it’s still open.

Across turns you need a different hook.

RemembersConversations remembers. It does not manage.

If you read the 100-message default as a context budget, you have a scrape from three weeks ago in every request you send today.

The hook is messages(), and it replaces

The provider assembles the prompt as instructions, then whatever messages() returns, then the new user message. So messages() is the whole cross-turn seam.

It’s a full replacement, not a filter.

Define it on your agent class and it shadows the trait’s version. That’s the mechanism and the trap in one.

An override that forgets to load from the store sends no history at all. Silently.

use App\Ai\History\HistoryPolicy;
use App\Ai\History\KeepEverything;
use Laravel\Ai\Concerns\RemembersConversations;
use Laravel\Ai\Contracts\Conversational;
use Laravel\Ai\Contracts\ConversationStore;

class ConversationalResearchAgent extends ResearchAgent implements Conversational
{
    use RemembersConversations;

    public function __construct(
        public HistoryPolicy $history = new KeepEverything,
        string $caching = 'default',
    ) {
        parent::__construct($caching);
    }

    public function messages(): iterable
    {
        if (! $this->conversationId) {
            return [];
        }

        $loaded = resolve(ConversationStore::class)
            ->getLatestConversationMessages($this->conversationId, $this->maxConversationMessages())
            ->all();

        return $this->history->apply($loaded, $this->conversationId);
    }
}

HistoryPolicy is one method, apply(array $messages, string $conversationId): array. Oldest first in, what the model should see out.

Three policies follow. Each one is a commit on the companion repo. I ran each one live on both providers, and replayed the window and the stub offline against the same stored conversation. The replay holds the pages fixed and varies only the policy.

Lever one: a token window, and the cliff

Walk the history newest first. Add up an estimate. Stop at a budget.

use Laravel\Ai\Messages\ToolResultMessage;

final class TokenWindow implements HistoryPolicy
{
    public function __construct(
        public int $budgetTokens = 8000,
        public bool $dropLeadingResults = true,
    ) {}

    public function apply(array $messages, string $conversationId): array
    {
        $kept = [];
        $spent = 0;

        for ($i = count($messages) - 1; $i >= 0; $i--) {
            $cost = Tokens::estimate($messages[$i]);

            if ($kept !== [] && $spent + $cost > $this->budgetTokens) {
                break;
            }

            $spent += $cost;
            array_unshift($kept, $messages[$i]);
        }

        if ($this->dropLeadingResults) {
            while ($kept !== [] && $kept[0] instanceof ToolResultMessage) {
                array_shift($kept);
            }
        }

        return $kept;
    }
}

Tokens::estimate() is chars divided by four on the JSON-encoded message. There’s no token counter in the SDK.

On this content Sonnet billed 9 to 23 percent more than the estimate. GPT-5.6 landed between 8 percent under and 18 percent over. So a 30,000 budget means roughly 33,000 to 37,000 billed on Anthropic. Measure yours on turn two of any real run.

The last loop is not optional. The cut can land between an assistant message that made tool calls and the ToolResultMessage that answered them. The result is newer, so a naive slice keeps it and drops the call.

I forced that on purpose:

messages.0.content.0: unexpected `tool_use_id` found in `tool_result` blocks:
toolu_016vfCLpVUGwE1QdHHDr79RQ. Each `tool_result` block must have a
corresponding `tool_use` block in the previous message.

OpenAI says the same thing in different words: No tool call found for function call output with call_id ….

The store can’t split a pair. It limits rows, and each row rebuilds its calls together with their results. Your policy cuts messages, so it can.

Replayed against the stored Sonnet conversation with a 30,000 budget, the window cut the ten-turn history from 1,041,056 estimated tokens to 137,134.

Live, the billed figure was 195,467 against the baseline’s 1,154,093.

And turn seven sent 886 tokens.

That’s the cliff. The turn-six result was larger than the whole budget, so the window kept the assistant’s answer and dropped the result the user was asking about.

A budget smaller than one tool result is not a window. It drops the result at the exact moment the user is asking about it.

Lever two: keep the call, drop the payload

Anthropic’s context-engineering guide calls tool-result clearing the safest form of compaction, and their API does it for you.

The SDK re-sends the stored payload verbatim. So you do it in the policy.

Keep every message. Keep the call’s id, name and arguments, so the pairing with the provider’s tool_use block survives and the model still knows the tool ran.

Replace the body of any result older than the last N turns with one line.

use Laravel\Ai\Messages\Message;
use Laravel\Ai\Messages\MessageRole;
use Laravel\Ai\Messages\ToolResultMessage;
use Laravel\Ai\Responses\Data\ToolResult;

final class StubStaleToolResults implements HistoryPolicy
{
    public function __construct(public int $keepRecentTurns = 1) {}

    public function apply(array $messages, string $conversationId): array
    {
        $turnsBack = 0;
        $out = $messages;

        for ($i = count($messages) - 1; $i >= 0; $i--) {
            $message = $messages[$i];

            if ($message->role === MessageRole::User && ! $message instanceof ToolResultMessage) {
                $turnsBack++;

                continue;
            }

            if ($turnsBack >= $this->keepRecentTurns && $message instanceof ToolResultMessage) {
                $out[$i] = new ToolResultMessage(
                    $message->toolResults->map(fn (ToolResult $result) => $this->stub($result))
                );
            }
        }

        return $out;
    }

    private function stub(ToolResult $result): ToolResult
    {
        return new ToolResult(
            id: $result->id,
            name: $result->name,
            arguments: $result->arguments,
            result: sprintf(
                '[cleared from context] %s(%s) ran earlier in this conversation. Call it again if you need the details.',
                $result->name,
                json_encode($result->arguments, JSON_UNESCAPED_SLASHES),
            ),
            resultId: $result->resultId,
        );
    }
}

A turn boundary is a user message in the stored history. The current question is appended after the policy runs, so keepRecentTurns = 1 keeps the previous turn’s results whole and stubs everything older.

The turn after a scrape still has the page. The turn after that has a one-line receipt.

Replay, same conversation: 274,274 estimated tokens. Turn seven still carries 180,592 estimated tokens, almost all of it that result, because the question at turn seven was about it.

Turn eight pays 8,993. Every turn after stays under 11,000.

Live: 208,127 billed.

Replayed, it keeps every message at turn ten, the same count as keeping everything, and stubs only the payloads.

And the answers held. Turn ten cited four distinct URLs and quoted the numbers from a paper scraped at turn five.

The numbers survive because the assistant’s own turn-five answer is still there verbatim. The URLs survive because the stub keeps the arguments, and the URL was an argument.

Lever three: summarise at a checkpoint, and check what survived

The SDK ships a summariser.

Laravel\Ai\Agents\SummarizeAgent, cheapest model, “no more than N sentences”, also reachable as Str::of($text)->summarize(sentences: 5).

So the third policy folds the overflowing part of the history into one summary and stores it on the conversation. From then on it sends the summary plus the recent tail.

It re-summarises only when the tail overflows again. Then it extends the stored summary instead of starting over.

Summarising on every turn pays for the prefix twice. Once to summarise it, again next turn when it’s sent. A checkpoint pays once.

The policy is forty lines around one SummarizeAgent call and one updateOrCreate.

Live on Sonnet: 82,560 billed tokens of history across ten turns, plus three summariser calls totalling 10,481 in and 915 out.

Less than half of either other lever.

Then I read the summary.

After the third fold it was a markdown page about model routing, the last thing folded in. No URL. No mention of the paper from turns four and five. None of the caching evidence.

Turn ten was asked for five bullets each citing a scraped URL. It cited zero.

Two causes, both in the run file. SummarizeAgent(5) returned thirteen sentences with headers, so the sentence limit is advisory on structured input. And the fold prompt said “summary so far, plus transcript”, and the cheapest model rewrote rather than extended.

The cheapest model is the point. On laravel/ai 0.11.2, the version behind these runs, #[UseCheapestModel] resolved to claude-haiku-4-5-20251001 on Anthropic and to gpt-5.6-luna on OpenAI.

On 1.2 it resolves to claude-haiku-5-5 and gpt-6-luna. I haven’t re-run the summariser on either.

Same policy, same prompt, run on OpenAI: the summary kept the arXiv URL and the caching numbers. Turn ten cited a URL in every bullet, two distinct.

Your summariser is only as good as the provider’s cheapest model. To pick a different one, pass model: or set models.text.cheapest under the provider in config/ai.php.

The fix is ten lines.

Same shape as the SDK’s agent, different instructions:

use Laravel\Ai\Attributes\UseCheapestModel;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Promptable;

#[UseCheapestModel]
final class ConversationSummarizer implements Agent
{
    use Promptable;

    public function instructions(): string
    {
        return <<<'TXT'
            You maintain a running summary of a conversation between a user and a research assistant that uses tools.
            You receive the summary so far (possibly empty) and a transcript of newer messages. Return the updated summary and nothing else.
            Keep everything already in the summary so far unless the transcript contradicts it. Extend, do not rewrite.
            Keep every URL, number, percentage, paper id, product name and decision that appears in either input, verbatim.
            Record which tools were called and with what arguments, one line each.
            Plain prose, no headings, at most 300 words.
            TXT;
    }
}

Rerun on Sonnet with that summariser: the stored summary names arXiv:2601.06007v2 and the caching results in 129 words. Turn ten cites four distinct URLs, one of them twice.

It does not repeat the paper’s 89 percent figure. The fold transcript cuts each tool result to 1,500 characters, so the number never reached the summariser. The instruction keeps what it is shown.

Summary is the lever for cost. Along with the window, it is one of two levers that can lose something you can’t get back.

The stock summariser did.

Which lever, decided by one question

Replay both stored baselines through window and stub, 30,000 budget, keep one turn:

Ten-turn history, estimated tokensSonnet conversationGPT-5.6 conversation
Keep everything (SDK default)1,041,056325,496
Token window137,134158,067
Stub stale results274,274113,639

Window wins on the Sonnet conversation. Stub wins on the GPT one.

Same code, same budget.

The difference is one result.

The Sonnet conversation had a 174,000-token result. The window dropped it entirely, the stub paid for it once. The GPT conversation had no result over the budget, so the window kept paying for the last one or two results in full every turn. The stub paid for each exactly once.

So: is any single tool result bigger than your budget?

If yes, the window is cheaper, and it forgets that result on the very next turn. If no, the stub is cheaper, and it forgets nothing the model can’t re-fetch.

Start with the stub. Its cost is bounded by “each result once”.

Put a window in front of it as a ceiling, sized above your largest tool result. When that ceiling fires, a tool is returning too much.

Reach for the checkpoint summary when the conversation has to outlive the budget. With a summariser you wrote, and a run file you actually read.

Rates in this post are Anthropic’s and OpenAI’s published cards on 2026-09-12. Every number is from a recorded run in the companion repo, laravel-ai-research-agent. The runs used laravel/ai 0.11.2, and a 1.2.0 replay moves no turn’s estimate by more than 41 tokens. research:conversation runs the ten turns under any policy. research:replay applies the window and the stub to a stored conversation without spending a token.

What you control now

You started with a trait that remembers everything and a cap that counts rows.

Now you can see what every turn of a remembered conversation sends, and how much of it is tool results the model already used. You can put a token ceiling on any RemembersConversations agent in one method. You can decide what a tool result is worth on turn fifty, and keep the receipt without the payload.

And you can compact a long conversation without losing the URL it was built on. Because you checked.

Count tokens, not rows. Keep the call, drop the body. Read the summary before you trust it.

What changed

October 12, 2026, for laravel/ai 1.2:

  • The post’s runs used laravel/ai 0.11.2. It said a StartingStep listener can’t trim inside a run, and that your only levers were smaller results, MaxSteps and a token ceiling. The listener half still holds. Since 1.0, agent middleware wraps every step, and PendingStep::withMessages() replaces what the step sends. The section now shows a middleware that stubs the results the model has already read. A faked three-step Anthropic run tests it on 1.2.0.
  • 1.0 replaced the tool_calls and tool_results columns with one steps column (upgrade guide). The loader is now assistantTurnFrom(), not reconstructToolTurn(). It still rebuilds every result verbatim, so the post’s main claim stands.
  • The loader’s skipWhile() guard is gone, and it isn’t needed. The store limits rows, and each row rebuilds its calls with their results. Your policy can still orphan a result, so TokenWindow keeps its last loop.
  • The summariser section named the cheapest models in the present tense. Those were 0.11.2’s. OpenAI’s moved to gpt-6-luna by 1.0.0, and Anthropic’s to claude-haiku-5-5 in 1.2.0 (#1107). The Haiku 4.5 result stands as measured. I haven’t repeated it on Haiku 5.5.
  • It also said only model: picks the summariser’s model. Both 0.11.2 and 1.2 read models.text.cheapest from the provider’s entry in config/ai.php first. That line was wrong on the day it shipped.
  • Both stored conversations went back through research:replay on 1.2.0. Message counts and tool-result estimates match the 0.11.2 replay exactly. The ten-turn totals move by under 0.2 percent, and no single turn by more than 41 tokens. The tables keep the numbers as published.

October 12, 2026, corrections from a fact-check of the original against its own run files:

  • The big turn-six result came from a Firecrawl search, not a scrape. It was more than two thirds of turn ten’s history, not almost all of it.
  • From turn two on, tool results were 87 to 98 percent of the history’s characters, not 88 to 92.
  • Sonnet billed 9 to 23 percent over the estimate, not 9 to 20. So a 30,000 budget bills about 33,000 to 37,000, not 36,000.
  • The stub’s “same message count as the baseline, 35” compared two different live runs, which sent 33 and 35. The replay keeps every stored message at turn ten, the same as keeping everything.
  • Three “five URLs” counted citations, not URLs. At turn ten the stub run and the preserving-summariser rerun each cited four distinct URLs, and the OpenAI summary run cited two.
  • The Sonnet conversation’s largest result was about 174,000 tokens, not 217,000. The 217,000 was every tool result in turn seven’s history.
  • The summary policy ran live only. research:replay covers the window and the stub, and the post said it covered all three.
  • Summary isn’t the only lever that can lose something for good. The window drops whole turns, the assistant’s answers included.