SherlockLiu Logo SherlockLiu
Back to all posts
Engineering

Subagents and Workflows: Composing Agents From Agents (Part 11)

SL
Aug 14, 2026 8 min read
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.

In-process spawn — fresh child, inherits nothing inheritsParentContext: false fork — seeded with parent's balanced completed-turn prefix inheritsParentContext: true Out-of-process acp / codex / claude-code — real child processes via ctx.subprocess dsh-sdk — SDK client spawns its own child runtime entirely

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.

no Activation followup() cold-resume from persisted Session running / waiting enqueue in place or wake startContinuable() resolves on inbox ACCEPTANCE, not turn completion — an async-first contract.

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.

workflow script worker_threads worker agent() calls fan out to subagent seam WorkflowError.fatal re-thrown, not mapped to null result never rejects — cancellation force-settles within bounded grace, never leaves a caller wedged.

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 nullnull 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

Comments