Add in-process subagent backends: spawn (fresh) and fork (seeded)

The second PR of the subagent seam: the two in-process backends that run a
child agent on the same cordis context, reusing the agent factory's quiescent
AgentHandle teardown. Both register on ctx.subagents (PR1's named-provider
registry) and share one run driver.

- dsh-subagent-spawn: a FRESH child via ctx.agents.create — own session, the
  parent's model by default (overridable), zero inherited conversation. Also
  exports the shared in-process run driver (startInProcessRun): mint ids, stamp
  cwd/parentSession-lineage/depth, drive the one-shot (send → whenIdle), read
  the last assistant/message + turn/end reason, dispose to quiescence.
- dsh-subagent-fork: a child SEEDED with the parent's balanced completed-turn
  prefix (the log up to and including its last turn/end), so the child inherits
  context. The in-flight unbalanced turn is excluded — a raw seed would fail the
  invariants replay. Proven: a regression test goes red if the boundary seeds
  the open turn.
- Seam extension: CreateAgentOptions.seed, threaded through AgentLoop.createAgent
  → ctx.sessions.prepare({ seed }) (the primitive resume already used). This is
  the fork-lineage path the TODO(sub-agents) markers anticipated.
- Depth: a merge-extensible AgentOptions.subagentDepth (0 top-level, parent+1 for
  a child); the depthLimit capability refuses a spawn past request.maxDepth.

Tests: real-loop unit tests for both backends (mock MODEL only, real loop +
invariants), a multi-subagent test (one parent drives a fork AND a spawn child
then keeps working), and a with-key e2e (a real parent delegates via the
`subagent` tool to a real child that writes a file on disk — world-verified).
100% per-file coverage. The coding-agent demo wires the spawn backend + tool.

Snapshot coverage of nested agents is deferred to a stacked follow-up
(TODO(subagent-snapshots)): dsh-llm-replay is a single global positional cursor
that cannot route calls to a parent vs. a child on one context. Recorded in the
RFC's deferrals and a new AGENTS.md rule: designing a subsystem must design its
test infrastructure END TO END up front, verifying the snapshot/e2e harness can
express the new shape — a gap this plan hit.
This commit is contained in:
Tianyi Cui
2026-06-22 05:58:40 +08:00
parent 861791d2d8
commit 7aabd2a3df
28 changed files with 1296 additions and 13 deletions
@@ -0,0 +1,57 @@
/**
* The in-process SPAWN subagent backend: registers a {@link SubagentProvider}
* on `ctx.subagents` that runs each child as a FRESH child {@link Agent} on the
* same cordis context (its own session, own system prompt, zero parent
* context). The cheapest transport, reusing the agent factory's quiescent
* teardown.
*
* The fork sibling (`@deepseek-ai/dsh-subagent-fork`) shares this package's run
* driver ({@link startInProcessRun}) and differs ONLY in seeding the child with
* a prefix of the parent's log.
*
* Plugin export shape: named `name`/`inject`/`Config`/`apply`, NO default.
*
* @module @deepseek-ai/dsh-subagent-spawn
*/
import type { Context } from 'cordis'
import z from 'schemastery'
import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
import { startInProcessRun } from './in-process.ts'
export { startInProcessRun, depthOf, SubagentDepthError } from './in-process.ts'
export type { InProcessRunOptions } from './in-process.ts'
export const name = 'subagent-spawn'
export const inject = ['subagents', 'agents']
/** Config: the registry name to register the provider under. */
export interface Config {
/** Provider name on `ctx.subagents` (default `spawn`). */
providerName: string
}
export const Config: z<Config> = z.object({
providerName: z.string().default('spawn'),
})
/**
* The spawn provider. Supports `depthLimit` (it constructs the child, so it can
* enforce a recursion cap) but NOT `outputSchema` or `toolFilter` in this cut —
* a request that needs either is rejected by the service before `start` runs.
*/
class SpawnProvider implements SubagentProvider {
readonly capabilities: SubagentCapabilities = { outputSchema: false, depthLimit: true, toolFilter: false }
constructor(readonly name: string, private readonly ctx: Context) {}
start(request: SubagentStartRequest) {
// Fresh child: no seed. The shared driver mints ids, stamps cwd/lineage/
// depth, drives the one-shot, and maps the result.
return startInProcessRun(this.ctx, request, { providerName: this.name })
}
}
export function apply(ctx: Context, config: Config): void {
ctx.subagents.registerProvider(new SpawnProvider(config.providerName, ctx))
}