Back to Articles

Essay

Stop Re-Reading Huge Tool Outputs: Breadcrumbs Over Blobs

Your agent just called searchDocs() and got back 48KB of JSON. On the next turn it re-reads all of it — or worse, silently drops the detail it needs. Store signatures and pointers instead of blobs.

July 18, 20267 min readagentsmemory
Stop Re-Reading Huge Tool Outputs: Breadcrumbs Over Blobs

Your agent calls searchDocs({ query: "refund policy" }).

The tool returns twelve pages of markdown — forty-eight kilobytes, easy. The model reads it, answers the user, everyone nods. Then the user asks a follow-up: "What about partial refunds on sale items?"

The agent still has the first answer in thread history. What it does not still have is room to think. That forty-eight kilobyte tool result is sitting in the message list, getting re-sent on every subsequent turn, crowding out everything else you actually need in context.

This is one of the quietest ways agent quality falls off a cliff. Nothing errors. Nothing crashes. The agent just gets dumber three turns later because you stored a blob where you should have stored a breadcrumb.

You'll learn

  • Why full tool payloads in thread history are a footgun
  • The breadcrumb shape: call signature + pointer + short summary
  • Where to trim (tool boundary, recall path, compaction)
  • A minimal TypeScript pattern you can drop into any agent stack

Blobs: the default that doesn't scale

Most first agent implementations do the obvious thing: append the tool's raw return value to the conversation as a tool_result message. It works in a demo. It stops working the moment the tool can return anything larger than a paragraph.

Blob in thread
what most demos do
tool_result: searchDocs()
{ "pages": [ … 48KB … ] }
Re-sent every turn
~12,000 tokens before the user even speaks again
Failure modes
OOM · rate limits · stale verbatim text · lost nuance after compaction
Breadcrumb in thread
what production needs
tool_result: searchDocs()
call + pointer + 2-sentence summary
Re-sent every turn
~45 tokens — room left for actual reasoning
Full payload
Fetched on demand via pointer, not replayed from history
VS
Same tool call, two storage strategies. The blob strategy looks fine until turn four.

Three failure modes show up over and over:

  1. Context crowding. The blob eats the window. System prompt, earlier user intent, and recent turns get truncated first — exactly the stuff that keeps an agent coherent.

  2. Stale verbatim replay. The model re-reads old tool output as if it's still authoritative, even when the underlying data changed two turns ago.

  3. Compaction surprises. When you finally summarize or trim history, the blob is the first thing that gets mangled — and it's usually the thing that held the precise detail the user is now asking about.

The fix isn't "never return big results from tools." Tools should be able to return big results. The fix is don't keep the big result in the conversation permanently.

A breadcrumb is a deliberately small record of a tool call that preserves enough for the model to reason about what happened — and a pointer to fetch more when it needs to.

Call signaturename + args (reproducible)
Short summarywhat the tool found, in ~1–3 sentences
Pointerdoc-store/id, s3://…, cache key
Full payloadoutside the thread — fetch on demand
Three layers in a breadcrumb. The thread keeps the top two; the store holds the blob.

Concretely, instead of this in thread history:

{
  "role": "tool",
  "tool_call_id": "call_abc",
  "content": "{ \"pages\": [ /* 48KB */ ] }"
}

…store something like this:

{
  "role": "tool",
  "tool_call_id": "call_abc",
  "content": "searchDocs({ query: \"refund policy\" })\nfound: 3 pages (policy.md, faq.md, exceptions.md)\nstored: doc-store/7f3a9c\nsummary: Returns within 30 days; sale items prorated; digital goods excluded unless required by law.\n(full text available via fetchDoc(id))"
}

The model can answer most follow-ups from the summary alone. When the user asks something the summary doesn't cover, the agent calls a recall toolfetchDoc("7f3a9c") or fetchDocSection(id, heading) — instead of hoping the blob survived compaction.

Side storeToolThreadwrite blobwrite breadcrumbfetchDoc()read blob
Write path: trim at the tool boundary. Read path: fetch only when the model asks.

A minimal pattern in TypeScript

The shape is always the same: tool writes big, thread stays small, recall tool reads big.

tools/search-docs.ts
type StoredDoc = { id: string; title: string; body: string };
 
const docStore = new Map<string, StoredDoc>();
 
export async function searchDocs({ query }: { query: string }) {
  const pages = await searchBackend(query); // however you search — vector, grep, API
 
  const ids = pages.map((p) => {
    const id = crypto.randomUUID().slice(0, 8);
    docStore.set(id, p);
    return { id, title: p.title };
  });
 
  const summary = await summarizeForThread(pages); // short — even a cheap model works
 
  return {
    breadcrumb: [
      `searchDocs({ query: ${JSON.stringify(query)} })`,
      `found: ${ids.map((x) => x.title).join(", ")}`,
      `stored: ${ids.map((x) => x.id).join(", ")}`,
      `summary: ${summary}`,
      `(use fetchDoc(id) for full text)`,
    ].join("\n"),
    // optional: keep ids for your own telemetry
    ids: ids.map((x) => x.id),
  };
}
 
export function fetchDoc({ id }: { id: string }) {
  const doc = docStore.get(id);
  if (!doc) return `No document stored with id ${id}.`;
  return `# ${doc.title}\n\n${doc.body}`;
}

Wire the agent so the tool handler stores the blob and only the breadcrumb string goes into the message list:

agent/run-turn.ts
const result = await searchDocs(args);
 
messages.push({
  role: "tool",
  tool_call_id: call.id,
  content: result.breadcrumb, // not JSON.stringify(pages)
});

In production, docStore is not an in-memory Map. It's Redis, Postgres, S3, or whatever you already use for session state — keyed by (threadId, ref). The pattern is the same; only the store changes.

Where to trim (the rule of one boundary)

Pick one place where large payloads get replaced by breadcrumbs. If you trim in three places inconsistently, you'll debug ghosts for a week.

BoundaryTrim here when…
Tool returnYou control the tool — best default
Before model callThird-party tools return huge JSON you can't change
Compaction jobYou already have a summarization pass — replace blobs with summaries + pointers retroactively

I trim at the tool return boundary whenever I can. It's the easiest place to guarantee the thread never sees the blob in the first place.

When breadcrumbs aren't enough

Breadcrumbs assume the model can reason from a short summary most of the time and reach for a recall tool when it can't. That breaks down when:

  • The task requires line-level fidelity across many documents in one turn (large code refactors, legal clause comparison). Consider a subagent with its own window instead of stuffing everything into the parent thread.
  • The summary step lies — if your summarizer drops negations or exceptions, the agent will confidently wrong-answer. Gate summaries on structured fields when precision matters (dates, amounts, IDs).

For the parent-thread case, breadcrumbs plus a recall tool cover the majority of support, research, and ops agents I've shipped.

Where to go next

This pattern is one slice of a broader memory stack — breadcrumbs for tool I/O, rolling windows for chat, optional summarization above that. If you're building on Mastra, Part 5: RAG shows the retrieval pipeline end-to-end; breadcrumbs are what keep that pipeline from flooding the thread after the first query.

The flagship follow-up — Build a Harness Agent From Scratch — covers the session layer that makes recall, persistence, and approvals hang together. Breadcrumbs keep each turn small; the harness keeps the conversation alive across them.