AI Engineering
tutorial
Featured

Your Subagents Are Hiding the Evidence

Spawning a subagent to keep the parent's context clean is the right instinct, and it is also where a lot of long-running agents quietly go wrong. The subagent does the work, returns a tidy paragraph, and the parent acts on it. But that paragraph is a lossy compression boundary nobody designed, and when it drops the one fact that mattered, the evidence is already gone. The fix is not a smarter summary. It is a return contract.

Viral Ruparel
10 min read
Share:

You started spawning subagents for the right reason. A long agent run was filling its own context window with tool output it would never look at again, forty files it grepped to find the two that mattered, three pages of test logs to confirm one line passed. The quality dropped as the window filled, so you moved the messy work into subagents. Each one gets a fresh context, does its narrow job, and hands back a clean paragraph. The parent stays small and focused. It worked, and the runs got noticeably better.

Then one of them went wrong in a way you could not explain. The parent decided the migration was safe and proceeded. It was not safe. When you went to trace why, the parent's transcript said only "the schema check subagent reported no blocking issues." The subagent that actually ran the check was gone, its context closed the moment it returned, and with it went the one warning it had seen and decided not to mention. The parent did nothing wrong. It acted faithfully on the summary it was given. The summary was the problem, and the summary was a design decision nobody made on purpose.

The boundary you did not know you drew

Subagent isolation is a real fix. The mechanics are simple and they are genuinely good. The parent calls a tool that spawns a subagent, the subagent runs in its own window with its own tool calls, and only its final message comes back. From the parent's side, calling a subagent looks exactly like calling read_file. All the intermediate noise stays private, so the parent's context grows by one paragraph instead of one thousand tokens of scratch work. This is the cleanest answer we have to context rot on long runs, and if you have not adopted it yet, you should.

But look at what that final paragraph actually is. The subagent saw everything. The parent sees a lossy compression of everything, produced by a language model that was asked, implicitly, to decide what mattered. There is no schema on that output, so the parent cannot check whether a field is missing. There is no provenance, so the parent cannot tell which file or row a claim came from. And the moment the subagent returns, its context is discarded, so the evidence behind the claim is unrecoverable. You have drawn a hard boundary through the middle of your system where information is destroyed, and you drew it in free text.

This is the same failure I described in why stale tool observations quietly poison an agent's context, except inverted. There the danger was keeping too much. Here the danger is throwing away too much, at a boundary where you cannot see it happen. Both come from treating context as something that just accumulates or evaporates on its own, rather than something you govern deliberately.

Make the return typed, not prose

The first move is to stop letting the subagent hand back a paragraph and start making it fill out a form. A typed return contract turns "tell me what you found" into "populate these fields," and the difference is that the parent can now inspect the result, and you can now tell when something is absent.

# contract.py -- the shape every subagent must return. The parent depends on
# this shape, not on the subagent's prose. Missing fields are now visible
# instead of silently absent, and confidence and caveats are first-class
# rather than something the model might mention if it feels like it.
from dataclasses import dataclass, field
from enum import Enum

class Verdict(str, Enum):
    PASS = "pass"
    FAIL = "fail"
    UNSURE = "unsure"

@dataclass
class SubagentResult:
    verdict: Verdict                 # the decision, not a vibe
    summary: str                     # one or two sentences, for the human
    caveats: list[str] = field(default_factory=list)   # what did NOT get checked
    evidence: list["EvidenceRef"] = field(default_factory=list)  # pointers, below
    confidence: float = 0.0          # 0..1, forces the model to commit

    def is_actionable(self) -> bool:
        # The parent refuses to act on a low-confidence pass without review.
        return self.verdict is Verdict.PASS and self.confidence >= 0.7

The caveats field is doing quiet, important work. A free-text summary answers the question "what did you find," and a model answering that question tends to report what it found, not what it failed to check. By making "what did not get checked" a named field, you force the omission into the open. The schema check subagent from the opening can no longer stay silent about the warning. It has a field whose entire job is to hold it.

Return handles, not payloads

The second move solves the evidence problem. You want the parent to stay small, which is the whole reason you spawned a subagent, but you also want the evidence to survive so a decision can be audited or a doubt can be resolved. Those pull in opposite directions only if the parent has to hold the evidence in its context. It does not. The subagent writes evidence to a shared store and returns lightweight references, and the parent holds pointers instead of payloads.

