Subagents and Workflows: Composing Agents From Agents (Part 11)
Series: Inside the DeepSeek Harness — Part 11 of 16
Everything so far has been about one agent’s internals. This post is about what happens when one agent creates another — and it’s the first seam in this series that deliberately breaks the “single provider” pattern Part 5 established for ctx.shell.
A named-provider registry, not a single-provider seam
ctx.shell throws on a second registration — one bash provider at a time. ctx.subagents does the opposite: multiple providers coexist, and callers pick one by name. The reason is structural, not stylistic — subagent transports are genuinely heterogeneous (an in-process spawn and an out-of-process CLI wrapper are nothing alike under the hood) and a real deployment needs several of them running side by side, not swapped one for another.
Figure: four provider kinds, one interface — the caller picks which world a subagent runs in by name.
Capability discovery uses two different mechanisms for two different reasons. Static capabilities (outputSchema, depthLimit, toolFilter, persona) are checked before start() — a request needing a capability the provider doesn’t advertise is rejected loudly, before the provider ever starts, never a silent degradation. The continuable path — covered below — is gated differently: by whether the provider defines an optional prepareContinuable?() method at all. TypeScript’s own narrowing is the capability check. Two different mechanisms exist because they’re answering two different questions: static flags map to options a caller chooses per-call; a continuable start has exactly one shape, so its presence or absence tells the whole story on its own.
Depth tracking splits across a durable field (SessionHeader.delegationDepth) and a runtime one (AgentOptions.subagentDepth); whichever is greater wins, and — notably — the seam owns both fields, not the loop. The agent loop itself has no idea depth limiting exists; it’s entirely the subagent seam’s concern.
Continuable children: not a variant of a one-shot run — a different category
A SubagentRun is, in the docs’ own words, “one disposable foreground delegation with one result.” It has no steering, no resume — that’s not an oversight, it’s the entire contract. A continuable background subagent is architecturally something else: a durable child Session with at most one process-local Activation (the period during which a reconstructed child Agent is actually resident in memory). The docs state this directly: “no continuable path creates a Task or an intermediate result-bearing wrapper.” It reuses the child’s own Agent inbox — the exact mechanism from Part 9 — as its only queue.
Figure: three states, one inbox — a continuable child is resumed, not restarted, and its queue survives the gaps between Activations.
interrupt() is deliberately fire-and-return: it issues Agent.cancel(cause, { keepInbox: true }) — the exact cancellation primitive from Part 9 — and does not wait for quiescence, preserving unclaimed inbox work and any descendants. A later waking send resumes whatever was still parked. This is why the manager drives a continuable child’s inbox directly, rather than wrapping it as a SubagentRun: reusing SubagentRun’s shape for something steerable and resumable would mean bolting resume/steer semantics onto a type whose entire contract says it has neither. Keeping them structurally separate is cleaner than making one type do two incompatible jobs.
One more integrity detail, and it’s a genuinely careful one: settlement waits for ctx.sessions.flush() (Part 7’s write-behind checkpoint) but explicitly ignores its participation boolean. The stated reasoning: “an arbitrary listener cannot prove that a persistence backend stored the state.” A flush() completing tells you the write was attempted and observed; it can’t prove durability from the caller’s side, so the code declines to pretend it can.
Two message sources that look similar and are kept strictly apart
SubagentReportMessageSource and SubagentSettledMessageSource both represent “the parent hears something from a child,” but they mean different things and are never merged. A report is content the child chose to send. A settled notice is the runtime’s own account of how an Activation ended, delivered automatically when it settles — the child never wrote it. The docs put the reasoning in one sentence worth quoting directly: “a transcript that merged them would credit the child with words it never wrote.” It’s a small distinction with a real consequence — anyone reading a subagent’s transcript later needs to be able to tell, unambiguously, which lines the child actually produced and which lines are the parent runtime narrating what happened.
Workflows: a single engine, and a run that can never hang
Unlike subagents, ctx.workflowEngine is a single-implementation seam — mounting a second engine replaces the first, rather than running alongside it, because a workflow run needs one authoritative scheduler. The shipped provider runs each workflow script inside its own node:worker_threads worker. Scripts are plain JS with top-level await allowed, and — a detail worth calling out — meta/args are schema-validated before any script text is evaluated at all, on plain JSON data. Validation never has to execute untrusted code just to introspect its shape.
Figure: a typo in workflow-script hook usage crashes loudly; a legitimate child-run failure resolves quietly to `null` — two different things, deliberately not conflated.
WorkflowError.fatal marks hook misuse specifically — bad arguments, an unknown agent() option, an invalid schema, a tripped depth cap, a seam start failure, cancellation itself. The parallel()/pipeline() combinators re-throw fatal errors instead of silently mapping them to null — null is reserved strictly for a legitimate child-run failure, never for a script bug. And WorkflowRun.result is documented to never reject: cancellation force-settles the run to stopReason: 'cancelled' within a bounded grace period, even if the script itself never returns on its own — so a caller awaiting a workflow’s result can never be left hanging indefinitely.
Adjacent long-running-work primitives round out the picture without competing with any of the above: terminals (persistent PTY sessions, where wait-reason and session-status are tracked independently — a send can return on inferred idle while the shell process is still very much alive), and jobs (background bash/subagent work, capped at 10 concurrent per owner by default, where teardown claims the “reported” flag unilaterally — specifically so a torn-down reporter chain doesn’t spend a model request per teardown layer just to notify nobody who’s still listening).
Ralph: composed from these same primitives, not a fourth thing
One more pattern worth naming, because it’s easy to mistake for a whole new subsystem when it’s actually just workflow and subagent primitives arranged a specific way. A Ralph loop is one foreground, fresh-agent workflow run toward an immutable objective — the glossary is explicit that it’s a model-facing tool policy, not a same-session goal, not an agent-loop mode, not a scheduler, and not a generic workflow script feature. A Ralph round is one fresh child session inside that loop; critically, each round’s child receives no parent or prior-child conversation seed at all — no fork, no shared history. Continuity across rounds comes from exactly two things: the shared workspace on disk, and one bounded Ralph handoff — a normalized structured report (status, summary, evidence, next steps, blockers) passed from one round to the next. The handoff supplements the workspace as the record of progress; it doesn’t replace it as the authority.
It’s a clean illustration of this series’ running theme: rather than inventing a new mechanism for “long autonomous task, broken into fresh attempts,” Ralph composes the workflow engine (this post’s worker-thread runner) and the subagent seam (spawn-in-process, with zero inherited context by design) into a specific policy — and the fact that it needed no new primitive to exist is itself evidence for how far the existing ones stretch.
Compare: Claude Code
Claude Code’s subagent system ships four hardcoded agent types — Explore (dual-locked read-only), Plan (reuses Explore’s toolset), General Purpose (full access), Verification (adversarial, background-only) — plus a Fork pattern for cache-sharing peer parallelism and a Coordinator pattern that strips the orchestrating agent down to four tools. All four types are compiled in; none is addable without a Claude Code release.
dsh’s answer to “what kinds of agents can exist” is this post’s named-provider registry: four provider kinds plus a structurally distinct “continuable” category, every one of them a registration rather than a compiled branch. It’s the same shape as the comparison in Part 16, one level up: where Part 16 covers what a single agent is (a preset — swappable, user-authorable), this post covers how many agents exist and how they talk to each other (a registry — extensible, provider-based). Read together, they’re the same underlying bet applied at two different layers: nothing about “what kind of agent” should be a fact the runtime hardcodes, if it can instead be a fact the runtime looks up.
Coming up in this series
(All 16 parts are live today — no daily drip.)
| Part | Title | What it covers |
|---|---|---|
| 1 | DeepSeek Harness: Inside the Open-Source Claude Code Rival | The launch, the comparison, no privileged core, the four Cordis primitives |
| 2 | Composing an App From YAML, Not Code | Profiles, bundles, five patch layers, boot, live HMR, --dump-config |
| 3 | Scope: Why a Live Agent Is the Key of Its Own Registration | Shadowing, restriction, lineage vs. scope |
| 4 | Tool Execution in DeepSeek Harness: Guards and Approval | Pre-execute, monotonic guards, post-execute, approval |
| 16 | DeepSeek Harness: Agent Presets as Data, Not Code | Agent presets, PTC/Code Mode, the plugin ecosystem, compared to Claude Code’s agent types |
| 5 | Capability Seams: Making Bash Swappable for Sandboxed Bash | The 3-role pattern end-to-end, across ~85 real seams |
| 6 | The Session Log: DeepSeek Harness’s Enforced Invariant | The real invariant-checking code, surface projection |
| 7 | Persistence and Compaction: Crash-Safe by Construction | JSONL/SQLite, torn-tail repair, the compaction lock bracket |
| 8 | Waterfalls: The One Event Pattern That Runs Everything | Five dispatch modes, durable vs. live events, retry |
| 9 | The Agent Loop: Turns, Steps, and a Real Cancellation Bug | The phase state machine, the inbox, a dated bug fix |
| 10 | The LLM Layer: One Message Format, Every Surface | Message vocabulary, streaming, retry-via-log-replay |
| 11 | Subagents and Workflows: Composing Agents From Agents (this post) | Provider kinds, continuable children, Ralph rounds |
| 12 | Defense in Depth: Sandboxing and Four Real Incidents | bwrap/Landlock/Seatbelt/ACL, real production postmortems |
| 13 | Three Surfaces, One Spine: Web, Typert RPC, and SDK/ACP | Typert codegen, the four-quadrant RPC envelope |
| 14 | Engineering Rigor: DeepSeek Harness’s Verification Gate | The 100% coverage gate, real engineering-culture quotes |
| 15 | What a Second Production Harness Teaches | dsh vs. the Agent Harness series, side by side |
Part 16 shipped after this series’ initial 15 posts, once Agent Presets and Code Mode landed — it reads best right after Part 4, which is why its row sits there instead of at the end.
Next: Part 12 is the security post — the full sandbox chain across four platforms, and four real, dated production incidents with root causes, including two that shipped as silent-wrong-behavior YAML footguns rather than crashes.
References
- Source citations pulled directly from the repo as of commit
47f9438(master, Aug 13, 2026 — same commit cited throughout this series):packages/subagent/subagent/src/index.ts(ctx.subagents, provider kinds, continuable Activation lifecycle) docs/subsystems/subagent.mdanddocs/subsystems/workflow.mddocs/subsystems/terminal.mdanddocs/subsystems/jobs.mddocs/glossary.md— Ralph loop / Ralph round / Ralph handoff definitions- Part 1 through Part 10 of this series
Have thoughts on this?
I read every email. If something resonated, felt wrong, or made you think — I'd love to hear from you.
Comments