Skip to content

8 min read

Returning the transcript rebuilds the context the split removed

On this page

The question the support-escalation agent was answering was whether any of Ledgerline's merchants would break when an upstream provider retired an API version. The parent spawned a research child, which made six tool calls: two code searches, a read of the provider's changelog, a query against the merchant configuration table, a read of two adapter files, and one more search to check a suspicion about a fallback path. It worked for eleven seconds and produced a genuinely good answer — nineteen merchants affected, three of them through an indirect path nobody had thought of.

Then it returned its entire working transcript, 28,100 tokens of it, as a single string, and the parent appended that string to its own messages and wrote the final answer.

text
parent window before spawn         22,400 tokens
child window at its own peak       28,100 tokens
parent window after the return     51,300 tokens   (+129%)
end-to-end latency vs inline       +4.1 s
eval score on 30 escalation cases  unchanged (24/30 -> 24/30)

The split is buying nothing at this point, and it is worth being precise about why rather than just noting the arithmetic. The parent's window is now bigger than it would have been if the research had run inline, because the child re-derived things in its own words and the parent got both the derivation and the answer. The parent is again conditioned on the child's full reasoning chain, which is the state chapter 1 described: it will find the child's conclusions well-supported, because it is reading the support. The one thing the split did preserve is that the child did its research without the parent's escalation history in its window, so the research itself was cleaner. Everything downstream of the return threw that away.

Sequence: parent (22.4k) → research child (0k) (task brief (1.1k)); research child (0k) → tools (search / read / query); tools → research child (0k) (results (avg 4.3k)); research child (0k) → parent (22.4k) (full transcript (28.1k)).

Length is the symptom; the defect is what an unstructured return keeps

The obvious repair is to have the child summarise before returning, and it does fix the arithmetic. Ledgerline tried it: a final turn asking the child to summarise its findings in under 400 tokens brought the parent's post-return window to 23,900, and latency dropped back to +1.2 s. The eval score went to 25/30, which is inside noise on thirty cases.

What the summaries did reliably was keep the confident parts. Across 30 runs, someone hand-labelled every assertion the child had made in its transcript and checked which survived into the summary. Assertions stated without hedging survived 96% of the time. Statements of the form I could not determine whether the sandbox adapter uses the same version pin survived 31% of the time. Explicit assumptions — assuming the config table's api_version column is authoritative and not overridden per-request — survived 24%. In eight of the thirty runs the parent's final answer stated as established fact something the child had explicitly flagged as unverified, and there was nowhere in the parent's window where that flag had ever appeared.

That is a worse failure than a large window, because it is silent. A 51k-token parent is visible in a dashboard. A parent that believes nineteen merchants are affected when the child meant nineteen if the config table is authoritative, and I could not check that produces an escalation email to nineteen merchants with no hedge in it, and nobody involved knows a caveat was dropped. The compression is doing what compression under a length budget does, which is spend the budget on the material that is easiest to state confidently.

Specify the fields, and let the schema do the work

The alternative is to stop asking for a summary and start asking for a record with named slots, enforced structurally rather than by instruction.

json
{
  "name": "research_result",
  "input_schema": {
    "type": "object",
    "required": ["findings", "assumptions", "unresolved", "coverage", "effort"],
    "properties": {
      "findings": {
        "type": "array",
        "items": {
          "type": "object",
          "required": ["claim", "provenance", "confidence"],
          "properties": {
            "claim":      {"type": "string"},
            "provenance": {"type": "array", "items": {"type": "string"},
                           "description": "file:line, table.column, or url + retrieved_at"},
            "confidence": {"enum": ["verified", "inferred", "reported"]}
          }
        }
      },
      "assumptions": {"type": "array", "items": {"type": "string"},
                      "description": "what had to be taken as true to proceed"},
      "unresolved": {
        "type": "array",
        "items": {"type": "object", "required": ["question", "would_settle_it"]}
      },
      "coverage":  {"type": "object",
                    "properties": {"searched": {"type": "array", "items": {"type": "string"}},
                                   "not_searched": {"type": "array", "items": {"type": "string"}}}},
      "effort":    {"type": "object",
                    "properties": {"tool_calls": {"type": "integer"},
                                   "budget_exhausted": {"type": "boolean"}}},
      "transcript_ref": {"type": "string",
                         "description": "handle for on-demand retrieval, not inlined"}
    }
  }
}

Each field earns its place by answering a question the parent will otherwise answer badly on its own. provenance, meaning the place each claim was read from, lets the parent quote a source in the escalation email and lets a human check one claim without re-running anything. confidence distinguishes a fact read out of a file from an inference across two files from something the provider's changelog asserted. assumptions is the field that would have caught the config-table problem, because the child had made that assumption explicitly and had nowhere to put it. unresolved carries the questions forward with the thing that would answer them, so the parent can decide to spend another call rather than guessing. coverage is the field teams add last and regret not having first, since without it the parent cannot tell a thorough child from a child that made one search and stopped — both return a short list of findings. effort with budget_exhausted distinguishes there is nothing more to find from I ran out of turns, which are the same output and opposite situations.