# evidence.py -- the subagent parks raw findings in a store keyed by id and
# hands back references. The parent's context grows by a label, not a payload,
# and the actual record is fetchable on demand for as long as the run lives.
import uuid
from dataclasses import dataclass

@dataclass(frozen=True)
class EvidenceRef:
    id: str
    label: str          # one line the parent can read without a fetch

class EvidenceStore:
    def __init__(self):
        self._records: dict[str, str] = {}

    def put(self, label: str, body: str) -> EvidenceRef:
        ref_id = uuid.uuid4().hex[:12]
        self._records[ref_id] = body
        return EvidenceRef(id=ref_id, label=label)

    def get(self, ref_id: str) -> str:
        # The parent calls this only when a decision actually hinges on detail,
        # so the expensive text enters its context at most once, and only if needed.
        return self._records[ref_id]

This is the same idea as offloading tool output to a filesystem the agent reads on demand, applied to the subagent boundary. The subagent's raw work does not vanish and it does not flood the parent. It sits in a store, addressable, and the parent pulls the one record it needs when a decision turns on it. The audit trail outlives the subagent's context because it was never inside the subagent's context to begin with.

Now the subagent's job looks less like writing a report and more like filing structured findings against a contract.

# schema_check.py -- a subagent that runs its messy work privately, parks the
# raw output as evidence, and returns a typed result. The parent will never see
# the two hundred lines of diff; it sees a verdict, a caveat, and a handle it
# can open if the verdict is close.
def run_schema_check(migration_sql: str, store: EvidenceStore) -> SubagentResult:
    report = analyze_migration(migration_sql)   # heavy, noisy, private to here

    evidence = [store.put(f"finding: {f.title}", f.detail) for f in report.findings]
    blocking = [f for f in report.findings if f.severity == "blocking"]
    warnings = [f.title for f in report.findings if f.severity == "warning"]

    return SubagentResult(
        verdict=Verdict.FAIL if blocking else Verdict.PASS,
        summary=(f"{len(blocking)} blocking, {len(warnings)} warnings on "
                 f"{report.table_count} tables."),
        caveats=warnings,               # the warning can no longer hide
        evidence=evidence,              # every finding is fetchable
        confidence=0.9 if report.covered_all_tables else 0.5,
    )

Run the opening scenario through this and it comes out differently. The warning lands in caveats, so the parent sees it. The finding sits in the evidence store, so when the parent's decision looks marginal it opens the exact record rather than trusting a paragraph. And the covered_all_tables check pushes confidence down when coverage was partial, so a pass that was really a "pass, mostly" no longer reads as a clean bill of health.

Know when not to spawn at all

The third move is knowing that spawning is not free and refusing to do it reflexively. Every subagent call pays a summarization tax. The parent describes the task, the subagent does the work and compresses it, and often the parent has to decompress by asking a follow-up that the compression threw away. When the task is small, that round trip costs more than it saves, and worse, it introduces a lossy boundary for no benefit. A single tool call does not need its own agent.

# spawn_policy.py -- decide whether isolation is worth its cost. The heuristic
# is simple: spawn when the intermediate noise is large relative to the
# conclusion. Cheap, bounded work stays inline where the parent can see it.
def should_spawn(task) -> bool:
    if task.estimated_tool_calls <= 1:
        return False                      # nothing to isolate
    if task.expected_output_tokens < 400:
        return False                      # the summary tax outweighs the noise saved
    # Spawn when the work is noisy AND its result is small enough to summarize
    # without losing the point. A big, noisy task with a big, irreducible result
    # is a sign the task is under-decomposed, not a candidate for a subagent.
    return task.estimated_intermediate_tokens > 2000

The signal to watch for is a subagent whose result is nearly as large as its work. That is not a candidate for isolation, it is a task that has not been decomposed enough, and forcing it through a summary boundary just guarantees loss. Isolation earns its cost when a task generates a mountain of noise and a small, clean conclusion. That gap is exactly the thing you are trying to keep out of the parent, and it is exactly what makes the conclusion safe to summarize.

Tradeoffs and the ways this still bites

A typed contract is a coupling. The parent now depends on the shape of the result, so a field you add for one subagent is a field every subagent has to think about. Keep the contract small and general, verdict, summary, caveats, evidence, confidence, and resist growing a bespoke schema per subagent, or you will be maintaining a type system instead of an agent.

The evidence store needs a lifecycle. It lives as long as the run and then it should be collected, because a long session that spawns hundreds of subagents will accumulate megabytes of parked findings that nobody will ever fetch again. Tie its lifetime to the run and clear it on completion.

