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
+1
View File
@@ -207,6 +207,7 @@ Dev/test/demo run **unbuilt** via tsx + the `paths` map in the root `tsconfig.js
- **Tests**: vitest, colocated under `packages/<group>/<pkg>/tests/*.spec.ts`. Every registry needs an HMR-safety test (dispose the contributing fiber, assert cleanup). **Excessive tests are welcome** — when in doubt, write the test; err on the side of covering edge cases, error paths, event ordering, and concurrency races even if they seem unlikely. Review findings get regression tests (see `packages/core/agent-loop/tests/review-fixes.spec.ts`). The same generosity applies to **real-API (with-key) e2e tests — inference is cheap here (we are DeepSeek), so do not ration them**: cover the agent's real flows (a real prompt that writes a file, multi-turn, tool use, cancellation) and run them frequently while developing, especially cheap **smoke tests** that boot the real example and check the world. A green mock/no-key suite proves the plumbing, not the product — the with-key smoke test is what catches "green units, broken product". See § Secrets / .env for the with-key policy and why self-skip is a CI accommodation, not a verdict that real-API tests are expensive.
- **Prefer the REAL implementation over a mock/stand-in in tests.** When the genuine collaborator is available in the repo, wire it up instead of hand-rolling a fake — a test that registers an inline `defineTool({ name: 'bash', … })` to stand in for `dsh-tool-bash` proves the *bridge* moves bytes but not that the *shipping tool* renders the way the test asserts; the two drift and the test passes while the product is wrong. Mock only the genuinely expensive/non-deterministic boundary (the LLM adapter, the network, the clock) and keep everything downstream real: a bridge tool-call test runs the scripted mock MODEL but the REAL tool + REAL executor (e.g. `makeBridgeHarness({ withBash: true })` plugs `dsh-bash-local` + `dsh-tool-bash` and runs an actual `echo`), so it verifies the actual `presentCall`/`presentResult` an editor sees. This is the unit-test echo of "verify the world, not a synthetic stand-in" (see § Defensive patterns) — a fake you wrote will agree with whatever you assumed; the real thing won't.
- **A change that affects the editor-facing transcript or end-to-end agent UX needs a snapshot test (or an explicit note in the PR why none applies).** The snapshot tier (`examples/*/tests/**/*.snapshot.ts`, `pnpm run test:snapshot`) boots the real example subprocess, replays a recorded session JSONL deterministically (keyless), and diffs the normalized stdout transcript + re-persisted session log against committed goldens — the full-transcript regression net that mock-level unit tests structurally cannot be (it is what catches a bridge-translation or loop-structure regression that leaves every unit green). When you change the ACP bridge, the agent loop's observable output, tool presentation, or anything an editor renders, add or update a scenario under `examples/acp-agent/tests/snapshots/` and re-record with `pnpm run test:snapshot:record`. Reviewing the golden diff is part of the review. The rule is scoped to transcript/UX-affecting changes — a pure internal refactor with no observable-output change does not need one, but say so. See [docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md](docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md).
- **Designing a new subsystem includes designing its test infrastructure — END TO END, up front, as part of the same plan.** When you introduce a new capability seam, a new agent-lifecycle shape, or anything that produces an observable transcript (a new tool family, a subagent transport, a new UI surface), the plan must name how it will be covered at EVERY tier it touches — unit, real-API e2e, AND the full-transcript snapshot tier — and, critically, must check that the existing test infrastructure can actually express that coverage. Do not assume a snapshot/e2e harness built for one shape (e.g. a single top-level ACP session) transparently supports a new shape (e.g. a parent agent driving nested child agents): verify it, and if it cannot, the harness extension is in-scope work to plan and schedule, not a detail to discover mid-implementation. This rule exists because a real plan under-scoped exactly this: the subagent backends were planned with unit + e2e coverage but the snapshot tier turned out to assume one session per process (`dsh-llm-replay`'s single positional cursor, single-file harvest), so nested-agent snapshot coverage became unplanned net-new infrastructure (`TODO(subagent-snapshots)`). The cost of finding that during design is a paragraph; the cost of finding it mid-build is a re-plan. When the harness gap is large enough to be its own reviewable unit, schedule it as a dedicated stacked follow-up with its own RFC — but SAY SO in the originating plan, with the gap named, rather than letting it surface as a surprise.
## Defensive patterns (hard-won)
+1 -1
View File
@@ -332,7 +332,7 @@ list(): Agent[]
Types: [Agent](../core-data-structures/core.md)
Source: [`packages/core/agent/src/index.ts:105`](../../packages/core/agent/src/index.ts)
Source: [`packages/core/agent/src/index.ts:116`](../../packages/core/agent/src/index.ts)
### `ctx.bash` — `BashExecutor` (abstract seam)
+7
View File
@@ -86,3 +86,10 @@ interface SubagentProvider {
```
The service (`ctx.subagents`) emits `subagent/start` when a run begins and `subagent/end` when it settles (see the [events catalog](../cordis-catalog/events-and-services.md)). Both emits contain a thrown listener **per listener** (logged, never propagated): one bad subscriber can neither strand a live run, surface as an unhandled rejection on the detached settle hook, nor starve the listeners registered after it.
## In-process backends: depth and seed
The two in-process backends ([dsh-subagent-spawn](../../packages/subagent/subagent-spawn) fresh, [dsh-subagent-fork](../../packages/subagent/subagent-fork) seeded) run the child as a child `Agent` on the same context via `ctx.agents.create`. Two pieces of vocabulary ride on the existing agent/session types rather than new core types:
- **Delegation depth** is a merge-extensible `AgentOptions.subagentDepth` field (`0` for a top-level agent, parent + 1 for a child). The seam owns it — the loop neither sets nor reads it — so a nested spawn reads its parent's depth from `parent.options.subagentDepth` and the `depthLimit` capability caps the tree by refusing a child whose depth would exceed `request.maxDepth`.
- **Fork seeding** uses `CreateAgentOptions.seed` (a `SessionEvent[]` prefix threaded through `AgentLoop.createAgent` → `ctx.sessions.prepare({ seed })`, the same primitive `resume` uses). The fork backend passes a *balanced completed-turn prefix* of the parent's log — the parent's events up to and including its last `turn/end` — so the seed is contiguous-from-0 and the [invariants](../../packages/support/invariants) replay accepts it (the in-flight, unbalanced turn is excluded).
+10
View File
@@ -63,6 +63,10 @@ graph TD
subagent-mock --> agent
subagent-mock --> llm
subagent-mock --> subagent
subagent-spawn --> agent
subagent-spawn --> llm
subagent-spawn --> session
subagent-spawn --> subagent
tool-subagent --> agent
tool-subagent --> llm
tool-subagent --> subagent
@@ -75,6 +79,10 @@ graph TD
stdio-agent --> session
stdio-agent --> session-persistence-jsonl
stdio-agent --> ui-stdio
subagent-fork --> agent
subagent-fork --> session
subagent-fork --> subagent
subagent-fork --> subagent-spawn
```
| Package | Depends on |
@@ -101,6 +109,8 @@ graph TD
| `tool-bash` | `agent`, `bash`, `llm`, `tools` |
| `agent-core` | `agent`, `agent-loop`, `invariants`, `llm`, `session`, `system-prompt`, `tool-bash`, `tools` |
| `subagent-mock` | `agent`, `llm`, `subagent` |
| `subagent-spawn` | `agent`, `llm`, `session`, `subagent` |
| `tool-subagent` | `agent`, `llm`, `subagent`, `tools` |
| `acp-agent` | `acp`, `agent-core`, `session-persistence-jsonl` |
| `stdio-agent` | `agent`, `agent-core`, `session`, `session-persistence-jsonl`, `ui-stdio` |
| `subagent-fork` | `agent`, `session`, `subagent`, `subagent-spawn` |
@@ -70,3 +70,4 @@ The `dsh-tool-subagent` consumer awaits `run.result` and returns the child's fin
- **Blocking the parent turn.** Synchronous collect holds the parent's `runStep` open for the child's full duration. This is acceptable for the first cut; **background / poll / spill semantics are deferred to a future redesign that unifies long-running-tool handling across subagents AND bash** (a sub-agent and a long `bash` background task pose the same "the model started something slow, how does it collect later" problem, and should share one mechanism rather than each inventing its own).
- **Live progress.** This cut surfaces only lifecycle + final result; a per-chunk child→parent update stream is deferred with the background redesign.
- **ACP client surface.** Proxying `fs`/`terminal` from the ACP child back to the parent (a shared-workspace mode) is future work; the first cut advertises neither, so the child self-serves in its own process.
- **Snapshot coverage of nested agents.** The snapshot tier (`pnpm run test:snapshot`) replays a recorded session through `dsh-llm-replay`, whose dispatch is a single GLOBAL positional cursor (the Nth `llm/stream` call serves the Nth recorded entry) and whose harness harvests a single session log file. A subagent runs as a *second* agent with its own session log, so a parent→child scenario needs per-session-keyed replay (or a call-ordered merge of both logs, sound because subagent execution is strictly nested/non-concurrent — the parent blocks on the child) plus harvest-all-logs and plural-session-id plumbing in the harness. This is self-contained infrastructure orthogonal to the backends, so it lands as a **dedicated stacked follow-up** rather than in the in-process-backends PR. Until it lands, in-process subagents are covered by real-loop unit tests (a parent driving a fork AND a spawn child) and a with-key e2e (a parent delegating to a child that writes a file), not by the snapshot transcript tier. Tracked by `TODO(subagent-snapshots)`.
+28 -5
View File
@@ -48,12 +48,35 @@
systemPrompt: |
You are coding-agent, a CLI coding assistant.
Your only tools are bash (plus bash_output/bash_kill for background
tasks). Do ALL file operations through bash: read with cat/sed/head,
search with grep, write with heredocs (cat <<'EOF' > file), edit
with sed or a rewrite. Each bash call runs in a fresh shell — pass
workdir instead of cd, and never rely on shell state between calls.
Your tools are bash (plus bash_output/bash_kill for background
tasks) and subagent. Do ALL file operations through bash: read with
cat/sed/head, search with grep, write with heredocs (cat <<'EOF' >
file), edit with sed or a rewrite. Each bash call runs in a fresh
shell — pass workdir instead of cd, and never rely on shell state
between calls.
Use the subagent tool to delegate a focused, self-contained subtask
to a fresh child agent (it works in its own context and returns only
its final result) — give it a complete, standalone instruction.
Check the [exit code: N] marker on every command; investigate
failures before moving on. Verify your work by running the code or
tests. Keep answers brief and factual.
# The subagent seam + an in-process spawn backend + the model-facing `subagent`
# tool, as leaf entries after the app (which provides ctx.agents/ctx.tools). The
# tool is bound to the `spawn` backend: a delegated task runs as a fresh child
# agent on this same process. (fork is available too — load dsh-subagent-fork
# and a second dsh-tool-subagent bound to it with a distinct toolName.)
- id: subagent
name: '@deepseek-ai/dsh-subagent'
- id: subagent-spawn
name: '@deepseek-ai/dsh-subagent-spawn'
config:
providerName: spawn
- id: tool-subagent
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: spawn
+4
View File
@@ -36,6 +36,10 @@
"packages/ui/stdio-agent": {
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"]
},
"packages/subagent/subagent-spawn": {
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"]
}
}
}
+4
View File
@@ -40,6 +40,8 @@ dsh-ui-stdio ← dsh-agent, dsh-llm, dsh-session (stdio readline UI plugin)
dsh-llm-replay ← dsh-llm, dsh-session (record/replay adapter for keyless snapshot tests)
dsh-subagent ← dsh-agent, dsh-llm, dsh-tools (abstract subagent provider-registry seam)
dsh-subagent-mock ← dsh-subagent (scripted provider for tests)
dsh-subagent-spawn ← dsh-subagent, dsh-agent, dsh-session, dsh-llm (in-process fresh child + shared run driver)
dsh-subagent-fork ← dsh-subagent-spawn, dsh-agent, dsh-session (in-process child seeded from parent log)
dsh-tool-subagent ← dsh-subagent, dsh-tools, dsh-agent (model-facing delegation tool)
dsh-agent-core ← timer, dsh-llm, dsh-session, dsh-system-prompt, dsh-tools, dsh-agent, dsh-invariants, dsh-tool-bash, dsh-agent-loop (the providerless spine, as one bundle plugin)
dsh-stdio-agent ← dsh-agent-core, dsh-ui-stdio, dsh-session-persistence-jsonl, dsh-agent, dsh-session (stdio chat APP + bin)
@@ -74,6 +76,8 @@ The rule: **extension** plugins depend on interfaces, never on the concrete loop
| `ui-stdio/` | `support` | Minimal stdio (readline) UI plugin: renders `agent/*` events, feeds stdin lines to the agent | (drives `ctx.agents`) |
| `llm-replay/` | `support` | Record/replay adapter: short-circuits `llm/stream` with chunks from a recorded session JSONL (keyless snapshot tests) | (listens on `llm/stream`) |
| `subagent/` | `subagent` | Abstract subagent seam: named-provider registry for delegating to child agents | `ctx.subagents` |
| `subagent-spawn/` | `subagent` | In-process backend: a fresh child agent (+ the shared in-process run driver) | (registers on `ctx.subagents`) |
| `subagent-fork/` | `subagent` | In-process backend: a child agent seeded with the parent's completed-turn prefix | (registers on `ctx.subagents`) |
| `subagent-mock/` | `support` | Scripted `SubagentProvider` for testing the seam through the real load path | (registers on `ctx.subagents`) |
| `tool-subagent/` | `subagent` | Model-facing `subagent` delegation tool over `ctx.subagents` | (registers on `ctx.tools`) |
| `brand/` | `util` | Type-only `Branded<B>` nominal-typing primitive (no runtime code, no harness deps) | (none — type-only) |
+10 -4
View File
@@ -140,16 +140,22 @@ export class AgentLoop extends Service implements AgentFactory {
/**
* Programmatic factory create ({@link AgentFactory}): an agent on a
* caller-supplied `sessionId` (NOT `${id}-session`), with optional session
* metadata (validated `cwd`, lineage). The ACP bridge uses this so the
* client-generated session id becomes the live/persisted session id. Returns
* an {@link AgentHandle} the owner disposes to tear down exactly this agent.
* metadata (validated `cwd`, lineage) and an optional `seed` event prefix. The
* ACP bridge uses this so the client-generated session id becomes the
* live/persisted session id; the in-process FORK subagent backend passes a
* `seed` (a balanced completed-turn prefix of the parent's log) so the child
* starts with the parent's context. Returns an {@link AgentHandle} the owner
* disposes to tear down exactly this agent.
*/
createAgent(options: CreateAgentOptions): AgentHandle {
// Check the agent id BEFORE preparing the session: register() would reject a
// duplicate id only AFTER the session enters the store, leaving an orphaned
// live session (and lazy persistence state) that blocks reuse of that id.
this.assertAgentIdFree(options.agentId)
const session = this.ctx.sessions.prepare(options.sessionId, { meta: options.meta ?? {} })
const session = this.ctx.sessions.prepare(options.sessionId, {
...options.seed !== undefined ? { seed: options.seed } : {},
meta: options.meta ?? {},
})
return this.startOwned(options.agentId, options.agentOptions ?? {}, session)
}
+12 -1
View File
@@ -6,7 +6,7 @@
*/
import { Context, Service } from 'cordis'
import type { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import type { Agent, AgentId, AgentOptions } from './types.ts'
export * from './types.ts'
@@ -37,6 +37,17 @@ export interface CreateAgentOptions {
* excluded — a factory caller never sets it).
*/
meta?: { cwd?: string; parentSession?: SessionId }
/**
* Seed events to reconstruct the child session's log from (the fork lineage
* primitive). When present, the factory creates the session with this event
* prefix so `deriveMessages()`/`lastTurnNumber` continue from it — used by the
* in-process FORK subagent backend to seed a child with a balanced
* completed-turn prefix of the parent's log. The prefix MUST be contiguous
* from seq 0 and balanced (no open turn/step, no dangling tool-call), or the
* session constructor (and the dev-mode invariants replay) reject it. Absent
* for a fresh (spawn) child.
*/
seed?: SessionEvent[]
/** Per-agent options (model, system prompt). */
agentOptions?: AgentOptions
}
+3 -1
View File
@@ -5,8 +5,10 @@ The subagent seam: an agent delegating work to a child agent. Like the [bash](..
| Package | Role | ctx key |
|---|---|---|
| `subagent/` | Abstract subagent seam: named-provider registry + vocabulary | `ctx.subagents` |
| `subagent-spawn/` | In-process backend: a fresh child agent (+ the shared run driver) | (registers on `ctx.subagents`) |
| `subagent-fork/` | In-process backend: a child seeded with the parent's completed-turn prefix | (registers on `ctx.subagents`) |
| `tool-subagent/` | Model-facing `subagent` delegation tool over `ctx.subagents` | (registers on `ctx.tools`) |
The interface lives at `subagent/subagent/`. Provider implementations live in their own packages — the in-process `dsh-subagent-spawn` / `dsh-subagent-fork` and the out-of-process `dsh-subagent-acp` — plus the test-only `dsh-subagent-mock` in [support](../support/README.md). All **product** packages except the mock.
The interface lives at `subagent/subagent/`. The in-process `subagent-spawn` / `subagent-fork` backends ship here; the out-of-process `dsh-subagent-acp` and the test-only `dsh-subagent-mock` (in [support](../support/README.md)) are separate. All **product** packages except the mock.
The proposal and design rationale: [docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md](../../docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md).
+23
View File
@@ -0,0 +1,23 @@
# @deepseek-ai/dsh-subagent-fork
The in-process **fork** subagent backend: a [`SubagentProvider`](../subagent/README.md) that runs each child as a child [`Agent`](../../core/agent) **seeded with a prefix of the parent's session log** — so the child inherits the parent's conversation context instead of starting fresh. Shares the run driver (`startInProcessRun`) with [`dsh-subagent-spawn`](../subagent-spawn/README.md); the only difference is the seed.
## The seed boundary (the crux)
At the moment a subagent tool's `execute` runs, the parent's CURRENT turn is open and unbalanced: the log holds the `assistant/message` carrying this spawn's tool-call and the dangling `tool/call` with no `tool/result` yet. Seeding that raw prefix would give the child an open turn that the session constructor and the dev-mode [invariants](../../support/invariants) replay **reject**.
So the fork seeds only the **balanced completed-turn prefix** — the parent's log up to and including its last `turn/end`, excluding the in-flight turn entirely (`completedTurnPrefix`). Because the live log keeps `seq === index`, the slice is contiguous-from-0 and a valid seed. A parent on its very first (not-yet-complete) turn forks an *empty* seed — i.e. effectively a fresh child.
The seam this rides on: `CreateAgentOptions.seed` (added on `dsh-agent`, threaded through `AgentLoop.createAgent``ctx.sessions.prepare({ seed })`), the same primitive `resume` uses.
## Capabilities
`{ outputSchema: false, depthLimit: true, toolFilter: false }` — identical to spawn (the depth/model/output behavior is the shared driver's).
## Config
| Key | Meaning |
|---|---|
| `providerName` | Registry name on `ctx.subagents` (default `fork`). |
See [`dsh-subagent-spawn`](../subagent-spawn/README.md) for the run lifecycle, model inheritance, and depth tracking — all shared.
@@ -0,0 +1,45 @@
{
"name": "@deepseek-ai/dsh-subagent-fork",
"description": "In-process fork subagent backend: runs a child agent seeded with a prefix of the parent's log",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/index.d.ts",
"exports": {
".": {
"types": "./lib/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-subagent": "^0.0.1",
"@deepseek-ai/dsh-subagent-spawn": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"dependencies": {
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-subagent-spawn": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@cordisjs/plugin-loader": "^1.0.0-rc.4",
"cordis": "^4.0.0-rc.6"
}
}
@@ -0,0 +1,79 @@
/**
* The in-process FORK subagent backend: registers a {@link SubagentProvider} on
* `ctx.subagents` that runs each child as a child {@link Agent} SEEDED with a
* prefix of the parent's session log — so the child inherits the parent's
* conversation context instead of starting fresh. Shares the run driver with
* `@deepseek-ai/dsh-subagent-spawn`; the only difference is the seed.
*
* The seed boundary is the crux: at the moment a subagent tool's `execute`
* runs, the parent's CURRENT turn is open and unbalanced (it holds the
* `assistant/message` with this spawn's tool-call, plus the dangling `tool/call`
* with no `tool/result`). Seeding that raw prefix gives the child an open turn
* the session constructor and the dev-mode invariants replay REJECT. So the
* fork seeds only the **balanced completed-turn prefix**: the parent's log up
* to and including its last `turn/end`, excluding the in-flight turn entirely.
*
* Plugin export shape: named `name`/`inject`/`Config`/`apply`, NO default.
*
* @module @deepseek-ai/dsh-subagent-fork
*/
import type { Context } from 'cordis'
import z from 'schemastery'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
import { startInProcessRun } from '@deepseek-ai/dsh-subagent-spawn'
export const name = 'subagent-fork'
export const inject = ['subagents', 'agents']
/** Config: the registry name to register the provider under. */
export interface Config {
/** Provider name on `ctx.subagents` (default `fork`). */
providerName: string
}
export const Config: z<Config> = z.object({
providerName: z.string().default('fork'),
})
/**
* The balanced completed-turn prefix of `parent`'s log: every event up to and
* including the last `turn/end`. Empty if the parent has never completed a turn
* (the in-flight turn is excluded, so a parent on its very first turn forks an
* empty — i.e. fresh — child). The result is contiguous from seq 0 (the live
* log keeps `seq === index`), so it is a valid session seed; the in-flight,
* unbalanced turn is dropped so the invariants replay accepts it.
*/
export function completedTurnPrefix(parent: Agent): SessionEvent[] {
const events = parent.session.events
const lastEnd = events.findLast(e => e.type === 'turn/end')
if (lastEnd === undefined) return []
// seq === array index (the append contract), so slice up to and including it.
return events.slice(0, lastEnd.seq + 1)
}
/**
* The fork provider. Supports `depthLimit`; NOT `outputSchema`/`toolFilter` this
* cut (the service rejects a request needing either before `start` runs).
*/
class ForkProvider implements SubagentProvider {
readonly capabilities: SubagentCapabilities = { outputSchema: false, depthLimit: true, toolFilter: false }
constructor(readonly name: string, private readonly ctx: Context) {}
start(request: SubagentStartRequest) {
const seed = completedTurnPrefix(request.parent)
return startInProcessRun(this.ctx, request, {
providerName: this.name,
// Only pass a seed when there's a completed turn to inherit; an empty seed
// is equivalent to a fresh child, so omit it to keep the session unseeded.
...seed.length > 0 ? { seed } : {},
})
}
}
export function apply(ctx: Context, config: Config): void {
ctx.subagents.registerProvider(new ForkProvider(config.providerName, ctx))
}
@@ -0,0 +1,99 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import * as Invariants from '@deepseek-ai/dsh-invariants'
import SubagentService from '@deepseek-ai/dsh-subagent'
import * as Spawn from '@deepseek-ai/dsh-subagent-spawn'
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import * as fork from '../src/index.ts'
type Script = ConstructorParameters<typeof MockAdapter>[0]
/**
* The two in-process backends coexist on one context: the SAME parent agent
* delegates to a `spawn` child (fresh) and a `fork` child (seeded with its log),
* and keeps working itself. This is the multi-provider coexistence the seam
* exists for — the named registry lets one runtime hold both transports.
*/
async function setup(script: Script) {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(Invariants)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SubagentService)
await ctx.plugin(Spawn, { providerName: 'spawn' })
await ctx.plugin(fork, { providerName: 'fork' })
ctx.llm.registerAdapter(['mock'], new MockAdapter(script))
const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' })
return { ctx, parent }
}
function text(blocks: { type: string; text?: string }[]): string {
return blocks.filter(b => b.type === 'text').map(b => b.text).join('')
}
describe('multi-subagent coexistence (spawn + fork on one context)', () => {
it('both providers register and coexist', async () => {
const { ctx } = await setup([])
expect(ctx.subagents.list().sort()).toEqual(['fork', 'spawn'])
})
it('the same parent drives a spawn child AND a fork child, then keeps working', async () => {
// Script order: parent turn 1, spawn child, fork child, parent turn 2.
const { ctx, parent } = await setup([
textResponse('parent turn one'),
textResponse('spawn child reply'),
textResponse('fork child reply'),
textResponse('parent turn two'),
])
// Parent does one real turn first, so the fork has a completed turn to seed.
parent.send([{ type: 'text', text: 'parent q1' }])
await parent.whenIdle()
const parentPrefixLen = parent.session.events.length
// Delegate to a fresh spawn child.
const spawnRun = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'spawn task' }], parent })
const spawnResult = await spawnRun.result
expect(spawnResult.stopReason).toBe('completed')
expect(text(spawnResult.output)).toBe('spawn child reply')
// Delegate to a fork child (seeded with the parent's turn-1 prefix).
const forkRun = ctx.subagents.start('fork', { prompt: [{ type: 'text', text: 'fork task' }], parent })
const forkResult = await forkRun.result
expect(forkResult.stopReason).toBe('completed')
expect(text(forkResult.output)).toBe('fork child reply')
// The two children are distinct sessions, both lineage-stamped to the parent.
const spawnChild = ctx.agents.get(spawnRun.id)!
const forkChild = ctx.agents.get(forkRun.id)!
expect(spawnChild.session.header.id).not.toBe(forkChild.session.header.id)
expect(spawnChild.session.header.parentSession).toBe(parent.session.header.id)
expect(forkChild.session.header.parentSession).toBe(parent.session.header.id)
// The fork child inherited the parent's prefix; the spawn child did not.
expect(forkChild.session.events.slice(0, parentPrefixLen).some(e => e.type === 'user/message')).toBe(true)
await spawnRun.dispose()
await forkRun.dispose()
// The parent is unaffected and keeps working after both delegations.
parent.send([{ type: 'text', text: 'parent q2' }])
await parent.whenIdle()
const lastParentMessage = parent.session.events.findLast(e => e.type === 'assistant/message')
expect(lastParentMessage?.type === 'assistant/message' && text(lastParentMessage.data.content)).toBe('parent turn two')
// The parent's OWN log never recorded the children's internal steps — its
// only subagent-related entries would be tool/call+tool/result IF it had
// used the tool, but here we called the service directly, so the parent log
// is purely its own two turns.
expect(parent.session.events.filter(e => e.type === 'turn/end')).toHaveLength(2)
})
})
@@ -0,0 +1,161 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import * as Invariants from '@deepseek-ai/dsh-invariants'
import SubagentService from '@deepseek-ai/dsh-subagent'
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import * as fork from '../src/index.ts'
import { completedTurnPrefix } from '../src/index.ts'
type Script = ConstructorParameters<typeof MockAdapter>[0]
/**
* Drives the REAL fork backend with a real loop + scripted mock MODEL + the
* real dsh-invariants plugin. The invariants plugin re-replays a seeded child
* log on `session/created` (its freeze-check), so a malformed (unbalanced) fork
* seed makes these tests THROW — that is the regression guard for the
* completed-turn-prefix boundary.
*/
async function setup(script: Script) {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(Invariants)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SubagentService)
await ctx.plugin(fork, { providerName: 'fork' })
ctx.llm.registerAdapter(['mock'], new MockAdapter(script))
const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' })
return { ctx, parent }
}
function text(blocks: { type: string; text?: string }[]): string {
return blocks.filter(b => b.type === 'text').map(b => b.text).join('')
}
describe('completedTurnPrefix', () => {
it('returns an empty prefix for a parent that has never completed a turn', async () => {
const { parent } = await setup([])
expect(completedTurnPrefix(parent)).toEqual([])
})
it('returns the balanced prefix up to and including the last turn/end', async () => {
const { parent } = await setup([textResponse('first'), textResponse('second')])
parent.send([{ type: 'text', text: 'q1' }])
await parent.whenIdle()
parent.send([{ type: 'text', text: 'q2' }])
await parent.whenIdle()
const prefix = completedTurnPrefix(parent)
// Ends exactly at the last turn/end; seq is contiguous from 0.
expect(prefix.at(-1)?.type).toBe('turn/end')
expect(prefix.map(e => e.seq)).toEqual(prefix.map((_, i) => i))
// Both completed turns are present.
expect(prefix.filter(e => e.type === 'turn/end')).toHaveLength(2)
})
})
describe('dsh-subagent-fork', () => {
it('forks an UNSEEDED (fresh) child when the parent has no completed turn', async () => {
// The parent has never completed a turn → empty prefix → the provider omits
// the seed → the child runs fresh. Exercises the `seed.length > 0` false arm.
const { ctx, parent } = await setup([textResponse('fresh child')])
expect(completedTurnPrefix(parent)).toEqual([])
const run = ctx.subagents.start('fork', { prompt: [{ type: 'text', text: 'child q' }], parent })
const result = await run.result
expect(result.stopReason).toBe('completed')
expect(text(result.output)).toBe('fresh child')
const child = ctx.agents.get(run.id)!
// Only the child's own turn — no seeded parent turns.
expect(child.session.events.filter(e => e.type === 'turn/end')).toHaveLength(1)
await run.dispose()
})
it('seeds the child with the parent\'s completed-turn prefix (child inherits context)', async () => {
// Parent runs one turn, then we fork. The child's seeded log should contain
// the parent's first turn, and the child should run its own new turn on top.
const { ctx, parent } = await setup([textResponse('parent answer'), textResponse('child answer')])
parent.send([{ type: 'text', text: 'parent question' }])
await parent.whenIdle()
const parentPrefixLen = parent.session.events.length
const run = ctx.subagents.start('fork', { prompt: [{ type: 'text', text: 'child question' }], parent })
const result = await run.result
expect(result.stopReason).toBe('completed')
expect(text(result.output)).toBe('child answer')
const child = ctx.agents.get(run.id)!
// The child's log STARTS with the parent's prefix (seeded), then its own turn.
expect(child.session.events.length).toBeGreaterThan(parentPrefixLen)
// The seeded prefix carried the parent's user message.
const seededUser = child.session.events.slice(0, parentPrefixLen).find(e => e.type === 'user/message')
expect(seededUser).toBeDefined()
// Lineage stamped.
expect(child.session.header.parentSession).toBe(parent.session.header.id)
await run.dispose()
})
it('produces an invariant-CLEAN seed: forking mid-turn excludes the open turn', async () => {
// Drive the parent so it has ONE completed turn, then start a SECOND turn
// that is still open (a hanging model call), and fork while it's in flight.
// The fork must seed only the completed first turn — an unbalanced seed
// would make the invariants replay throw inside ctx.subagents.start.
const { ctx, parent } = await setup([textResponse('done'), 'hang', textResponse('child')])
parent.send([{ type: 'text', text: 'q1' }])
await parent.whenIdle()
// Start a second turn that hangs (open turn/start + open step, never ends).
parent.send([{ type: 'text', text: 'q2' }])
await new Promise(r => setTimeout(r, 20)) // let the hanging turn open
// Forking now must NOT throw (the open second turn is excluded from the seed).
const run = ctx.subagents.start('fork', { prompt: [{ type: 'text', text: 'child q' }], parent })
const result = await run.result
expect(result.stopReason).toBe('completed')
expect(text(result.output)).toBe('child')
const child = ctx.agents.get(run.id)!
// The child's seed has exactly the ONE completed parent turn (the open one excluded).
const seedTurnEnds = child.session.events.filter(e => e.type === 'turn/end')
// 1 from the seeded parent turn + 1 from the child's own completed turn.
expect(seedTurnEnds.length).toBe(2)
parent.cancel()
await run.dispose()
})
it('advertises depthLimit but not outputSchema/toolFilter', async () => {
const { ctx } = await setup([])
expect(ctx.subagents.getProvider('fork')!.capabilities).toEqual({ outputSchema: false, depthLimit: true, toolFilter: false })
})
it('unregisters the provider when its fiber is disposed (HMR safety)', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
await ctx.plugin(AgentRegistry)
const fiber = await ctx.plugin(fork, { providerName: 'fork' })
expect(ctx.subagents.list()).toEqual(['fork'])
await fiber.dispose()
expect(ctx.subagents.list()).toEqual([])
})
it('has the namespace-plugin export shape (no stray default)', () => {
expect('default' in fork).toBe(false)
expect(fork.name).toBe('subagent-fork')
expect(fork.inject).toEqual(['subagents', 'agents'])
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(fork) as Record<string, unknown>
expect(unwrapped).toBe(fork)
expect(unwrapped.name).toBe('subagent-fork')
expect(unwrapped.inject).toEqual(['subagents', 'agents'])
expect(typeof unwrapped.apply).toBe('function')
})
})
@@ -0,0 +1,33 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../core/agent"
},
{
"path": "../../core/session"
},
{
"path": "../subagent"
},
{
"path": "../subagent-spawn"
}
]
}
@@ -0,0 +1,29 @@
# @deepseek-ai/dsh-subagent-spawn
The in-process **spawn** subagent backend: a [`SubagentProvider`](../subagent/README.md) that runs each child as a **fresh** child [`Agent`](../../core/agent) on the same cordis context (`ctx.agents`) — its own session, its own (or the parent's) model, zero inherited conversation. The cheapest transport, reusing the agent factory's quiescent [`AgentHandle`](../../core/agent) teardown.
It also exports the **shared in-process run driver** (`startInProcessRun`) that the [fork](../subagent-fork/README.md) backend builds on — spawn and fork differ only in the session seed.
## What it does
`start(request)`
1. computes child depth = `depthOf(parent) + 1`; if `request.maxDepth` is set and exceeded, throws `SubagentDepthError` (the `depthLimit` capability);
2. creates a child via `ctx.agents.create` with a fresh `AgentId`/`SessionId`, the parent's `cwd` + `parentSession` lineage, and `agentOptions` (the child inherits the **parent's model** by default — a child with no model can't run — overridable via `request.agentOptions.model`; the system prompt is NOT inherited);
3. drives the one-shot: `child.send(prompt)` then `await child.whenIdle()` (ordering matters — `send` enqueues synchronously, so `whenIdle` observes the queued work and resolves on the child's `running → idle` transition, never before the turn starts);
4. reads the result: the last `assistant/message` content (deep-cloned — the log is frozen) and the last `turn/end.reason` mapped to a `SubagentStopReason`.
`dispose()` delegates to `AgentHandle.dispose()` (stop loop → await quiescence → remove session); `cancel()` cancels the child's in-flight turn.
## Capabilities
`{ outputSchema: false, depthLimit: true, toolFilter: false }`. It constructs the child, so it enforces a recursion cap; structured output and tool-scoping are deferred (the service rejects a request needing either before `start` runs).
## Config
| Key | Meaning |
|---|---|
| `providerName` | Registry name on `ctx.subagents` (default `spawn`). |
## Depth tracking
Delegation depth rides on a merge-extensible `AgentOptions.subagentDepth` field (0 for a top-level agent, parent + 1 for a child), so a nested spawn reads its parent's depth from `parent.options.subagentDepth`. Read it with the exported `depthOf(agent)`.
@@ -0,0 +1,48 @@
{
"name": "@deepseek-ai/dsh-subagent-spawn",
"description": "In-process spawn subagent backend: runs a fresh child agent on ctx.agents",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/index.d.ts",
"exports": {
".": {
"types": "./lib/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-subagent": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"dependencies": {
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-bash-local": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tool-bash": "workspace:^",
"@deepseek-ai/dsh-tool-subagent": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@cordisjs/plugin-loader": "^1.0.0-rc.4",
"cordis": "^4.0.0-rc.6"
}
}
@@ -0,0 +1,164 @@
/**
* The shared in-process subagent run driver. A subagent backend that runs the
* child as a child {@link Agent} on the SAME cordis context (`ctx.agents`) —
* the cheapest transport, reusing the agent factory's quiescent
* {@link AgentHandle} teardown. Both in-process backends use this:
* `@deepseek-ai/dsh-subagent-spawn` (a fresh child) and
* `@deepseek-ai/dsh-subagent-fork` (a child seeded with a prefix of the
* parent's log) differ ONLY in the `seed` they pass — everything downstream
* (drive the child, read its final output, map the stop reason, dispose) is
* identical and lives here.
*
* @module @deepseek-ai/dsh-subagent-spawn/in-process
*/
import { randomUUID } from 'node:crypto'
import type { Context } from 'cordis'
import { AgentId, type Agent, type AgentHandle, type AgentOptions } from '@deepseek-ai/dsh-agent'
import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent'
declare module '@deepseek-ai/dsh-agent' {
interface AgentOptions {
/**
* The agent's delegation depth in the subagent tree — 0 for a top-level
* (config/ACP-created) agent, parent depth + 1 for a subagent. Set by the
* in-process backends on every child they create so a nested spawn reads its
* parent's depth from `parent.options.subagentDepth` and the `depthLimit`
* capability can cap the tree. Merge-extensible field (the seam owns it; the
* loop neither sets nor reads it).
*/
subagentDepth?: number
}
}
/** Read an agent's delegation depth (absent ⇒ a top-level agent, depth 0). */
export function depthOf(agent: Agent): number {
return agent.options.subagentDepth ?? 0
}
/** Thrown when a spawn would exceed the request's `maxDepth` cap. */
export class SubagentDepthError extends Error {
constructor(public readonly attemptedDepth: number, public readonly maxDepth: number) {
super(`subagent depth ${attemptedDepth} exceeds maxDepth ${maxDepth}`)
this.name = 'SubagentDepthError'
}
}
/** Map a session `turn/end` reason to a {@link SubagentStopReason}. */
function toStopReason(reason: TurnEndReason | undefined): SubagentStopReason {
switch (reason?.kind) {
case 'completed':
return 'completed'
case 'max-tokens':
return 'max-tokens'
case 'aborted':
return 'aborted'
// `disposed` (torn down mid-turn) and `interrupted` (crash-closed) both mean
// the turn did not finish cleanly; surface them as a generic failure rather
// than a clean completion. A missing reason (no turn ran) is also an error.
case 'error':
case 'disposed':
case 'interrupted':
default:
return 'error'
}
}
/** Extra inputs the spawn/fork backends supply to {@link startInProcessRun}. */
export interface InProcessRunOptions {
/** The provider name (`spawn`/`fork`), for error context only. */
readonly providerName: string
/**
* The child session's seed: a balanced, contiguous-from-0 prefix of the
* parent's log (FORK), or `undefined` for a fresh child (SPAWN).
*/
readonly seed?: SessionEvent[]
}
/**
* Start an in-process child agent for `request` and return a {@link SubagentRun}.
*
* Drives the child as a one-shot: `send(prompt)` then `whenIdle()` (the ordering
* matters — `send` enqueues synchronously, so `whenIdle` observes the queued
* work and resolves only on the child's `running → idle` transition, never
* before the turn starts). The final `assistant/message` is the result output,
* the matching `turn/end.reason` the stop reason. `dispose()` delegates to the
* factory's {@link AgentHandle.dispose} (stop loop → await quiescence → remove
* session); `cancel()` cancels the child's in-flight turn.
*/
export function startInProcessRun(
ctx: Context,
request: SubagentStartRequest,
options: InProcessRunOptions,
): SubagentRun {
const childDepth = depthOf(request.parent) + 1
if (request.maxDepth !== undefined && childDepth > request.maxDepth) {
throw new SubagentDepthError(childDepth, request.maxDepth)
}
const childId = AgentId(randomUUID())
const parentHeader = request.parent.session.header
// Inherit the parent's model by default (a child with no model cannot run);
// an explicit `request.agentOptions.model` overrides it. The parent's
// systemPrompt is NOT inherited — a fresh child is a clean specialist unless
// the caller supplies one.
const agentOptions: AgentOptions = {
...request.parent.options.model !== undefined ? { model: request.parent.options.model } : {},
...request.agentOptions,
subagentDepth: childDepth,
}
const handle: AgentHandle = ctx.agents.create({
agentId: childId,
sessionId: SessionId(randomUUID()),
meta: {
...parentHeader.cwd !== undefined ? { cwd: parentHeader.cwd } : {},
parentSession: parentHeader.id,
},
...options.seed !== undefined ? { seed: options.seed } : {},
agentOptions,
})
const child = handle.agent
// Bridge the request's abort signal to the child (the consumer also bridges
// its own exec.signal, but a backend-level bridge keeps the contract local).
const onAbort = (): void => { child.cancel('subagent cancelled') }
request.signal?.addEventListener('abort', onAbort, { once: true })
const result: Promise<SubagentResult> = (async () => {
try {
child.send(request.prompt)
await child.whenIdle()
return readResult(child)
} finally {
request.signal?.removeEventListener('abort', onAbort)
}
})()
return {
id: childId,
result,
cancel(reason?: string): void {
child.cancel(reason ?? 'subagent cancelled')
},
async dispose(): Promise<void> {
request.signal?.removeEventListener('abort', onAbort)
await handle.dispose()
},
}
}
/**
* Read a settled child's terminal result from its session log: the last
* `assistant/message` content (deep-cloned — the log is frozen) and the last
* `turn/end` reason mapped to a {@link SubagentStopReason}.
*/
function readResult(child: Agent): SubagentResult {
const events = child.session.events
const lastMessage = events.findLast((e): e is SessionEvent<'assistant/message'> => e.type === 'assistant/message')
const lastEnd = events.findLast((e): e is SessionEvent<'turn/end'> => e.type === 'turn/end')
const output: ContentBlock[] = lastMessage ? structuredClone(lastMessage.data.content) : []
return { output, stopReason: toStopReason(lastEnd?.data.reason) }
}
@@ -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))
}
@@ -0,0 +1,49 @@
import { Context } from 'cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import SubagentService from '@deepseek-ai/dsh-subagent'
import * as Spawn from '../src/index.ts'
import * as ToolSubagent from '@deepseek-ai/dsh-tool-subagent'
/**
* Shared harness for the spawn-backend e2e: the full real stack (DeepSeek
* adapter + real bash tool + the subagent tool bound to the spawn backend), so
* a real parent agent can delegate to a real in-process child that does real
* work (writes a file). Lives outside the *.e2e.ts pattern so importing it never
* re-registers another file's tests.
*/
export async function spawnHarness(workdir: string): Promise<Context> {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LlmDeepSeek, { models: ['deepseek-v4-flash'] })
await ctx.plugin(LocalBashExecutor, { cwd: workdir, timeoutMs: 30_000 })
await ctx.plugin(ToolBash)
await ctx.plugin(SubagentService)
await ctx.plugin(Spawn, { providerName: 'spawn' })
// The model-facing subagent tool, bound to the spawn backend.
await ctx.plugin(ToolSubagent, { provider: 'spawn' })
return ctx
}
export function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') {
dispose()
resolve()
}
})
})
}
@@ -0,0 +1,54 @@
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import type { Context } from 'cordis'
import { AgentId } from '@deepseek-ai/dsh-agent'
import { spawnHarness, waitForIdle } from './harness.ts'
/**
* With-key smoke for the in-process spawn backend: a REAL parent agent delegates
* to a REAL child (via the `subagent` tool → spawn backend) that uses the REAL
* bash tool to write a file, and we verify the WORLD (the file on disk) — not
* the agent's self-report. This is the "green units, broken product" guard:
* mocks prove the plumbing, only a real model proves a parent can actually drive
* a child to do real work. Key-gated (self-skips without DEEPSEEK_API_KEY).
*/
let ctx: Context | undefined
let workdir: string | undefined
afterEach(async () => {
await ctx?.fiber.dispose()
ctx = undefined
if (workdir !== undefined) await rm(workdir, { recursive: true, force: true })
workdir = undefined
})
describe.skipIf(!process.env.DEEPSEEK_API_KEY)('spawn backend with-key smoke', () => {
it('a parent delegates to a child that writes a file on disk', async () => {
workdir = await mkdtemp(join(tmpdir(), 'dsh-subagent-spawn-e2e-'))
ctx = await spawnHarness(workdir)
const parent = ctx.agentLoop.create(AgentId('e2e-parent'), {
model: 'deepseek-v4-flash',
systemPrompt: 'You are an orchestrator. To do file work, delegate to a subagent with the `subagent` tool — '
+ 'give it a complete, standalone instruction. Report only when done.',
})
parent.send([{ type: 'text', text:
'Use the subagent tool to delegate this exact task: "Use the bash tool to write the text '
+ 'SUBAGENT_WAS_HERE into a file named proof.txt in the current directory." '
+ 'After the subagent finishes, tell me it is done.' }])
await waitForIdle(ctx, parent)
// Verify the WORLD: the child actually wrote the file.
const proof = await readFile(join(workdir, 'proof.txt'), 'utf8')
expect(proof).toContain('SUBAGENT_WAS_HERE')
// The parent's log records the subagent tool/call + its result (not the
// child's internal steps).
const events = [...parent.session.events]
const subagentCalls = events.filter(e => e.type === 'tool/call' && e.data.name === 'subagent')
expect(subagentCalls.length).toBeGreaterThan(0)
}, 180_000)
})
@@ -0,0 +1,239 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import * as Invariants from '@deepseek-ai/dsh-invariants'
import SubagentService from '@deepseek-ai/dsh-subagent'
import { MockAdapter, maxTokensResponse, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import * as spawn from '../src/index.ts'
import { depthOf, SubagentDepthError } from '../src/in-process.ts'
type Script = ConstructorParameters<typeof MockAdapter>[0]
/**
* Drives the REAL spawn backend end-to-end: a real agent loop + a scripted mock
* MODEL (the only mocked boundary) + the real SubagentService + the real
* dsh-invariants plugin (so a malformed child session log would fail the test).
* The parent is a real config agent; the spawn provider creates a real child
* agent on the same context and we assert its output.
*/
async function setup(script: Script) {
const ctx = new Context()
const adapter = new MockAdapter(script)
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(Invariants)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SubagentService)
await ctx.plugin(spawn, { providerName: 'spawn' })
ctx.llm.registerAdapter(['mock'], adapter)
const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' })
return { ctx, parent, adapter }
}
function text(blocks: { type: string; text?: string }[]): string {
return blocks.filter(b => b.type === 'text').map(b => b.text).join('')
}
describe('dsh-subagent-spawn', () => {
it('runs a fresh child to completion and returns its final assistant output', async () => {
// One model call for the child: a plain text answer.
const { ctx, parent } = await setup([textResponse('child answer')])
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'do X' }], parent })
const result = await run.result
expect(result.stopReason).toBe('completed')
expect(text(result.output)).toBe('child answer')
await run.dispose()
})
it('gives the child its OWN session (not the parent\'s), with parentSession lineage', async () => {
const { ctx, parent } = await setup([textResponse('hi')])
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
await run.result
const child = ctx.agents.get(run.id)!
expect(child.session.header.id).not.toBe(parent.session.header.id)
expect(child.session.header.parentSession).toBe(parent.session.header.id)
await run.dispose()
})
it('a fresh child does NOT inherit the parent conversation (its log starts empty before the prompt)', async () => {
// Drive the parent through one real turn so it has history, THEN spawn.
const { ctx, parent } = await setup([textResponse('parent turn'), textResponse('child sees nothing')])
parent.send([{ type: 'text', text: 'parent prompt' }])
await parent.whenIdle()
const parentEventCount = parent.session.events.length
expect(parentEventCount).toBeGreaterThan(0)
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'child prompt' }], parent })
await run.result
const child = ctx.agents.get(run.id)!
// The child's first user/message is its OWN prompt, not the parent's history.
const firstUser = child.session.events.find(e => e.type === 'user/message')
expect(firstUser).toBeDefined()
await run.dispose()
})
it('disposes the child to quiescence (agent removed from the registry)', async () => {
const { ctx, parent } = await setup([textResponse('x')])
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
await run.result
expect(ctx.agents.get(run.id)).toBeDefined()
await run.dispose()
// After dispose, the child is unregistered (the AgentHandle teardown ran).
expect(ctx.agents.get(run.id)).toBeUndefined()
})
it('stamps child depth = parent depth + 1 (via the merged AgentOptions field)', async () => {
const { ctx, parent } = await setup([textResponse('x')])
expect(depthOf(parent)).toBe(0)
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
await run.result
const child = ctx.agents.get(run.id)!
expect(depthOf(child)).toBe(1)
await run.dispose()
})
it('refuses to spawn past maxDepth (depthLimit capability)', async () => {
const { ctx, parent } = await setup([])
// parent is depth 0, child would be depth 1 — cap at 0 forbids any child.
expect(() => ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent, maxDepth: 0 }))
.toThrow(SubagentDepthError)
})
it('maps a child that hit its token ceiling to stopReason "max-tokens"', async () => {
const { ctx, parent } = await setup([maxTokensResponse('cut off')])
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
const result = await run.result
expect(result.stopReason).toBe('max-tokens')
await run.dispose()
})
it('maps a child whose turn errored (script exhausted) to stopReason "error" with empty output', async () => {
// Empty script: the child's first model call throws "script exhausted", the
// turn ends `error`, and there is no assistant/message → empty output.
const { ctx, parent } = await setup([])
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
const result = await run.result
expect(result.stopReason).toBe('error')
expect(result.output).toEqual([])
await run.dispose()
})
it('cancelling a running child settles the run as aborted (the abort bridge + cancel())', async () => {
// 'hang' makes the child's model stream one chunk then wait until aborted.
const controller = new AbortController()
const { ctx, parent } = await setup(['hang'])
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent, signal: controller.signal })
// Let the child's turn start, then abort via the request signal (the
// backend bridges it to child.cancel()).
await new Promise(r => setTimeout(r, 30))
controller.abort()
const result = await run.result
expect(result.stopReason).toBe('aborted')
await run.dispose()
})
it('run.cancel() also cancels the child directly', async () => {
const { ctx, parent } = await setup(['hang'])
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
await new Promise(r => setTimeout(r, 30))
run.cancel('test cancel')
const result = await run.result
expect(result.stopReason).toBe('aborted')
await run.dispose()
})
it('run.cancel() with no reason uses the default cancel reason', async () => {
const { ctx, parent } = await setup(['hang'])
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
await new Promise(r => setTimeout(r, 30))
run.cancel()
const result = await run.result
expect(result.stopReason).toBe('aborted')
await run.dispose()
})
it('does not expose the optional runtime methods (sendMessage/resume) in this cut', async () => {
const { ctx, parent } = await setup([textResponse('x')])
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
expect('sendMessage' in run).toBe(false)
expect('resume' in run).toBe(false)
await run.result
await run.dispose()
})
it('inherits the parent cwd into the child session', async () => {
const { ctx } = await setup([textResponse('x')])
// A parent WITH a cwd (config agents have none, so create one explicitly).
const parentHandle = ctx.agents.create({
agentId: AgentId('cwd-parent'),
sessionId: SessionId('cwd-parent-session'),
meta: { cwd: '/tmp/parent-workspace' },
agentOptions: { model: 'mock' },
})
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent: parentHandle.agent })
await run.result
const child = ctx.agents.get(run.id)!
expect(child.session.header.cwd).toBe('/tmp/parent-workspace')
await run.dispose()
await parentHandle.dispose()
})
it('uses request.agentOptions.model when the parent has no model of its own', async () => {
const { ctx } = await setup([textResponse('explicit model child')])
// A parent with NO model (its own turns would need one supplied per-request).
const parentHandle = ctx.agents.create({
agentId: AgentId('modelless-parent'),
sessionId: SessionId('modelless-parent-session'),
agentOptions: {},
})
// The request supplies the child's model explicitly.
const run = ctx.subagents.start('spawn', {
prompt: [{ type: 'text', text: 'p' }],
parent: parentHandle.agent,
agentOptions: { model: 'mock' },
})
const result = await run.result
expect(result.stopReason).toBe('completed')
expect(text(result.output)).toBe('explicit model child')
await run.dispose()
await parentHandle.dispose()
})
it('advertises depthLimit but not outputSchema/toolFilter', async () => {
const { ctx } = await setup([])
const provider = ctx.subagents.getProvider('spawn')!
expect(provider.capabilities).toEqual({ outputSchema: false, depthLimit: true, toolFilter: false })
})
it('unregisters the provider when its fiber is disposed (HMR safety)', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
await ctx.plugin(AgentRegistry)
const fiber = await ctx.plugin(spawn, { providerName: 'spawn' })
expect(ctx.subagents.list()).toEqual(['spawn'])
await fiber.dispose()
expect(ctx.subagents.list()).toEqual([])
})
it('has the namespace-plugin export shape (no stray default)', () => {
expect('default' in spawn).toBe(false)
expect(spawn.name).toBe('subagent-spawn')
expect(spawn.inject).toEqual(['subagents', 'agents'])
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(spawn) as Record<string, unknown>
expect(unwrapped).toBe(spawn)
expect(unwrapped.name).toBe('subagent-spawn')
expect(unwrapped.inject).toEqual(['subagents', 'agents'])
expect(typeof unwrapped.apply).toBe('function')
})
})
@@ -0,0 +1,33 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../core/agent"
},
{
"path": "../../llm/llm"
},
{
"path": "../../core/session"
},
{
"path": "../subagent"
}
]
}
+10
View File
@@ -211,6 +211,16 @@ async function* replayEntry(entry: ReplayEntry, signal: AbortSignal | undefined)
* the snapshot harness runs one ACP session per scenario to guarantee that. The
* cursor is advanced synchronously at listener-invocation time (not lazily
* inside the generator) so call ORDER, not iteration order, fixes the mapping.
*
* TODO(subagent-snapshots): this single global cursor cannot route calls to the
* right agent when a parent and an in-process subagent both stream on one ctx.
* Snapshot coverage of nested agents needs either per-session-keyed replay (a
* `Map<sessionId, cursor>` fed by the calling agent on the `agent/request`
* waterfall, which carries the agent) or a call-ordered merge of the parent and
* child session logs (sound because subagent execution is strictly nested —
* the parent blocks on the child). Tracked as a stacked follow-up to the
* in-process subagent backends; see the subagent RFC's "Snapshot coverage of
* nested agents" deferral.
*/
export function installLlmReplay(ctx: Context, config: ReplayConfig): () => void {
const entries = loadReplayScript(config)
+89
View File
@@ -333,6 +333,95 @@ importers:
specifier: ^4.0.0-rc.6
version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
packages/subagent/subagent-fork:
dependencies:
schemastery:
specifier: ^3.18.0
version: 3.18.0
devDependencies:
'@cordisjs/plugin-loader':
specifier: ^1.0.0-rc.4
version: 1.0.0-rc.4(cordis@4.0.0-rc.6)
'@deepseek-ai/dsh-agent':
specifier: workspace:^
version: link:../../core/agent
'@deepseek-ai/dsh-agent-loop':
specifier: workspace:^
version: link:../../core/agent-loop
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
version: link:../../support/invariants
'@deepseek-ai/dsh-llm':
specifier: workspace:^
version: link:../../llm/llm
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
'@deepseek-ai/dsh-subagent':
specifier: workspace:^
version: link:../subagent
'@deepseek-ai/dsh-subagent-spawn':
specifier: workspace:^
version: link:../subagent-spawn
'@deepseek-ai/dsh-system-prompt':
specifier: workspace:^
version: link:../../core/system-prompt
'@deepseek-ai/dsh-tools':
specifier: workspace:^
version: link:../../core/tools
cordis:
specifier: ^4.0.0-rc.6
version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
packages/subagent/subagent-spawn:
dependencies:
schemastery:
specifier: ^3.18.0
version: 3.18.0
devDependencies:
'@cordisjs/plugin-loader':
specifier: ^1.0.0-rc.4
version: 1.0.0-rc.4(cordis@4.0.0-rc.6)
'@deepseek-ai/dsh-agent':
specifier: workspace:^
version: link:../../core/agent
'@deepseek-ai/dsh-agent-loop':
specifier: workspace:^
version: link:../../core/agent-loop
'@deepseek-ai/dsh-bash-local':
specifier: workspace:^
version: link:../../bash/bash-local
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
version: link:../../support/invariants
'@deepseek-ai/dsh-llm':
specifier: workspace:^
version: link:../../llm/llm
'@deepseek-ai/dsh-llm-deepseek':
specifier: workspace:^
version: link:../../llm/llm-deepseek
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
'@deepseek-ai/dsh-subagent':
specifier: workspace:^
version: link:../subagent
'@deepseek-ai/dsh-system-prompt':
specifier: workspace:^
version: link:../../core/system-prompt
'@deepseek-ai/dsh-tool-bash':
specifier: workspace:^
version: link:../../bash/tool-bash
'@deepseek-ai/dsh-tool-subagent':
specifier: workspace:^
version: link:../tool-subagent
'@deepseek-ai/dsh-tools':
specifier: workspace:^
version: link:../../core/tools
cordis:
specifier: ^4.0.0-rc.6
version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
packages/subagent/tool-subagent:
dependencies:
schemastery:
+3 -1
View File
@@ -34,6 +34,8 @@
{ "path": "./packages/support/llm-replay" },
{ "path": "./packages/subagent/subagent" },
{ "path": "./packages/support/subagent-mock" },
{ "path": "./packages/subagent/tool-subagent" }
{ "path": "./packages/subagent/tool-subagent" },
{ "path": "./packages/subagent/subagent-spawn" },
{ "path": "./packages/subagent/subagent-fork" }
]
}