The filled instance for the deprecation question came to 1,240 tokens, and the child spent about 900 output tokens producing it, against roughly 380 for the free-form summary. That is the contract's direct cost and it is small; the indirect cost is that a child which knows it must fill coverage and unresolved spends a little of its reasoning budget on bookkeeping instead of on the question. Ledgerline saw the child's median tool calls rise from 5.4 to 6.1 after the contract landed, which reads as the child doing one more search so that not_searched would be shorter.

Enforcement matters more than wording, because a field that the child may omit is a field the child will omit on exactly the runs where it is inconvenient to fill. The return is a tool call against the schema above, so a malformed or incomplete response is rejected by the same machinery that rejects any bad tool input, and the rejection goes back to the child rather than to the parent.

python
result = child.run(brief, forced_tool="research_result", max_retries=2)

if result.validation_errors:            # schema rejected it; child retries
    raise SubagentContractError(result.validation_errors)

if not result.findings and not result.unresolved:
    # a child that found nothing and could not name a single open question
    # has almost certainly stopped early rather than finished
    result.effort.budget_exhausted = True

parent.messages.append(render_for_parent(result))   # ~1.2k tokens, never the transcript

The second check in that snippet is a guard rather than a schema rule, and it exists because the schema cannot express this is empty in a way that means failure. An empty findings array is a legitimate answer to some questions and a silent early stop for others, and the difference is visible only against coverage and effort. Two or three such guards accumulate around any contract, and they belong in the parent's code where they can be tested, not in the child's instructions where they are advisory.

The contract also did not arrive whole. The first version had findings and assumptions only, because the config-table incident is what prompted the work. provenance was added three weeks later, after a merchant disputed one of the nineteen and nobody could say which file the claim had come from without re-running the research. coverage and effort arrived together two months in, after a run where the child hit its tool-call ceiling on the second search and returned one thin finding that the parent presented as a complete answer. Every field on that schema is a scar, which is the normal way these things get built, and a team adopting the pattern from scratch can save itself the first two incidents by starting with all of them.

Return styleParent window afterCaveats retainedRuns where parent overstatedParent can cite a source
Full transcript (28.1k)51,300all of them, buried3/30yes, by reading 28k tokens
Free-form summary (400 tok)23,90031%8/30no
Field contract (1.2k)24,700all, in named slots1/30yes, from provenance

The objection that a contract loses something

A teammate will say that any fixed set of fields discards information the parent might have needed, and that a transcript at least contains everything. Both halves are true and the conclusion does not follow.

The parent does not read 28,100 tokens and use them evenly; it compresses them in its own head as it writes the answer, and that compression has the same bias as the child's summary with none of the child's knowledge about which parts were shaky. Choosing a contract does not introduce a lossy step, it moves an existing lossy step from the parent's hidden reasoning into a schema that a reviewer can read, a validator can reject, and a postmortem can amend. When the config-table incident happened, the fix was a one-line schema change; there is no equivalent one-line fix for the parent should have weighted that sentence in the middle of the transcript more heavily.

The objection also assumes the parent would have used the extra material well, and the 51k arm is the evidence against that. Its eval score was identical to running the research inline, at higher cost and higher latency, which means the 28,100 tokens the parent received bought nothing measurable over 22,400 tokens of doing it itself. If a transcript were the information-preserving option the objection imagines, the arm that had all of it should have won something. What it actually produced was three runs in thirty where the parent overstated a finding, which is fewer than the summary's eight and more than the contract's one, so even on the objection's own terms the transcript is the middle option rather than the safe one.

The transcript should also be kept, just not inlined. transcript_ref names a stored artifact, and giving the parent a fetch_transcript_span(ref, tool_call_id) tool means the material is available for the rare occasions it is needed, at the parent's choice, in the amount it asks for. Ledgerline's parents called it in about 4% of runs. That is the shape of the answer to the objection: the information is not gone, it stopped being mandatory.

There is a scaling argument for the contract that only shows up with more than one child. A parent that spawns one researcher can absorb a transcript; a parent that spawns four and receives four transcripts is holding 112k tokens of other agents' reasoning before it writes a word, and the four transcripts overlap, contradict each other in places, and give the parent no way to tell which contradiction matters. Four filled contracts come to about 5k tokens, arrive in the same shape, and can be merged mechanically — findings concatenated and deduplicated on claim, unresolved unioned, coverage.not_searched intersected to show what none of the four looked at. Contracts compose because they are records; transcripts do not compose because they are narratives, and narratives merge only by being read.

Two honest limits. Naming a field changes what the child produces, so declaring unresolved makes children generate unresolved items, and in the first month 12% of those entries were trivia the child had raised because the slot existed. Trimming came from watching which entries the parent ever acted on. And a contract makes poor work legible rather than preventing it — a child that searched one file and found nothing will now say so in coverage, which is progress, and it will still have searched one file.

Everything so far has treated the split as a way to control what a single child knows. The other reason to split has a completely different ledger: the child does not need a cleaner window at all, it needs to run at the same time as its siblings, and that changes what the return contract has to carry.

Sub-agent context isolation — read the current version at https://daviddominguez.dev/learn/en/sub-agent-context-isolation/returning-the-transcript-rebuilds-the-context-the-split-removed

Back to top