DEV Community

Mohammad Fauzel Sadeghizad
Mohammad Fauzel Sadeghizad

Posted on

Giving a Coding Agent an Org Chart

Every agent harness eventually grows a "spawn a subagent" tool, and every one hits the same wall: the child's transcript flows back into the mother's context. Spawn three children to refactor three modules and the mother now carries three full transcripts — tool calls, failed attempts, whole-file reads — most of which she never needed. Context is an agent's scarcest resource, and the default subagent design spends it as if the children's work were the point. The outcome is the point. Subsessions is our answer: a protocol layer over the continuable-subagent runtime in our fork of the DeepSeek Harness. The hero picture is the whole idea:

              ┌───────────────────────────────┐
              │   mother session (planner)    │   sees: briefs + reports
              └──────┬────────┬────────┬──────┘   never: transcripts
           ┌─────────┘        │        └──────────┐
    ┌──────▼──────┐    ┌──────▼───────┐    ┌──────▼──────┐
    │  worker A   │    │  worker B    │    │  verifier   │
    │ owns a/*    │    │ owns b/*     │    │ leaf role   │
    └──────┬──────┘    └──────┬───────┘    └─────────────┘
           │ progress         │ final report
           └────────┬─────────┘
                    ▼
     bounded reports only → mother pays O(report)
Enter fullscreen mode Exit fullscreen mode

The design rests on one invariant that outranks every convenience:

The mother's context grows by O(report), never by O(child transcript). The human sees the whole org tree; the mother sees only structured reports.

The shape of a subsession

A subsession is a real session — its own runtime SessionId, context window, tool permissions — that differs from a plain subagent in four ways.

It is role-typed. Spawn takes a role, never a model id; the preset's role table maps roles to tiers and depth caps. A role with depthCap: 0 is a leaf: the system asserts its tool filter denies subsession_spawn, so a worker can never become a mother — checked at role-definition time, not left to prompt goodwill.

It is brief-initialized. The mother writes a versioned brief (briefVersion: 1): mission, owned file globs, token cap, depth allowance, report contract (required sections + max report tokens), an optional verify list and continues: <predecessorId>. Validated field-by-field before anything starts; a rejection names the exact fields.

It is report-communicating. The child talks back through exactly one tool. A progress report is a heartbeat — quiet delivery, no wake. A final report settles the node forever and must satisfy the report contract, with a size gate that runs before structural validation: an over-cap report is rejected as REPORT_TOO_LARGE even if also malformed, so the size invariant is never traded away for a better error message. Reports are append-only, bounded to the last 50.

It is continuable. At any point a child can append a checkpoint to its journal — phase, state, decisions so far, next step — an append-only JSONL file per child; the registry holds only a pointer (workspaceFile:<path>#<line>). When a child dies, is killed, or finishes blocked, a fresh child can be spawned with continues: <predId>, the seed text carrying the checkpoint ref. Predecessors are never deleted.

The spawn pipeline is a pinned order, not vibes

Child creation runs nine checks in a fixed order, each short-circuiting into a canonical error code:

validateBrief → role exists → role safety → depth → tool filter
→ scope overlap vs live siblings → tree budget → concurrency caps
→ start child exactly once → register
Enter fullscreen mode Exit fullscreen mode

Two deserve comment. Scope overlap is a conservative glob check against live siblings under the same mother — two children can't silently own the same files (SCOPE_OVERLAP names the sibling and the paths; allowOverlap: true is allowed but logged). Tree budget is declared-arithmetic: the sum of live descendant caps plus the new cap must fit inside the mother's declared budget — arithmetic, not metering (more below).

Architecture: decision cores with injected ports

The protocol splits into two kinds of code. Decision cores are pure functions over the registry and schemas — no harness imports, no I/O, no clock. planAndStartSpawn takes a SpawnPorts object (role table, config, an identifyMother() probe, a startChild spy target) and returns an outcome; acceptChildReport, sweepQuiescence, buildSuccessionBrief, handoverOf all share the shape. This made the protocol testable with spies before runtime wiring existed, and made check ordering a tested property.

Runtime wiring is a thin host plugin that adapts the cores to real services — ctx.subagents.startContinuable, defineTool, the subagent/end event, file appends — with the clock injected by the wrapper, keeping the cores clockless and quiescence math deterministic in tests.

One honest wart, documented rather than hidden: the continuable-setup registry is process-global, so the registry and journal are module-level singletons. Multiple mounted instances would double-register child tools and throw; sharing the singletons makes whichever instance wins behaviorally identical, because node ids are global session ids.

Approvals: protocol-only, and we mean it

The spec's sharpest non-negotiable: children can never park on a human approval, and the mother can never approve a descendant's escalation — no code path, not even a hidden one. The runtime pins children to approvalPolicy: 'never' before dispatch; a test greps the plugin sources for approvalPolicy and user-approval on every run.
So how does a child that hits a permission wall make progress? It checkpoints, then sends a final report with outcome: 'blocked' and blockers: ["approval-required: fs write"]. The human's queue is a view over blocked finals — rendered in the browser, not a runtime state. The child settles cleanly, its slot frees, and a human decides whether to spawn a successor with the missing grant. Escalation becomes a data structure instead of a hung process.

The browser sees the org chart

A second client plugin renders what the mother never sees: the whole tree. It polls two host RPC endpoints every five seconds and shows the full org tree at any depth with live token usage per node where the projection is available — cold children show declared caps only, never faked numbers — checkpoint badges on settled nodes (a predecessor stays browsable after death), and the blocked queue with a Handover button that copies mission + blockers + checkpoint summary to the clipboard.

One deliberate data-plane detail: RPC payloads use conditional spreads (...(x !== undefined ? { x } : {})) because a key: undefined breaks the runtime's value snapshots.

Succession: firing an employee without reading their desk

When a child finishes blocked, fails, or is retired — then what? subsession_succeed { childId, role?, mission?, tokenCap? } spawns a successor: it inherits the predecessor's brief (with optional overrides), pins continues: <predecessorId>, and — critically — re-runs the entire spawn pipeline against the current tree. A stale scope claim can legitimately fail with SCOPE_OVERLAP against a sibling that moved in since. Succession is not a resurrection; it's a fresh hire with a handover letter.
The handover letter is the O(report) invariant made tangible:

continues: "a9e70fb7-…"
checkpoint: workspaceFile:.dsh/subsessions/a9e70fb7-….journal.jsonl#1
predecessor handover: need permission
predecessor blockers: approval-required: fs write
predecessor open questions: is fs write allowed?
Enter fullscreen mode Exit fullscreen mode

A pointer, a phase, three lines of summary. Not the transcript.

The gate before succession is a quiescence sweep: a running child whose last sign of life (max(heartbeat, claim-stamp)) is older than retireTimeoutMs is presumed dead — settled failed, descendants orphaned, slot freed. This fixed a real leak: silently-dead children held their slots forever, because nothing else emits a death event. The sweep runs at succession entry; claim-stamps are taken at the spawn gate (silence measured from the real spawn moment), and the sweep is idempotent.

How we verified it

Four phases, four gates, one rule kept throughout: no pass claim without machine evidence (junit XML, tsc exit codes, lint counts).

Gate Content Result
P1 Host plugin: schemas, roles, registry, budget, scope, journal, pipelines 132/132 tests, tsc 0
P2 Client UI: org tree + blocked queue + Handover host 141/141, client 5/5, bundle verified
P3 Succession command, quiescence sweep, claim stamps host 155/155 (14 suites), lint 0
P4 Docs only package README

Three layers of tests carry the weight: protocol tests over the pure cores (brief/report validation, pinned check order, budget boundaries, scope conflicts, idempotent second finals, orphan cascades — 28 cases, no runtime); integration tests driving the real cores through ports with spies, including the kill-mid-task → checkpoint → respawn roundtrip; and a driver suite over the real runtime — actual Context, children executed in-process, no model turns. The succession roundtrip asserts the successor's actual seed text contains the checkpoint ref and handover lines: the property that matters, verified end to end.

The tests earned their keep: a client bug (a transport failure wiped the UI's last-good snapshot), a tool-filter gap (subsession_succeed and subsession_checkpoint were missing from the deny-strip guard), and a clock-ownership drift (claimedAt was never stamped, so "silence since spawn" was measured from epoch zero). None visible in the passing happy path.

Related work: we're not the first to want this boundary

Claude Code — context: fork and agent frontmatter. Claude Code 2.1.0 added "running skills and slash commands in a forked sub-agent context" via context: fork in SKILL.md frontmatter — deliberately left OFF by default. agent: <name> dispatches a skill to a named subagent, model: (opus / sonnet / haiku / inherit) is honored in fork mode, allowed-tools: pre-approves a list, and subagent definitions accept skills: string[] to preload at startup. The tell that the idea crossed from "interesting" into "maintained": public bug reports — hooks not propagating into forked subagents, output swallowed by the desktop UI, infinite re-invocation loops (fixed in 2.1.145), agent: silently ignored in 2.1.112. (sub-agents docs, agent skills overview, skills spec, issue #14661)

Cognition — "Don't Build Multi-Agents" and the read-parallel/write-serial rule. Parallel agents make implicit, conflicting decisions; context passing across agents is the unsolved problem. What works in production: multiple agents contribute intelligence but writes stay single-threaded; parallel-writer swarms still don't see meaningful adoption. Anthropic's cost number: a multi-agent research system burns ~15× the tokens of chat, and token usage alone explained 80% of performance variance on BrowseComp. (Don't Build Multi-Agents, Anthropic multi-agent research)

MAST — the 14 failure modes. 1,600+ annotated traces across 7 multi-agent frameworks found 14 distinct failure modes in 3 categories — bad specification, inter-agent misalignment, failed verification — with inter-annotator kappa of 0.88. ChatDev scores 33.33% correctness on its ProgramDev benchmark, and the headline: multi-agent gains are often minimal against single-agent frameworks or even best-of-N sampling. (arXiv:2503.13657)

ReWOO — decoupling reasoning from observations. ReWOO's planner emits a plan with placeholders, workers execute the steps, and a solver composes the final answer once — instead of interleaving every tool observation back into the model's reasoning loop. Subsessions shares the instinct (don't feed raw observations upstream) but draws the boundary at the session level: the mother never receives the child's loop, only the settled report. (arXiv:2305.18323)

Where we sit: closest to Claude Code's forked-context line — role-typed children returning bounded output — but with the boundary pinned structurally (spawn gates, size gates, protocol-only approvals, succession) rather than by frontmatter convention.

Where we deviated from the spec — on purpose, at the gate

A spec that can't be argued with is a wish list. Every deviation was surfaced in a gate report, not smuggled in:

  • Approval parking → protocol-only. The blocked-final queue replaces runtime parking entirely (above).
  • Delivery modes: quiet | next-step only. Progress delivers quietly; finals deliver at the next step boundary. No retry buffer — a dead mother's report triggers the orphan cascade instead.
  • Tier→model is plugin config, not eval. Presets can't pin models in our runtime; per-task tier selection via evals is future work.
  • Journal = workspace-file JSONL. The spec wanted a memory service the fork doesn't have; append-only files with line-numbered pointers get the same properties.
  • verify: ['files'] = existence check. A missing claimed file marks the node disputed; changed-but-unclaimed detection needs snapshot diffs we didn't build.
  • Declared-budget arithmetic. No live token metering exists in the runtime for cold children; caps are enforced by arithmetic, live usage is shown only where the projection exists, and we never fake a number.
  • UI is polling, not push. Five seconds is fine; event push is an optimization, not a design change.

What we'd tell the next implementer

  • Pin the check order and test the order itself. Half the subtle bugs in permission-adjacent systems are ordering bugs.
  • Keep decision cores clockless and I/O-free. Every deterministic test we have is downstream of that one choice.
  • Make escalation a data structure. A hung child waiting for permission is a bug you can't see; a blocked final report is a row in a queue.
  • Never fake telemetry. Cold children show declared caps, not invented usage; a dashboard that lies once is a dashboard forever.

Status

All four phases are landed and gated in the fork: host plugin, client UI, succession + quiescence, docs. The host suite stands at 155/155 across 14 suites, the client suite at 5/5, type checks and lint clean. Gate reports record the evidence and every deviation.

Which leaves the honest ending. The O(report) invariant is enforced structurally today — every gate, every report contract, every succession letter exists to hold it. And now we have measured what it is worth on real workloads: read the follow-up measurement report, Measuring the Multi-Agent Fork Tax.

Top comments (0)