M6.3
Context isolation and the return channel
912 words · about 6 min
Concept
A child's context is scratch space. Its only durable product is the result it returns. Everything else — its tool calls, its reasoning, its eleven failed test runs — is discarded when the run settles. That is the entire economic argument for delegation, and it imposes a discipline: whatever the parent will need must be in the result, because there is no second chance to ask.
This makes result design the highest-leverage decision in a multi-agent system. An unstructured child return is a second unbounded input to the parent, which is the problem you delegated to avoid. Three failure modes recur:
- Prose returns. The child writes four paragraphs. The parent must now parse English to decide what to do, and cannot branch reliably. The token savings are real but small, and the reliability loss is large.
- Return-the-whole-thing. The child pastes the file it read. You have paid a session's overhead to move tokens from one window to another.
- Underspecified success. The child returns "done" and the parent has no way to verify. Completion claimed by the worker is not completion verified by anyone — a point that returns with force in M6.8.
The fix is a schema. Ask for { files: string[], summary: string, blockers: string[] } and you get a value you can branch on, a bounded size, and an explicit slot for "what went wrong" that the child cannot silently omit.
Mechanism: the result envelope and the report tool
Mechanismdsh's result type separates four concerns:
interface SubagentResult {
readonly output: ContentBlock[]
readonly structured?: unknown
readonly diagnostic?: string // ≤4096 UTF-8 bytes, scrubbed
readonly stopReason: SubagentStopReason
}output is the model-visible content. structured is the schema-validated value when an output schema was requested. diagnostic is a bounded and scrubbed operator-facing string — bounded because an unbounded error message from a child is an unbounded input to the parent, scrubbed because a child's error text can contain paths, environment fragments, and credentials. stopReason is how the run ended, and it is a merge-extensible derived union:
interface SubagentStopReasonMap {
completed: 'completed'; aborted: 'aborted'; error: 'error'
'max-tokens': 'max-tokens'; refusal: 'refusal'
}Separately, there is a push channel. packages/subagent/tool-subagent-report registers a child-scoped report tool with one required output string, letting a long-running child hand something back before it finishes. Two design details are worth studying:
- Delivery mode.
reportDeliveryisnext-stepby default, which uses the parent'ssteer()— the report influences the parent's next decision. The alternative,quiet, usesinject()— the report lands in the transcript without redirecting the parent. - The channel cannot be filtered away. From the README: "Scope-local registration deliberately survives the child's global
toolFilter, so a delegation allow-list cannot remove the only return channel." This is a real bug class. A parent that restricts a child to["read", "grep"]would, under naive implementation, remove the child's ability to reply — producing a child that works correctly and can never say so.
The recipient is not a parameter. reportFrom() derives it from the child's durable parentSession; callers cannot name a recipient. A child cannot report to its grandparent, its sibling, or anyone else.
Worked example — the honest-failure result
Worked exampleA child is asked to fix a failing test. It cannot: the failure is environmental, a missing binary.
Bad result: "I attempted several approaches but was unable to resolve the issue. The test appears to depend on external state." The parent learns nothing actionable and cannot distinguish this from a lazy child.
Good result, under a schema with a blockers field: { status: "blocked", blockers: ["test requires pdflatex on PATH; not installed"], filesChanged: [] }. Now the parent can act — install the binary, or escalate to the user with a specific ask.
The schema created a slot the child had to fill. That is the mechanism: structure forces disclosure that prose lets you avoid.
Tradeoffs
Schemas cost model compliance — not every provider supports structured output, and a model asked for JSON sometimes returns JSON-shaped prose. Bounded diagnostics lose detail that would have helped debugging. And the deeper tradeoff is that isolation makes children unobservable: you cannot ask a finished child what it was thinking. Event logs of the child session mitigate this for operators but not for the parent model, which sees only the result.
How `dsh` does it
How dsh does itBeyond the envelope above, dsh is careful about authorship in the transcript. Two distinct message sources exist — SubagentReportMessageSource (kind: 'subagent-report') and SubagentSettledMessageSource (kind: 'subagent-settled') — so a transcript never credits the child with words the runtime authored on its behalf. The subagent/descriptor event is log-only and never enters model history at all.
The report tool's failure text is also instructive: direct parent is not live; report was not delivered. And the README adds the caveat that a failed tool call does not prove non-delivery. Distributed messaging does not become reliable because both ends are in one process.
How others differ
Most harnesses return the child's final assistant message as plain text and stop there. Structured child output is not universal. Claude Code's subagents return their final message to the parent; the tool allow-list is per-subagent, and the return path is not something a tool filter can accidentally sever, because it is not a tool. That is a different solution to the same problem dsh solves with scope-local registration — worth noting that the problem exists in both designs and each had to answer it.
Check yourself
2 questions on M6.3