Parallel subagents are where this gets subtle. If you fan several out at once, each returns its own references and its own caveats, and the parent has to reconcile them, including the case where two subagents reach opposite verdicts on overlapping evidence. The contract makes that reconciliation possible, because the parent can compare verdict and confidence fields and open the specific evidence behind a disagreement, which is a far better position than trying to reconcile two paragraphs of prose. If your subagents share a writable store rather than isolated ones, you are no longer doing isolation, you are back to a shared blackboard, and that is a legitimate choice but a different one with its own rules.

And do not let the contract lull you into trusting a subagent's confidence as if it were calibrated. A model's self-reported confidence is a weak signal, better than nothing and worth thresholding on, but not a substitute for the parent opening real evidence on the decisions that matter most.

The takeaway

Subagent isolation is not just a way to save tokens. It is a boundary in your system where one agent's full understanding gets compressed into another agent's single input, and if you leave that boundary as free text you have built a place where information dies silently and takes its own evidence with it. Give the boundary a contract. Make the subagent fill named fields instead of writing prose, so omissions become visible. Return handles to evidence instead of the evidence itself, so the parent stays small and the audit trail survives. And spawn only when the noise you are hiding is genuinely larger than the conclusion you are keeping. Do that and isolation stops being a place where runs mysteriously go wrong and becomes what it was supposed to be, the thing that keeps a long run clean.

If your agents are spawning subagents and you have had a run make a confident wrong decision you could not trace afterward, the boundary is almost always where to look. Book a consultation call and we can find where your system is compressing away the evidence it needed to keep.

Viral Ruparel

Generative AI consultant helping teams ship reliable LLM and agent systems in production.

Contact Viral about your AI project →

Frequently Asked Questions

What is subagent context isolation?+

It is the pattern where a parent agent spawns a subagent that runs in its own fresh context window, does a focused task, and returns only a final result to the parent. Every intermediate tool call, file read, and piece of scratch work stays inside the subagent and never touches the parent's context. The point is to keep the parent's window small and on-task so it does not rot as the run gets long.

Why is a free-text subagent return risky?+

Because the subagent saw everything and the parent sees only the summary. That summary is a compression boundary with no schema and no provenance. If it drops a caveat, a confidence level, or the one row that contradicted the headline, the parent has no way to know something is missing, and the evidence that would let you debug it was thrown away when the subagent's context closed. A typed return contract with evidence handles fixes this.

When should I not spawn a subagent?+

When the task is small enough that the round trip costs more than it saves. Spawning pays a summarization tax, the parent asks, the subagent works and compresses, the parent decompresses by asking follow-ups. If the work is a single tool call or a few hundred tokens of output, inline it. Reserve subagents for work whose intermediate noise is large relative to its conclusion, which is exactly when isolation earns its cost.

How do evidence handles keep subagent returns honest?+

Instead of pasting raw evidence into the summary, the subagent writes its findings to a shared store and returns lightweight references, an id and a one-line label, alongside its structured conclusion. The parent stays small because it holds pointers, not payloads, and when a decision hinges on a detail it fetches the exact record on demand. Nothing is lost, and the audit trail survives the subagent's context closing.

Related Articles

AI Engineering

Your Multi-Agent System Already Has a Blackboard

Wire a few agents together with direct handoffs and it works. Add a fifth and the wiring becomes the system, brittle and impossible to trace. Most teams drift into a shared context blob that nobody designed, then spend weeks debugging it. That blob is a blackboard, a forty-year-old architecture pattern, and building it on purpose instead of by accident is what keeps a multi-agent system auditable as it grows.

AI Engineering

Your Voice Agent's Dead Air Is an Architecture Problem

A voice agent that goes silent for two seconds after the caller stops talking feels broken, and no faster model fixes it, because the floor is retrieval plus generation plus speech. The fix is structural: run a fast loop that owns the microphone and the caller's attention, and a slow loop that does the real work behind it. This is how to split them, handle barge-in cleanly, and warm the expensive work before the caller has finished the sentence.

AI Engineering

Your Easy Queries Are Paying for Thinking They Never Use

You turned on extended thinking because it lifted your quality numbers, and a quarter later the invoice had doubled. The reason is boring: most of your traffic is easy, and you are buying every one of those easy requests a slow, expensive reasoning path it never needed. Reasoning effort is a per-query decision now, not a global switch, and treating it that way buys back most of the bill without touching quality on the requests that actually matter.