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

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.

Almost all of it was a page the agent scraped on turn six.

It will send that page 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’s tool_calls and tool_results to their own columns. When it loads the row back it rebuilds the whole thing: an assistant message carrying the calls, then a ToolResultMessage carrying every result body, verbatim.

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

So a page 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 88 to 92 percent of it.

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

Turns nine and ten called no tool at all. They paid 245,000 tokens each to carry the turn-six page.

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.

You cannot trim inside a run. Say so and move on

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.

There’s an open issue on the SDK, #669, asking for exactly this since 2026-05-28. The one reply proposes swapping out the whole generation loop.

That’s the honest answer. In-run growth has the levers you already have: smaller tool results, MaxSteps, a token ceiling.

Across turns is different.

Across turns you own the 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. Each was run live on both providers, then replayed 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 20 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 36,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’s own loader guards against this with a skipWhile(). Your override gets no such guard.

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 page was larger than the whole budget, so the window kept the assistant’s answer and dropped the page the user was asking about.

A budget smaller than one tool result is not a window. It drops the page 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 pays 180,592 for the page, because the question at turn seven was about the page.

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

Live: 208,127 billed.

Same message count as the baseline, 35 at turn ten. Nothing dropped. Only payloads.

And the answers held. Turn ten cited five 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. #[UseCheapestModel] resolves to Haiku 4.5 on Anthropic and to gpt-5.6-luna on OpenAI.

Same policy, same prompt, run on OpenAI: the summary kept the arXiv URL and the caching numbers, and turn ten cited five URLs.

Your summariser is only as good as the provider’s cheapest model. You don’t get to choose it without passing model: yourself.

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 five distinct URLs.

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. It is also the only one 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 page.

The Sonnet conversation had a 217,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. research:conversation runs the ten turns under any policy. research:replay applies each policy 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.