Преглед изворни кода

Add subagent capability seam: interface, mock backend, model-facing tool

Introduce the `packages/subagent/` group and the abstract subagent seam — an
agent delegating to a child agent — as a named-provider registry (`ctx.subagents`),
unlike the single-implementation bash seam, so multiple transports (in-process,
ACP, future A2A) coexist. This first PR lands the interface, a scripted test
backend, and the model-facing tool, validated through the real cordis load path.

- dsh-subagent: SubagentService registry + SubagentProvider/SubagentRun
  vocabulary + subagent/start|end events. Start-time capabilities (outputSchema,
  depthLimit, toolFilter) are checked pre-start and rejected loud; runtime
  capabilities (sendMessage, resume) are optional methods on SubagentRun.
- dsh-subagent-mock (support): scripted provider for keyless, deterministic
  tests through the real Loader/export path.
- dsh-tool-subagent: the model-facing `subagent` tool, config-bound to one
  provider; synchronous collect with try/finally dispose, signal->cancel
  bridging, and non-completed-stop-reason -> isError mapping.
- Proposed RFC documenting the seam, the fork-vs-spawn-as-separate-backends
  decision, own-session isolation, synchronous-collect scope, and the deferral
  of background/poll/spill to a future unification with bash.
- Wire the new group into tsconfigs, build refs, package hierarchy docs, the
  module graph, and the cordis catalog.

RFC: docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md
Tianyi Cui пре 3 месеци
родитељ
комит
1a81f2cccd

+ 37 - 2
docs/cordis-catalog/events-and-services.md

@@ -11,7 +11,7 @@ The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary
 
 ## Events
 
-Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `next()` and may transform or veto — see [waterfall semantics](../architecture.md#cordis-waterfall-semantics-important)), **parallel** (awaited fan-out, no veto). The harness declares 22 events across 5 scopes.
+Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `next()` and may transform or veto — see [waterfall semantics](../architecture.md#cordis-waterfall-semantics-important)), **parallel** (awaited fan-out, no veto). The harness declares 24 events across 6 scopes.
 
 ### `agent/*`
 
@@ -231,6 +231,28 @@ Awaited durability checkpoint. The agent loop awaits `ctx.parallel('session/flus
 
 Source: [`packages/core/session/src/index.ts:45`](../../packages/core/session/src/index.ts)
 
+### `subagent/*`
+
+#### `subagent/end` — emit
+
+A subagent run settled — emitted when SubagentRun.result resolves (any stop reason). Paired with Events['subagent/start'].
+
+```ts cordis-catalog
+'subagent/end'(info: SubagentRunEndInfo): void
+```
+
+Source: [`packages/subagent/subagent/src/index.ts:65`](../../packages/subagent/subagent/src/index.ts)
+
+#### `subagent/start` — emit
+
+A subagent run started — emitted after the provider is resolved and its capabilities validated, as the child run begins. Paired with Events['subagent/end'].
+
+```ts cordis-catalog
+'subagent/start'(info: SubagentRunInfo): void
+```
+
+Source: [`packages/subagent/subagent/src/index.ts:59`](../../packages/subagent/subagent/src/index.ts)
+
 ### `system-prompt/*`
 
 #### `system-prompt/assemble` — waterfall
@@ -279,7 +301,7 @@ Source: [`packages/core/tools/src/index.ts:43`](../../packages/core/tools/src/in
 
 ## Services
 
-The 8 `ctx.<key>` services the harness provides. An abstract seam (e.g. `ctx.bash`) is implemented by a separate package; the interface is what consumers code against.
+The 9 `ctx.<key>` services the harness provides. An abstract seam (e.g. `ctx.bash`) is implemented by a separate package; the interface is what consumers code against.
 
 ### `ctx.agentLoop` — `AgentLoop`
 
@@ -392,6 +414,19 @@ list(): Session[]
 
 Source: [`packages/core/session/src/index.ts:229`](../../packages/core/session/src/index.ts)
 
+### `ctx.subagents` — `SubagentService`
+
+The `subagents` service: a registry of named SubagentProviders and a capability-checked start surface.
+
+```ts cordis-catalog
+registerProvider(provider: SubagentProvider): () => void
+getProvider(name: string): SubagentProvider | undefined
+list(): string[]
+start(name: string, request: SubagentStartRequest): SubagentRun
+```
+
+Source: [`packages/subagent/subagent/src/index.ts:103`](../../packages/subagent/subagent/src/index.ts)
+
 ### `ctx.systemPrompt` — `SystemPrompt`
 
 Registry service (`ctx.systemPrompt`): plugins contribute ordered text sections and tool-schema providers; the agent loop calls `assemble()` once per step.

+ 13 - 0
docs/module-graph.md

@@ -45,6 +45,9 @@ graph TD
   agent-loop --> session-persistence
   agent-loop --> system-prompt
   agent-loop --> tools
+  subagent --> agent
+  subagent --> llm
+  subagent --> tools
   tool-bash --> agent
   tool-bash --> bash
   tool-bash --> llm
@@ -57,6 +60,13 @@ graph TD
   agent-core --> system-prompt
   agent-core --> tool-bash
   agent-core --> tools
+  subagent-mock --> agent
+  subagent-mock --> llm
+  subagent-mock --> subagent
+  tool-subagent --> agent
+  tool-subagent --> llm
+  tool-subagent --> subagent
+  tool-subagent --> tools
   acp-agent --> acp
   acp-agent --> agent-core
   acp-agent --> session-persistence-jsonl
@@ -87,7 +97,10 @@ graph TD
 | `ui-stdio` | `agent`, `llm`, `session` |
 | `acp` | `agent`, `llm`, `session`, `session-persistence`, `tools` |
 | `agent-loop` | `agent`, `llm`, `session`, `session-persistence`, `system-prompt`, `tools` |
+| `subagent` | `agent`, `llm`, `tools` |
 | `tool-bash` | `agent`, `bash`, `llm`, `tools` |
 | `agent-core` | `agent`, `agent-loop`, `invariants`, `llm`, `session`, `system-prompt`, `tool-bash`, `tools` |
+| `subagent-mock` | `agent`, `llm`, `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` |

+ 1 - 0
docs/rfc/README.md

@@ -44,6 +44,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r
 | [Agent Client Protocol (ACP) support for external editors](proposed/feature/2026-06-14-acp-agent-client-protocol.md) | 2026-06-14 |
 | [Multiplex concurrent ACP sessions over one connection](proposed/feature/2026-06-14-acp-multi-session.md) | 2026-06-14 |
 | [Optional Code Mode — model writes TypeScript against an SDK of all tools](proposed/feature/2026-06-15-optional-code-mode.md) | 2026-06-15 |
+| [Subagent capability seam](proposed/feature/2026-06-21-subagent-capability-seam.md) | 2026-06-21 |
 
 ### Simplification
 

+ 72 - 0
docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md

@@ -0,0 +1,72 @@
+# RFC: Subagent capability seam
+
+Status: proposed
+
+> **Implementation status:** PR1 (this proposal + the `dsh-subagent` interface, the `dsh-subagent-mock` test backend, and the `dsh-tool-subagent` consumer) is the first of three PRs. The two in-process backends (`dsh-subagent-spawn`, `dsh-subagent-fork`) and the out-of-process `dsh-subagent-acp` backend land in PR2 and PR3. Status stays `proposed` until all three ship; the file moves to `implemented/feature/` then, amended to describe what actually landed.
+
+## Problem
+
+The harness has a long-deferred seam for **subagents** — an agent delegating work to another agent. The intent is sketched in two `TODO(sub-agents)` markers ([packages/core/agent/src/types.ts](../../../../packages/core/agent/src/types.ts), [packages/core/agent-loop/src/index.ts](../../../../packages/core/agent-loop/src/index.ts)): a creation option referencing a parent agent (fork = seed the child session with the parent's event log; spawn = fresh session), with the child returned as an `Agent` handle so steering and event subscription work uniformly. No service, vocabulary, or implementation exists yet.
+
+The distinctive requirement — the one that shapes the whole design — is that **multiple subagent implementations must coexist at runtime**. A parent may want a cheap in-process child for a scoped subtask AND an isolated out-of-process child (over ACP) in the same session. The transports we foresee:
+
+- **in-process** — a child `ReactLoopAgent` on the same `Context` (the cheapest, and nearly free given the existing agent factory);
+- **ACP** — act as an ACP *client* driving another agent process (which can be another instance of ourselves);
+- later: **A2A**, the **Codex app-server**, and the **Claude Code Agent SDK** — each the same out-of-process "start a child, prompt it, stream updates, cancel" shape as the ACP backend.
+
+## Why not the bash seam shape
+
+The bash seam ([capability seams](../../implemented/architecture/2026-06-13-capability-seams.md)) registers exactly one `BashExecutor` per context; loading a second throws. That is correct for bash (one machine, one way to run a command) but wrong here: coexistence is the requirement. So the subagent service is a **named-provider registry** — each implementation registers under a unique name and a caller picks one by name — mirroring the **LLM adapter registry** (`LlmService.registerAdapter`), not the single-service bash executor. The seam is still three-package (interface / implementation / consumer); only the "one vs. many implementations" axis differs.
+
+## Proposal
+
+### The three-package seam
+
+A new package group `packages/subagent/`:
+
+| Package | Role |
+|---|---|
+| `@deepseek-ai/dsh-subagent` | interface: `SubagentService` (`ctx.subagents`), `SubagentProvider`, `SubagentRun`, the request/result/capability vocabulary, the `subagent/*` events |
+| `@deepseek-ai/dsh-subagent-spawn` | implementation: a fresh in-process child via `ctx.agents.create` (PR2) |
+| `@deepseek-ai/dsh-subagent-fork` | implementation: an in-process child seeded with a snapshot of the parent's log (PR2) |
+| `@deepseek-ai/dsh-subagent-acp` | implementation: an ACP client driving a configured child process (PR3) |
+| `@deepseek-ai/dsh-subagent-mock` | support: a scripted provider for testing the seam through the real load path (PR1) |
+| `@deepseek-ai/dsh-tool-subagent` | consumer: the model-facing `subagent` tool over `ctx.subagents` (PR1) |
+
+### The primitive: `start → SubagentRun`
+
+A provider exposes `start(request) → SubagentRun`. The run carries a `result` promise (the terminal `SubagentResult`), `cancel()`, and `dispose()`. The transport-neutral verb is **`start`**; "spawn" is reserved for the in-process `dsh-subagent-spawn` backend's identity, not the service verb. The service's `start(name, request)` resolves the named provider, validates capabilities, delegates, and emits `subagent/start` / `subagent/end` around the run.
+
+### Two kinds of optional capability, discovered two ways
+
+- **Start-time features** (`outputSchema`, `depthLimit`, `toolFilter`) ride on a static `provider.capabilities` descriptor. The service checks every requested one BEFORE delegating and **rejects loud** (`SubagentError('UNSUPPORTED_CAPABILITY')`) if the provider lacks it — never accepted-then-ignored. They must be checked before a run exists, which is why they cannot be runtime methods.
+- **Runtime features** (steering via `sendMessage`, follow-up via `resume`) are **optional methods** on `SubagentRun`. The method's presence IS the capability, and TypeScript narrowing is the discovery mechanism: a consumer cannot call an absent method without narrowing first, so there is no silent-degradation path and no separate flags object to keep in sync.
+
+### Fork vs. fresh are separate backends, not a flag
+
+Rather than a `context: 'fresh' | 'fork'` request field, the distinction is the provider's identity: `dsh-subagent-spawn` (fresh, isolated, own system prompt) and `dsh-subagent-fork` (seeded from the parent's log) are two registered providers. You pick behavior by picking a provider — consistent with the registry being the selection mechanism.
+
+### Child isolation and the parent log
+
+Each subagent runs in its **own `Session`** (own id, `parentSession` lineage), persisted independently. The parent's log records only the spawn `tool/call` and its `tool/result` (the child's final output) — the child's internal steps and tool calls stay in the child's own session, never injected into the parent log. This is the only design that is identical across transports: an ACP child's internal events physically cannot be injected into our parent log, so making in-process behave the same keeps the seam transport-agnostic.
+
+### Synchronous collect (first cut)
+
+The `dsh-tool-subagent` consumer awaits `run.result` and returns the child's final output as the tool result, blocking the parent's turn until the child finishes. It does so inside a `try/finally` that always `dispose()`s the run (no leaked idle child/session on any path), bridges `exec.signal` to `run.cancel()`, and maps a non-`completed` stop reason to an `isError` result rather than returning partial output as success. Steering (`sendMessage`) is part of the contract but **intentionally unused** this cut.
+
+### Provider selection is config, not model-facing
+
+`dsh-tool-subagent` binds to exactly one provider name (`Config.provider`); the model sees only `{ description, prompt }`. To expose more than one transport, load the tool plugin more than once, each bound to a different provider. The *service* holds the multi-provider registry; the *tool* picks one — no provider/type parameter in the schema this cut.
+
+## Plan (three PRs, each converged with Codex separately)
+
+1. **PR1 — interface + tool + mock.** This RFC, `dsh-subagent` (service, registry, vocabulary, `subagent/*` events), `dsh-subagent-mock` (scripted provider), `dsh-tool-subagent`. Wire the new `packages/subagent/` group into the tsconfigs, the build references, the package hierarchy docs, and the module graph. Tests: registry HMR-safety, duplicate-name rejection, start-time capability rejection, and at least one test driving the tool through the **real cordis Loader / export path** (a hand-built `ctx.plugin` mount bypasses `unwrapExports` and cannot catch a broken export shape — see [postmortem 0001](../../../postmortem/0001-acp-default-export-drops-inject.md)).
+2. **PR2 — in-process backends.** `dsh-subagent-spawn` and `dsh-subagent-fork` over `ctx.agents.create` + `AgentHandle.dispose`. The fork backend must seed only a **balanced, completed-turn prefix** of the parent log: at tool-execute time the parent's turn is open (it holds the `assistant/message` and the dangling spawn `tool/call` with no `tool/result`), and seeding that raw prefix gives the child an unbalanced turn the [invariants](../../../../packages/support/invariants/src/index.ts) freeze-check rejects. Depth tracking (parent depth + 1, refused past `maxDepth`) and its exact storage are settled in PR2.
+3. **PR3 — ACP backend.** `dsh-subagent-acp` as an ACP client over a configured spawn command (stdio); point it at our own `acp-agent` example to "talk to our own process". Minimal client stub: advertise no optional client capabilities, auto-resolve `session/request_permission` via a configured default, consume `session/update` without surfacing it this cut. Decide the `@agentclientprotocol/sdk` version (recommended: bump to 0.28.x for the fluent client API; the bump is shared with the existing `dsh-acp` bridge, so re-run its snapshot + e2e).
+
+## Risks and deferrals
+
+- **Recursion.** Without a guard, an in-process child inherits the spawn tool and can spawn unboundedly. Depth-limit is an optional capability (the in-process backends enforce it; ACP advertises it off and rejects a `maxDepth` request); tool-filtering is likewise optional. Tool-filtering, when implemented, needs a `tools/execute` veto in the child context — schema filtering alone is insufficient because a model can hallucinate a denied tool name.
+- **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.

+ 7 - 0
packages/README.md

@@ -11,6 +11,7 @@ Packages are grouped by modular role at `packages/<group>/<pkg>/`. The group dir
 | [`core/`](core/README.md) | Product API spine: session, system-prompt, tools, agent, and the concrete loop | Product — stable surface |
 | [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface |
 | [`bash/`](bash/README.md) | Bash capability family: the executor seam, a local impl, and the model-facing tool | Product — stable surface |
+| [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry seam and the model-facing delegation tool | Product — stable surface |
 | [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface |
 | [`ui/`](ui/README.md) | Editor/client integration surfaces (the ACP bridge) | Product — stable surface |
 | [`support/`](support/README.md) | Dev/test/example infrastructure (invariants, stdio UI, replay adapter) | Support — lower compatibility expectations |
@@ -37,6 +38,9 @@ dsh-invariants    ← dsh-llm, dsh-session, dsh-agent (dev-mode contract checks)
 dsh-acp           ← dsh-agent, dsh-llm, dsh-session, dsh-session-persistence  (ACP JSON-RPC bridge)
 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-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)
 dsh-acp-agent     ← dsh-agent-core, dsh-acp, dsh-session-persistence-jsonl     (ACP server APP + bin)
@@ -69,6 +73,9 @@ The rule: **extension** plugins depend on interfaces, never on the concrete loop
 | `acp-agent/` | `ui` | ACP server APP: agent-core spine + JSONL persistence + the `acp` bridge (no stdout logger), with a `bin` | (composition + `bin`) |
 | `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-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) |
 
 Each package has its own `README.md` with purpose, service API, events, extension points, and deliberate non-goals (TODOs).

+ 12 - 0
packages/subagent/README.md

@@ -0,0 +1,12 @@
+# subagent/ — subagent capability family
+
+The subagent seam: an agent delegating work to a child agent. Like the [bash](../bash/README.md) and [llm](../llm/README.md) families this is a capability seam (see [capability seams](../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)) — but with one defining difference: **multiple provider implementations coexist in one context**, registered by name, rather than the single-implementation bash shape. The registry mirrors the LLM adapter registry.
+
+| Package | Role | ctx key |
+|---|---|---|
+| `subagent/` | Abstract subagent seam: named-provider registry + vocabulary | `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 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).

+ 39 - 0
packages/subagent/subagent/README.md

@@ -0,0 +1,39 @@
+# @deepseek-ai/dsh-subagent
+
+The **subagent seam**: an abstract `SubagentService` (`ctx.subagents`) for an agent delegating work to another agent. A *subagent* is a child agent; a `SubagentProvider` is one transport for running it.
+
+This package is the interface third of the capability seam, split so each concern evolves (and swaps) independently:
+
+| Package | Role |
+|---|---|
+| `@deepseek-ai/dsh-subagent` (this) | the interface: registry service + vocabulary types |
+| `@deepseek-ai/dsh-subagent-spawn` | an implementation: fresh in-process child |
+| `@deepseek-ai/dsh-subagent-fork` | an implementation: in-process child seeded from the parent's log |
+| `@deepseek-ai/dsh-subagent-acp` | an implementation: ACP client driving another process |
+| `@deepseek-ai/dsh-tool-subagent` | the model-facing tool over `ctx.subagents` |
+
+Unlike the bash seam (one executor per context, second load throws), **multiple providers coexist** here. Each registers under a unique name and a caller picks one by name — the shape mirrors the LLM adapter registry (`LlmService.registerAdapter`), not the single-service bash executor. This is the requirement that rules out the bash shape: an agent may want an in-process child for a cheap subtask and an out-of-process ACP child for an isolated one, in the same runtime.
+
+## Service API (`ctx.subagents`)
+
+| Member | Semantics |
+|---|---|
+| `registerProvider(provider)` | Register under `provider.name`. Throws `SubagentError('DUPLICATE_PROVIDER')` on a name clash. Effect-scoped (HMR-safe); returns the disposer. |
+| `getProvider(name)` | Look up a provider (`undefined` if absent). |
+| `list()` | Registered provider names (insertion order). |
+| `start(name, request)` | Resolve the provider (`NO_PROVIDER` if absent), validate every requested START-TIME capability (`UNSUPPORTED_CAPABILITY` for the first unmet one — before any child is created), then delegate to `provider.start` and emit `subagent/start` / `subagent/end` around the run. |
+
+## Capabilities: two kinds, discovered two ways
+
+- **Start-time features** (`outputSchema`, `depthLimit`, `toolFilter`) are a static `provider.capabilities` descriptor, checked by the service BEFORE a run exists. A request that needs one the provider lacks is **rejected loud** (`UNSUPPORTED_CAPABILITY`), never accepted-then-ignored.
+- **Runtime features** (steering, resume) are **optional methods** on `SubagentRun` (`sendMessage?`, `resume?`). The method's presence IS the capability; TS narrowing is the discovery mechanism — a consumer cannot call an absent method without narrowing first, so there is no silent degradation path.
+
+## Run lifecycle
+
+`provider.start(request)` returns a `SubagentRun`: a handle with a `result` promise, `cancel()`, `dispose()`, and the optional runtime methods. `result` resolves with a `SubagentResult` (`output`, optional `structured`, `stopReason`) — it does **not** reject on a child-level failure (a model/transport failure resolves with `stopReason: 'error'`), so the consumer maps a non-`completed` reason to an `isError` tool result. The consumer MUST `dispose()` on every path (success, error, abort) to reach child quiescence and avoid leaking an idle child / session.
+
+## Scope (first cut)
+
+The consumer collects **synchronously**: it starts a run and awaits `result`. Steering (`sendMessage`) is part of the contract but intentionally unused. Background / poll / spill semantics are deferred to a future redesign unifying long-running-tool handling across subagents and bash. See the RFC: [docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md](../../../docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md).
+
+See `src/types.ts` for the full contracts.

+ 34 - 0
packages/subagent/subagent/package.json

@@ -0,0 +1,34 @@
+{
+  "name": "@deepseek-ai/dsh-subagent",
+  "description": "Abstract subagent seam (ctx.subagents): named-provider registry for delegating to child 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-tools": "^0.0.1",
+    "cordis": "^4.0.0-rc.6"
+  },
+  "devDependencies": {
+    "@deepseek-ai/dsh-agent": "workspace:^",
+    "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/dsh-tools": "workspace:^",
+    "cordis": "^4.0.0-rc.6"
+  }
+}

+ 191 - 0
packages/subagent/subagent/src/index.ts

@@ -0,0 +1,191 @@
+/**
+ * The subagent seam (`ctx.subagents`): a named-provider registry plus a
+ * capability-validating `start` surface. A subagent is an agent delegating
+ * work to another agent; a {@link SubagentProvider} is one transport for
+ * running that child (in-process spawn/fork, ACP to another process, and —
+ * later — A2A, the Codex app-server, the Claude Code Agent SDK).
+ *
+ * Unlike the bash seam (one executor per context, second load throws), MULTIPLE
+ * providers coexist here: each registers under a unique name and a caller picks
+ * one by name. The shape mirrors the LLM adapter registry
+ * (`LlmService.registerAdapter`), not the single-service bash executor.
+ *
+ * This package is the INTERFACE third of the capability seam. Implementations
+ * (`@deepseek-ai/dsh-subagent-spawn`, `-fork`, `-acp`) and the model-facing
+ * consumer (`@deepseek-ai/dsh-tool-subagent`) are separate packages.
+ *
+ * Scope (first cut): the consumer collects synchronously — it starts a run and
+ * awaits {@link SubagentRun.result}. Steering ({@link SubagentRun.sendMessage})
+ * is part of the contract but intentionally unused; background / poll / spill
+ * semantics are deferred to a future redesign that unifies long-running-tool
+ * handling across subagents and bash.
+ *
+ * @module @deepseek-ai/dsh-subagent
+ */
+
+import { Context, Service } from 'cordis'
+import { HarnessError } from '@deepseek-ai/dsh-llm'
+import type { AgentId } from '@deepseek-ai/dsh-agent'
+import type {
+  SubagentCapabilities,
+  SubagentProvider,
+  SubagentResult,
+  SubagentRun,
+  SubagentStartRequest,
+} from './types.ts'
+
+export type {
+  SubagentCapabilities,
+  SubagentProvider,
+  SubagentResult,
+  SubagentRun,
+  SubagentStartRequest,
+  SubagentStopReason,
+  SubagentStopReasonMap,
+} from './types.ts'
+
+declare module 'cordis' {
+  interface Context {
+    subagents: SubagentService
+  }
+
+  interface Events {
+    /**
+     * A subagent run started — emitted after the provider is resolved and its
+     * capabilities validated, as the child run begins. Paired with
+     * {@link Events['subagent/end']}.
+     * @mode emit
+     */
+    'subagent/start'(info: SubagentRunInfo): void
+    /**
+     * A subagent run settled — emitted when {@link SubagentRun.result}
+     * resolves (any stop reason). Paired with {@link Events['subagent/start']}.
+     * @mode emit
+     */
+    'subagent/end'(info: SubagentRunEndInfo): void
+  }
+}
+
+/** Identifying detail for a started subagent run (the `subagent/start` payload). */
+export interface SubagentRunInfo {
+  /** The provider that started the run. */
+  provider: string
+  /** The child agent/session id. */
+  id: AgentId
+}
+
+/** Outcome detail for a settled subagent run (the `subagent/end` payload). */
+export interface SubagentRunEndInfo {
+  /** The provider that ran it. */
+  provider: string
+  /** The child agent/session id. */
+  id: AgentId
+  /** The terminal stop reason. */
+  stopReason: SubagentResult['stopReason']
+}
+
+/**
+ * Typed error for subagent-seam failures. Extends {@link HarnessError}, so the
+ * `code` string (`DUPLICATE_PROVIDER`, `NO_PROVIDER`, `UNSUPPORTED_CAPABILITY`)
+ * is shared, machine-routable taxonomy.
+ */
+export class SubagentError extends HarnessError {
+  constructor(message: string, code: string, options?: ErrorOptions) {
+    super(message, code, options)
+    this.name = 'SubagentError'
+  }
+}
+
+/**
+ * The `subagents` service: a registry of named {@link SubagentProvider}s and a
+ * capability-checked {@link start} surface.
+ */
+export class SubagentService extends Service {
+  private providers = new Map<string, SubagentProvider>()
+
+  constructor(ctx: Context) {
+    super(ctx, 'subagents')
+  }
+
+  /**
+   * Register a provider under its `provider.name`. Throws {@link SubagentError}
+   * (`DUPLICATE_PROVIDER`) if the name is already taken. Effect-scoped: disposed
+   * with the calling fiber (HMR-safe).
+   */
+  registerProvider(provider: SubagentProvider): () => void {
+    const dispose = this.ctx.effect(function* (this: SubagentService) {
+      if (this.providers.has(provider.name)) {
+        throw new SubagentError(`a subagent provider named "${provider.name}" is already registered`, 'DUPLICATE_PROVIDER')
+      }
+      this.providers.set(provider.name, provider)
+      yield () => {
+        this.providers.delete(provider.name)
+      }
+    }.bind(this), 'subagents.registerProvider()')
+    // ctx.effect's disposer returns Promise<void>; our disposer API is
+    // synchronous fire-and-forget — discard the (always-resolved) promise.
+    return () => void dispose()
+  }
+
+  /** Look up a registered provider by name (`undefined` if absent). */
+  getProvider(name: string): SubagentProvider | undefined {
+    return this.providers.get(name)
+  }
+
+  /** The names of all registered providers (insertion order). */
+  list(): string[] {
+    return [...this.providers.keys()]
+  }
+
+  /**
+   * Start a subagent run on the named provider. Resolves the provider (throws
+   * `NO_PROVIDER` if absent), validates every requested START-TIME capability
+   * against {@link SubagentProvider.capabilities} (throws `UNSUPPORTED_CAPABILITY`
+   * for the first unmet one — fail loud, before any child is created), then
+   * delegates to {@link SubagentProvider.start} and emits `subagent/start` /
+   * `subagent/end` around the run.
+   */
+  start(name: string, request: SubagentStartRequest): SubagentRun {
+    const provider = this.providers.get(name)
+    if (!provider) {
+      throw new SubagentError(`no subagent provider registered for "${name}"`, 'NO_PROVIDER')
+    }
+    this.assertCapabilities(provider, request)
+
+    const run = provider.start(request)
+    this.ctx.emit('subagent/start', { provider: name, id: run.id })
+    // Emit `subagent/end` when the run settles. The result promise does not
+    // reject on a child-level failure (it resolves with stopReason 'error'),
+    // so a rejection here is an infrastructure fault — surface its stop reason
+    // as 'error' for the telemetry event without swallowing the rejection
+    // (the consumer still observes it via `run.result`).
+    void run.result.then(
+      (result) => { this.ctx.emit('subagent/end', { provider: name, id: run.id, stopReason: result.stopReason }) },
+      () => { this.ctx.emit('subagent/end', { provider: name, id: run.id, stopReason: 'error' }) },
+    )
+    return run
+  }
+
+  /**
+   * Reject a request that needs a start-time capability the provider lacks.
+   * Each optional request field maps to one {@link SubagentCapabilities} flag;
+   * the first unmet one throws `UNSUPPORTED_CAPABILITY`.
+   */
+  private assertCapabilities(provider: SubagentProvider, request: SubagentStartRequest): void {
+    const needs: { when: boolean; cap: keyof SubagentCapabilities }[] = [
+      { when: request.outputSchema !== undefined, cap: 'outputSchema' },
+      { when: request.maxDepth !== undefined, cap: 'depthLimit' },
+      { when: request.toolFilter !== undefined, cap: 'toolFilter' },
+    ]
+    for (const { when, cap } of needs) {
+      if (when && !provider.capabilities[cap]) {
+        throw new SubagentError(
+          `subagent provider "${provider.name}" does not support the "${cap}" capability`,
+          'UNSUPPORTED_CAPABILITY',
+        )
+      }
+    }
+  }
+}
+
+export default SubagentService

+ 172 - 0
packages/subagent/subagent/src/types.ts

@@ -0,0 +1,172 @@
+/**
+ * Subagent seam vocabulary: the request/result/capability types a
+ * {@link SubagentProvider} consumes and produces. No runtime code — types
+ * only, per the package convention.
+ *
+ * @module @deepseek-ai/dsh-subagent/types
+ */
+
+import type { Agent, AgentId, AgentOptions } from '@deepseek-ai/dsh-agent'
+import type { ContentBlock } from '@deepseek-ai/dsh-llm'
+import type { SchemaSpec } from '@deepseek-ai/dsh-tools'
+
+/**
+ * Which START-TIME features a provider supports. Checked by the service
+ * BEFORE delegating to {@link SubagentProvider.start}: a request that needs a
+ * capability the chosen provider lacks is rejected with a typed error rather
+ * than accepted-then-ignored (the "fail loud, no silent degradation" rule).
+ *
+ * Start-time features live here (a static descriptor) because they must be
+ * checked before a run exists. RUNTIME features (steering, resume) are instead
+ * modeled as OPTIONAL METHODS on {@link SubagentRun}: the method's presence IS
+ * the capability, and TS narrowing is the discovery mechanism — a consumer
+ * cannot call an absent method without narrowing first.
+ */
+export interface SubagentCapabilities {
+  /** Honor {@link SubagentStartRequest.outputSchema} (structured final output). */
+  outputSchema: boolean
+  /** Enforce {@link SubagentStartRequest.maxDepth} (recursion cap). */
+  depthLimit: boolean
+  /** Enforce {@link SubagentStartRequest.toolFilter} (child tool scoping). */
+  toolFilter: boolean
+}
+
+/**
+ * What a caller asks for when starting a subagent. The tool layer builds this
+ * from the model's `{ description, prompt }` plus its own config; the service
+ * validates {@link SubagentCapabilities} against the named provider, then
+ * passes it to {@link SubagentProvider.start}.
+ */
+export interface SubagentStartRequest {
+  /** The task/prompt for the child agent (a user message in the child session). */
+  prompt: ContentBlock[]
+  /**
+   * The spawning ("parent") agent — the one whose tool call started this
+   * subagent. REQUIRED: in-process backends read `parent.session.header` for
+   * the working directory, the `parentSession` lineage to stamp on the child,
+   * and the parent's delegation depth. Out-of-process backends (ACP) ignore it.
+   */
+  parent: Agent
+  /**
+   * Cancellation signal from the spawning context (the tool's `exec.signal`).
+   * A provider that honors it aborts the child when the signal fires; the
+   * consumer also bridges it to {@link SubagentRun.cancel} explicitly.
+   */
+  signal?: AbortSignal
+  /** Per-child agent options (model, system prompt). */
+  agentOptions?: AgentOptions
+  /**
+   * Optional structured-output schema. When set AND the provider's
+   * {@link SubagentCapabilities.outputSchema} is `true`, the child's final
+   * answer is shaped to this schema and surfaced as {@link SubagentResult.structured}.
+   * Requesting it against a provider that lacks the capability is rejected at start.
+   */
+  outputSchema?: SchemaSpec
+  /**
+   * Optional recursion cap (max delegation depth below this child). Requires
+   * {@link SubagentCapabilities.depthLimit}; rejected at start otherwise.
+   */
+  maxDepth?: number
+  /**
+   * Optional child tool scoping. Requires {@link SubagentCapabilities.toolFilter};
+   * rejected at start otherwise.
+   */
+  toolFilter?: { allow?: string[]; deny?: string[] }
+}
+
+/**
+ * Why a subagent run ended. Merge-extensible (a backend may add variants);
+ * consumers branch on the known cases and fall through `default`. The known
+ * cases mirror the harness turn-end vocabulary so the tool layer can map a
+ * non-`completed` result to an `isError` tool result.
+ */
+export interface SubagentStopReasonMap {
+  /** The child finished its turn normally. */
+  completed: 'completed'
+  /** The run was cancelled (parent signal, explicit `cancel()`, or peer cancel). */
+  aborted: 'aborted'
+  /** The child failed (model error, transport error). */
+  error: 'error'
+  /** The child hit its token ceiling before finishing. */
+  'max-tokens': 'max-tokens'
+  /** The child declined the task. */
+  refusal: 'refusal'
+}
+
+export type SubagentStopReason = SubagentStopReasonMap[keyof SubagentStopReasonMap]
+
+/**
+ * The terminal outcome of a subagent run, resolved by {@link SubagentRun.result}.
+ */
+export interface SubagentResult {
+  /** The child's final assistant output (the last assistant message's content). */
+  output: ContentBlock[]
+  /**
+   * The structured result, present IFF the request carried an `outputSchema`
+   * AND the provider honored it. Shape is validated against the request schema
+   * by the provider; `unknown` here because the seam is schema-agnostic.
+   */
+  structured?: unknown
+  /** Why the run ended. A non-`completed` reason means `output` may be partial. */
+  stopReason: SubagentStopReason
+}
+
+/**
+ * A live subagent run: a handle the consumer holds while a child executes.
+ * Returned by {@link SubagentProvider.start} (via the service). The consumer
+ * awaits {@link result}, may {@link cancel} mid-flight, and MUST {@link dispose}
+ * on every path to reach child quiescence (no leaked idle child / session).
+ *
+ * {@link sendMessage} and {@link resume} are OPTIONAL: a provider that supports
+ * the runtime capability defines the method; one that doesn't omits it. The
+ * presence of the method IS the capability — narrow before calling.
+ */
+export interface SubagentRun {
+  /** The child agent's id (also its session id token, for correlation). */
+  readonly id: AgentId
+  /**
+   * Resolves with the child's terminal {@link SubagentResult} when the run
+   * settles. Does NOT reject on a child-level failure — a model/transport
+   * failure resolves with `stopReason: 'error'` so the consumer maps it to an
+   * `isError` tool result. Rejects only on an infrastructure fault the seam
+   * cannot represent as a stop reason.
+   */
+  readonly result: Promise<SubagentResult>
+  /** Request cancellation of the in-flight run; {@link result} settles `aborted`. */
+  cancel(reason?: string): void
+  /**
+   * Reach child quiescence and release the run's resources (in-process: dispose
+   * the owned agent handle and remove its session; ACP: kill the subprocess).
+   * Idempotent; awaits the child actually stopping, not merely requesting it.
+   */
+  dispose(): Promise<void>
+  /**
+   * OPTIONAL (steering capability): send additional content to the running
+   * child between steps. Present only on providers that support live steering.
+   */
+  sendMessage?(content: ContentBlock[]): void
+  /**
+   * OPTIONAL (resume capability): send a follow-up task to a settled child,
+   * continuing its session, and return a fresh run for the continuation.
+   */
+  resume?(content: ContentBlock[]): SubagentRun
+}
+
+/**
+ * A subagent backend: one transport for running a child agent (in-process
+ * spawn/fork, ACP to another process, …). Implementations register under a
+ * unique name via {@link SubagentService.registerProvider}; multiple providers
+ * coexist in one context (unlike the single-implementation bash seam).
+ */
+export interface SubagentProvider {
+  /** Unique registry name (e.g. `spawn`, `fork`, `acp`). */
+  readonly name: string
+  /** The start-time features this provider supports (see {@link SubagentCapabilities}). */
+  readonly capabilities: SubagentCapabilities
+  /**
+   * Start a child run. The service has already validated that every requested
+   * start-time capability is supported, so an implementation may assume e.g.
+   * `request.maxDepth` is honorable when present.
+   */
+  start(request: SubagentStartRequest): SubagentRun
+}

+ 182 - 0
packages/subagent/subagent/tests/service.spec.ts

@@ -0,0 +1,182 @@
+import { describe, expect, it, vi } from 'vitest'
+import { Context } from 'cordis'
+import { AgentId, type Agent } from '@deepseek-ai/dsh-agent'
+import { HarnessError } from '@deepseek-ai/dsh-llm'
+import SubagentService, {
+  SubagentError,
+  type SubagentCapabilities,
+  type SubagentProvider,
+  type SubagentResult,
+  type SubagentRun,
+  type SubagentStartRequest,
+} from '@deepseek-ai/dsh-subagent'
+
+/** A minimal parent Agent stand-in — the service only reads `parent.id`. */
+function fakeParent(id = 'parent-1'): Agent {
+  return { id: AgentId(id) } as unknown as Agent
+}
+
+const ALL_CAPS: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: true }
+const NO_CAPS: SubagentCapabilities = { outputSchema: false, depthLimit: false, toolFilter: false }
+
+/** A scripted provider whose run settles immediately with a fixed result. */
+class StubProvider implements SubagentProvider {
+  startCount = 0
+  constructor(
+    readonly name: string,
+    readonly capabilities: SubagentCapabilities = ALL_CAPS,
+    private readonly result: SubagentResult = { output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' },
+  ) {}
+
+  start(request: SubagentStartRequest): SubagentRun {
+    this.startCount++
+    return {
+      id: AgentId(`child:${this.name}:${request.parent.id}`),
+      result: Promise.resolve(this.result),
+      cancel() {},
+      async dispose() {},
+    }
+  }
+}
+
+function baseRequest(overrides: Partial<SubagentStartRequest> = {}): SubagentStartRequest {
+  return { prompt: [{ type: 'text', text: 'do a thing' }], parent: fakeParent(), ...overrides }
+}
+
+describe('SubagentService', () => {
+  it('registers a provider and starts a run on it by name', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SubagentService)
+    const provider = new StubProvider('alpha')
+    ctx.subagents.registerProvider(provider)
+
+    expect(ctx.subagents.list()).toEqual(['alpha'])
+    expect(ctx.subagents.getProvider('alpha')).toBe(provider)
+
+    const run = ctx.subagents.start('alpha', baseRequest())
+    expect(provider.startCount).toBe(1)
+    await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' })
+  })
+
+  it('lets multiple providers coexist (the defining requirement)', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SubagentService)
+    ctx.subagents.registerProvider(new StubProvider('spawn'))
+    ctx.subagents.registerProvider(new StubProvider('acp'))
+
+    expect(ctx.subagents.list()).toEqual(['spawn', 'acp'])
+    expect(ctx.subagents.getProvider('spawn')).toBeDefined()
+    expect(ctx.subagents.getProvider('acp')).toBeDefined()
+  })
+
+  it('throws NO_PROVIDER when starting on an unregistered name', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SubagentService)
+    try {
+      ctx.subagents.start('missing', baseRequest())
+      expect.fail('expected NO_PROVIDER')
+    } catch (error: unknown) {
+      expect(error).toBeInstanceOf(SubagentError)
+      expect((error as SubagentError).code).toBe('NO_PROVIDER')
+    }
+  })
+
+  it('rejects duplicate provider names with DUPLICATE_PROVIDER', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SubagentService)
+    ctx.subagents.registerProvider(new StubProvider('dup'))
+    try {
+      ctx.subagents.registerProvider(new StubProvider('dup'))
+      expect.fail('expected DUPLICATE_PROVIDER')
+    } catch (error: unknown) {
+      expect(error).toBeInstanceOf(SubagentError)
+      expect((error as SubagentError).code).toBe('DUPLICATE_PROVIDER')
+    }
+  })
+
+  it('unregisters a provider when its owning fiber is disposed (HMR safety)', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SubagentService)
+
+    const fiber = await ctx.plugin(Object.assign((inner: Context) => {
+      inner.subagents.registerProvider(new StubProvider('scoped'))
+    }, { inject: ['subagents'] }))
+    expect(ctx.subagents.list()).toEqual(['scoped'])
+
+    await fiber.dispose()
+    expect(ctx.subagents.list()).toEqual([])
+  })
+
+  it('re-registers a name after its prior registration is disposed (not wedged)', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SubagentService)
+
+    const dispose = ctx.subagents.registerProvider(new StubProvider('reuse'))
+    expect(ctx.subagents.list()).toEqual(['reuse'])
+    dispose()
+    expect(ctx.subagents.list()).toEqual([])
+
+    const disposeAgain = ctx.subagents.registerProvider(new StubProvider('reuse'))
+    expect(ctx.subagents.list()).toEqual(['reuse'])
+    disposeAgain()
+    expect(ctx.subagents.list()).toEqual([])
+  })
+
+  describe('start-time capability validation (fail loud, before any child)', () => {
+    it.each([
+      { field: 'outputSchema', request: baseRequest({ outputSchema: { x: { type: 'string' } } }) },
+      { field: 'maxDepth', request: baseRequest({ maxDepth: 2 }) },
+      { field: 'toolFilter', request: baseRequest({ toolFilter: { deny: ['bash'] } }) },
+    ])('rejects $field against a provider that lacks the capability — before start() runs', ({ request }) => {
+      const ctx = new Context()
+      return ctx.plugin(SubagentService).then(() => {
+        const provider = new StubProvider('weak', NO_CAPS)
+        ctx.subagents.registerProvider(provider)
+        try {
+          ctx.subagents.start('weak', request)
+          expect.fail('expected UNSUPPORTED_CAPABILITY')
+        } catch (error: unknown) {
+          expect(error).toBeInstanceOf(SubagentError)
+          expect((error as SubagentError).code).toBe('UNSUPPORTED_CAPABILITY')
+        }
+        // The child was never started — the check is pre-spawn.
+        expect(provider.startCount).toBe(0)
+      })
+    })
+
+    it('allows a capability request when the provider supports it', async () => {
+      const ctx = new Context()
+      await ctx.plugin(SubagentService)
+      const provider = new StubProvider('strong', ALL_CAPS)
+      ctx.subagents.registerProvider(provider)
+      ctx.subagents.start('strong', baseRequest({ outputSchema: { x: { type: 'string' } }, maxDepth: 1 }))
+      expect(provider.startCount).toBe(1)
+    })
+  })
+
+  it('emits subagent/start then subagent/end around a run', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SubagentService)
+    ctx.subagents.registerProvider(new StubProvider('events'))
+
+    const started = vi.fn()
+    const ended = vi.fn()
+    ctx.on('subagent/start', started)
+    ctx.on('subagent/end', ended)
+
+    const run = ctx.subagents.start('events', baseRequest())
+    expect(started).toHaveBeenCalledWith(expect.objectContaining({ provider: 'events', id: run.id }))
+
+    await run.result
+    // `subagent/end` fires from a `.then` on the result — let the microtask run.
+    await Promise.resolve()
+    expect(ended).toHaveBeenCalledWith(expect.objectContaining({ provider: 'events', id: run.id, stopReason: 'completed' }))
+  })
+
+  it('SubagentError extends the shared HarnessError base', () => {
+    const err = new SubagentError('boom', 'NO_PROVIDER')
+    expect(err).toBeInstanceOf(HarnessError)
+    expect(err.name).toBe('SubagentError')
+    expect(err.code).toBe('NO_PROVIDER')
+  })
+})

+ 27 - 0
packages/subagent/subagent/tsconfig.json

@@ -0,0 +1,27 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib"
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    {
+      "path": "../../../vendor/cosmokit"
+    },
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../../core/agent"
+    },
+    {
+      "path": "../../llm/llm"
+    },
+    {
+      "path": "../../core/tools"
+    }
+  ]
+}

+ 18 - 0
packages/subagent/tool-subagent/README.md

@@ -0,0 +1,18 @@
+# @deepseek-ai/dsh-tool-subagent
+
+The model-facing `subagent` tool: delegate a self-contained task to a child agent and return its final output. Pure schema + lifecycle shaping over the [`ctx.subagents`](../subagent/README.md) provider registry — an in-process, ACP, or future A2A backend swaps in without changing what the model sees.
+
+## Provider selection is config, not model-facing
+
+This plugin binds to **exactly one** provider (`Config.provider`). The model sees only `{ description, prompt }` — there is no provider/type parameter in the schema. To expose more than one transport, load the plugin more than once, each bound to a different provider. Keeping selection in config (not the schema) is the deliberate split: the *service* holds a multi-provider registry; the *tool* picks one.
+
+| Config key | Meaning |
+|---|---|
+| `provider` (required) | The `ctx.subagents` provider name to start runs on (`spawn`, `fork`, `acp`, …). |
+| `agentOptions` | Default per-child `{ model?, systemPrompt? }` applied to every spawned child. |
+
+## Lifecycle (synchronous collect)
+
+`execute` starts a run on the configured provider and **awaits `run.result` inside a `try/finally` that always `dispose()`s the run** — the owned child agent/session is torn down on every path (success, error, abort), never leaked. The tool's abort signal (`exec.signal`) is bridged to `run.cancel()`. A non-`completed` stop reason (aborted/error/max-tokens/refusal) maps to an `isError` tool result rather than returning partial output as success.
+
+Background / poll collection is deferred (see the [RFC](../../../docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md)); this cut blocks the parent turn until the child finishes.

+ 42 - 0
packages/subagent/tool-subagent/package.json

@@ -0,0 +1,42 @@
+{
+  "name": "@deepseek-ai/dsh-tool-subagent",
+  "description": "Model-facing subagent delegation tool over the ctx.subagents seam",
+  "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-subagent": "^0.0.1",
+    "@deepseek-ai/dsh-tools": "^0.0.1",
+    "cordis": "^4.0.0-rc.6"
+  },
+  "dependencies": {
+    "schemastery": "^3.18.0"
+  },
+  "devDependencies": {
+    "@deepseek-ai/dsh-agent": "workspace:^",
+    "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/dsh-subagent": "workspace:^",
+    "@deepseek-ai/dsh-subagent-mock": "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"
+  }
+}

+ 147 - 0
packages/subagent/tool-subagent/src/index.ts

@@ -0,0 +1,147 @@
+/**
+ * The model-facing `subagent` tool: delegate a task to a child agent and return
+ * its final output. Pure schema + lifecycle shaping — every transport concern
+ * lives behind the `ctx.subagents` provider registry
+ * (`@deepseek-ai/dsh-subagent`), so an in-process, ACP, or future A2A backend
+ * swaps in without touching what the model sees.
+ *
+ * Provider selection is config, not model-facing: this plugin is bound to
+ * EXACTLY ONE provider name (`Config.provider`). To expose more than one
+ * transport, load the plugin more than once, each bound to a different provider
+ * — there is no provider/type parameter in the model-facing schema. The model
+ * sees only `{ description, prompt }`.
+ *
+ * Collection is SYNCHRONOUS this cut: `execute` starts a run and awaits
+ * `run.result` inside a `try/finally` that always disposes the run, so the
+ * owned child agent/session is torn down on every path (success, error, abort)
+ * and never leaks as a live idle child. A non-`completed` stop reason maps to an
+ * `isError` tool result (by throwing) rather than returning partial output as
+ * success.
+ *
+ * @module @deepseek-ai/dsh-tool-subagent
+ */
+
+import type { Context } from 'cordis'
+import z from 'schemastery'
+import { defineTool } from '@deepseek-ai/dsh-tools'
+import type { AgentOptions } from '@deepseek-ai/dsh-agent'
+import type { ContentBlock } from '@deepseek-ai/dsh-llm'
+import type { SubagentResult, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
+
+export const name = 'tool-subagent'
+export const inject = ['tools', 'subagents']
+
+/** Config: which registered provider this tool delegates to, plus child defaults. */
+export interface Config {
+  /** The `ctx.subagents` provider name to start runs on (e.g. `spawn`, `acp`). */
+  provider: string
+  /**
+   * Default per-child agent options (model, system prompt) applied to every
+   * spawned child. Omitted fields fall back to the child loop's own defaults.
+   */
+  agentOptions?: AgentOptions
+}
+
+export const Config: z<Config> = z.object({
+  provider: z.string().required(),
+  agentOptions: z.object({
+    model: z.string(),
+    systemPrompt: z.string(),
+  }),
+})
+
+/**
+ * Flatten a child's final output blocks to text for the tool result. The child
+ * may return non-text blocks; this cut surfaces the text content (the common
+ * case) and drops the rest, which is acceptable for a synchronous summary —
+ * the structured path (`outputSchema`) is the channel for non-text results.
+ */
+function outputText(blocks: ContentBlock[]): string {
+  return blocks
+    .filter((b): b is Extract<ContentBlock, { type: 'text' }> => b.type === 'text')
+    .map(b => b.text)
+    .join('')
+}
+
+/** A non-`completed` stop reason means the child did not finish cleanly. */
+function stopReasonError(result: SubagentResult): string | undefined {
+  switch (result.stopReason) {
+    case 'completed':
+      return undefined
+    case 'aborted':
+      return 'subagent run was cancelled'
+    case 'error':
+      return 'subagent run failed'
+    case 'max-tokens':
+      return 'subagent run hit its token limit before finishing'
+    case 'refusal':
+      return 'subagent declined the task'
+    // Merge-extensible union: a backend may add stop reasons. Treat an unknown
+    // terminal reason as a failure rather than reporting partial output as success.
+    default:
+      return `subagent run ended abnormally (${String(result.stopReason)})`
+  }
+}
+
+export function apply(ctx: Context, config: Config): void {
+  ctx.tools.register(defineTool({
+    name: 'subagent',
+    description:
+      'Delegate a self-contained task to a subagent (a separate agent that works in its own context) '
+      + 'and return its final result. Use this to offload focused, independent work — research, a scoped '
+      + 'implementation, an analysis — so it does not consume this conversation\'s context. The subagent '
+      + 'runs to completion and you receive only its final answer, not its intermediate steps. Give it a '
+      + 'complete, standalone prompt: it does not see this conversation.',
+    parameters: {
+      description: {
+        type: 'string',
+        required: true,
+        description: 'A short (3-5 word) description of the delegated task, for display.',
+      },
+      prompt: {
+        type: 'string',
+        required: true,
+        description: 'The complete, self-contained task for the subagent. It does not share this '
+          + 'conversation\'s context, so include everything it needs.',
+      },
+    },
+    async execute(args, exec): Promise<ContentBlock[]> {
+      const parent = exec.agent
+      if (!parent) {
+        // The loop sets `exec.agent` for every model-driven call; its absence
+        // means a non-agent caller invoked the tool directly, which has no
+        // parent to attribute the child to. Fail loud rather than guess.
+        throw new Error('subagent tool requires a calling agent (exec.agent was undefined)')
+      }
+
+      const request: SubagentStartRequest = {
+        prompt: [{ type: 'text', text: args.prompt }],
+        parent,
+        ...exec.signal ? { signal: exec.signal } : {},
+        ...config.agentOptions ? { agentOptions: config.agentOptions } : {},
+      }
+
+      const run: SubagentRun = ctx.subagents.start(config.provider, request)
+
+      // Bridge the tool's abort signal to the run: if the parent step is
+      // aborted while the child is in flight, cancel the child too.
+      const onAbort = (): void => { run.cancel('parent step aborted') }
+      exec.signal?.addEventListener('abort', onAbort, { once: true })
+
+      try {
+        const result = await run.result
+        const error = stopReasonError(result)
+        if (error !== undefined) {
+          // Map a non-clean finish to an isError result (the registry turns a
+          // throw into an isError). Report the reason, not partial output.
+          throw new Error(error)
+        }
+        return [{ type: 'text', text: outputText(result.output) }]
+      } finally {
+        exec.signal?.removeEventListener('abort', onAbort)
+        // Always reach child quiescence — never leak a live idle child/session.
+        await run.dispose()
+      }
+    },
+  }))
+}

+ 210 - 0
packages/subagent/tool-subagent/tests/tool-subagent.spec.ts

@@ -0,0 +1,210 @@
+import { describe, expect, it, vi } from 'vitest'
+import { Context } from 'cordis'
+import Loader from '@cordisjs/plugin-loader'
+import { CallId } from '@deepseek-ai/dsh-llm'
+import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
+import ToolRegistry from '@deepseek-ai/dsh-tools'
+import { AgentId, type Agent } from '@deepseek-ai/dsh-agent'
+import SubagentService from '@deepseek-ai/dsh-subagent'
+import * as mock from '@deepseek-ai/dsh-subagent-mock'
+import * as tool from '../src/index.ts'
+
+/**
+ * Drives the REAL plugin body: mounts `dsh-tool-subagent` on a real
+ * `ToolRegistry` + `SubagentService`, with the real `dsh-subagent-mock` as the
+ * backend, and invokes the registered `subagent` tool through
+ * `ctx.tools.execute`. The mock is the genuine collaborator (we mock only the
+ * "child agent", the expensive/non-deterministic boundary) — everything
+ * downstream of the tool is the shipping code path.
+ */
+
+/** A minimal parent Agent — the tool reads `agent.id` for `parent`. */
+function fakeAgent(id = 'parent-1'): Agent {
+  return { id: AgentId(id) } as unknown as Agent
+}
+
+async function setup(toolConfig: tool.Config, mockConfig: Partial<mock.Config> = {}) {
+  const ctx = new Context()
+  await ctx.plugin(SystemPrompt)
+  await ctx.plugin(ToolRegistry)
+  await ctx.plugin(SubagentService)
+  await ctx.plugin(mock, { name: 'mock', ...mockConfig })
+  await ctx.plugin(tool, toolConfig)
+  return ctx
+}
+
+let callCounter = 0
+function callSubagent(ctx: Context, args: unknown, over: { agent?: Agent | undefined; signal?: AbortSignal } = {}) {
+  // Distinguish "no override" (use a default agent) from an explicit
+  // `{ agent: undefined }` (test the no-agent path). Under
+  // exactOptionalPropertyTypes the key is omitted rather than set to undefined.
+  const agent = 'agent' in over ? over.agent : fakeAgent()
+  return ctx.tools.execute({
+    callId: CallId(`call-${++callCounter}`),
+    name: 'subagent',
+    arguments: args,
+    ...agent ? { agent } : {},
+    ...over.signal ? { signal: over.signal } : {},
+  })
+}
+
+function text(result: { content: { type: string; text?: string }[] }): string {
+  return result.content.filter(b => b.type === 'text').map(b => b.text).join('')
+}
+
+describe('dsh-tool-subagent', () => {
+  it('registers a `subagent` tool that delegates to the configured provider and returns its output', async () => {
+    const ctx = await setup({ provider: 'mock' }, { reply: 'child says hi' })
+    const result = await callSubagent(ctx, { description: 'do a thing', prompt: 'go research X' })
+    expect(result.isError).toBe(false)
+    expect(text(result)).toBe('child says hi')
+  })
+
+  it('exposes only description + prompt to the model (no provider/type parameter)', async () => {
+    const ctx = await setup({ provider: 'mock' })
+    const schema = ctx.tools.schemas().find(s => s.name === 'subagent')
+    expect(schema).toBeDefined()
+    const props = (schema!.parameters as { properties?: Record<string, unknown> }).properties ?? {}
+    expect(Object.keys(props).sort()).toEqual(['description', 'prompt'])
+  })
+
+  it('maps a non-completed stop reason to an isError result (not partial success)', async () => {
+    const ctx = await setup({ provider: 'mock' }, { stopReason: 'refusal' })
+    const result = await callSubagent(ctx, { description: 'd', prompt: 'p' })
+    expect(result.isError).toBe(true)
+    expect(text(result)).toContain('declined')
+  })
+
+  it('fails loud when invoked without a calling agent', async () => {
+    const ctx = await setup({ provider: 'mock' })
+    const result = await callSubagent(ctx, { description: 'd', prompt: 'p' }, { agent: undefined })
+    expect(result.isError).toBe(true)
+    expect(text(result)).toContain('requires a calling agent')
+  })
+
+  it('surfaces an UNSUPPORTED_CAPABILITY rejection as an isError result is NOT applicable here '
+    + '(the tool requests no capabilities) — a missing provider IS surfaced', async () => {
+    // Bind the tool to a provider name that is not registered: the service throws
+    // NO_PROVIDER, the registry turns it into an isError result.
+    const ctx = await setup({ provider: 'does-not-exist' })
+    const result = await callSubagent(ctx, { description: 'd', prompt: 'p' })
+    expect(result.isError).toBe(true)
+    expect(text(result)).toContain('no subagent provider')
+  })
+
+  it('disposes the run on the success path (no leaked child)', async () => {
+    // Spy on the provider's run.dispose via a wrapping provider registered
+    // directly on the service, then point the tool at it.
+    const disposed = vi.fn()
+    const ctx = new Context()
+    await ctx.plugin(SystemPrompt)
+    await ctx.plugin(ToolRegistry)
+    await ctx.plugin(SubagentService)
+    ctx.subagents.registerProvider({
+      name: 'spy',
+      capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
+      start: () => ({
+        id: AgentId('spy-child'),
+        result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
+        cancel() {},
+        dispose: async () => void disposed(),
+      }),
+    })
+    await ctx.plugin(tool, { provider: 'spy' })
+
+    await callSubagent(ctx, { description: 'd', prompt: 'p' })
+    expect(disposed).toHaveBeenCalledTimes(1)
+  })
+
+  it('disposes the run on the error path too', async () => {
+    const disposed = vi.fn()
+    const ctx = new Context()
+    await ctx.plugin(SystemPrompt)
+    await ctx.plugin(ToolRegistry)
+    await ctx.plugin(SubagentService)
+    ctx.subagents.registerProvider({
+      name: 'spy',
+      capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
+      start: () => ({
+        id: AgentId('spy-child'),
+        result: Promise.resolve({ output: [], stopReason: 'error' as const }),
+        cancel() {},
+        dispose: async () => void disposed(),
+      }),
+    })
+    await ctx.plugin(tool, { provider: 'spy' })
+
+    const result = await callSubagent(ctx, { description: 'd', prompt: 'p' })
+    expect(result.isError).toBe(true)
+    expect(disposed).toHaveBeenCalledTimes(1)
+  })
+
+  it('bridges the tool abort signal to run.cancel()', async () => {
+    const cancelled = vi.fn()
+    const ctx = new Context()
+    await ctx.plugin(SystemPrompt)
+    await ctx.plugin(ToolRegistry)
+    await ctx.plugin(SubagentService)
+    ctx.subagents.registerProvider({
+      name: 'spy',
+      capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
+      start: () => {
+        let resolveResult: (r: { output: never[]; stopReason: 'aborted' }) => void
+        const result = new Promise<{ output: never[]; stopReason: 'aborted' }>((res) => { resolveResult = res })
+        return {
+          id: AgentId('spy-child'),
+          result,
+          cancel: () => {
+            cancelled()
+            resolveResult({ output: [], stopReason: 'aborted' })
+          },
+          dispose: async () => {},
+        }
+      },
+    })
+    await ctx.plugin(tool, { provider: 'spy' })
+
+    const controller = new AbortController()
+    const pending = callSubagent(ctx, { description: 'd', prompt: 'p' }, { signal: controller.signal })
+    controller.abort()
+    const result = await pending
+    expect(cancelled).toHaveBeenCalledTimes(1)
+    expect(result.isError).toBe(true)
+  })
+
+  it('tools depend on the service: no `subagent` tool without ctx.subagents', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SystemPrompt)
+    await ctx.plugin(ToolRegistry)
+    // No SubagentService mounted. The tool injects ['tools','subagents'] so its
+    // apply never runs; the tool is absent rather than half-registered.
+    let booted = true
+    try {
+      await ctx.plugin(tool, { provider: 'mock' })
+      await new Promise(r => setTimeout(r, 20))
+    } catch {
+      booted = false
+    }
+    // Either it never booted, or it booted but registered no tool.
+    const present = ctx.get('tools')?.schemas().some(s => s.name === 'subagent') ?? false
+    expect(booted && present).toBe(false)
+  })
+
+  it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/inject/Config/apply', () => {
+    // Postmortem 0001 guard: this plugin HAS `inject = ['tools','subagents']`, so
+    // a stray `export default apply` would collapse the module via
+    // `unwrapExports` (`exports.default ?? exports`), DROP `inject`, and crash at
+    // load with "cannot get property … without inject". Guard the shape directly.
+    expect('default' in tool).toBe(false)
+    expect(tool.name).toBe('tool-subagent')
+    expect(tool.inject).toEqual(['tools', 'subagents'])
+
+    const loader = Object.create(Loader.prototype) as Loader
+    const unwrapped = loader.unwrapExports(tool) as Record<string, unknown>
+    expect(unwrapped).toBe(tool)
+    expect(unwrapped.name).toBe('tool-subagent')
+    expect(unwrapped.inject).toEqual(['tools', 'subagents'])
+    expect(typeof unwrapped.apply).toBe('function')
+    expect(unwrapped.Config).toBeDefined()
+  })
+})

+ 33 - 0
packages/subagent/tool-subagent/tsconfig.json

@@ -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/tools"
+    },
+    {
+      "path": "../subagent"
+    }
+  ]
+}

+ 19 - 0
packages/support/subagent-mock/README.md

@@ -0,0 +1,19 @@
+# @deepseek-ai/dsh-subagent-mock
+
+A scripted `SubagentProvider` for testing the [subagent seam](../../subagent/subagent/README.md) without a model or a real child agent — the subagent analog of [`dsh-llm-replay`](../llm-replay/README.md).
+
+It lets a test drive `ctx.subagents` and the model-facing `dsh-tool-subagent` through the **real cordis Loader / export path**, exercising provider registration, start-time capability validation, the run lifecycle (`result` / `cancel` / `dispose`), and the structured-output branch — all deterministically and keylessly.
+
+## Usage
+
+Load it as a plugin (functional shape: `name`/`inject`/`Config`/`apply`, no default). Config (all optional):
+
+| Key | Default | Meaning |
+|---|---|---|
+| `name` | `mock` | Registry name to register the provider under. |
+| `reply` | `mock subagent reply` | The scripted child's final answer text. |
+| `stopReason` | `completed` | The stop reason `result` settles with. |
+| `capabilities` | all `true` | Which start-time capabilities (`outputSchema`/`depthLimit`/`toolFilter`) the provider advertises. |
+| `structured` | `{ reply }` | Structured value surfaced when a request carries an `outputSchema` and the capability is on. |
+
+A `cancel()` issued before `result` settles flips the stop reason to `aborted`, so the cancellation path is observable.

+ 38 - 0
packages/support/subagent-mock/package.json

@@ -0,0 +1,38 @@
+{
+  "name": "@deepseek-ai/dsh-subagent-mock",
+  "description": "Scripted subagent provider for testing the subagent seam (keyless, deterministic)",
+  "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-subagent": "^0.0.1",
+    "cordis": "^4.0.0-rc.6"
+  },
+  "dependencies": {
+    "schemastery": "^3.18.0"
+  },
+  "devDependencies": {
+    "@deepseek-ai/dsh-agent": "workspace:^",
+    "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/dsh-subagent": "workspace:^",
+    "@cordisjs/plugin-loader": "^1.0.0-rc.4",
+    "cordis": "^4.0.0-rc.6"
+  }
+}

+ 112 - 0
packages/support/subagent-mock/src/index.ts

@@ -0,0 +1,112 @@
+/**
+ * A scripted {@link SubagentProvider} for testing the subagent seam WITHOUT a
+ * model or a real child agent. Mirrors `@deepseek-ai/dsh-llm-replay`: it lets a
+ * test drive the service and the model-facing tool through the REAL cordis
+ * Loader / export path, exercising registration, capability validation, the
+ * run lifecycle, and the structured-output branch deterministically.
+ *
+ * Plugin export shape: named `name`/`inject`/`Config`/`apply`, NO default —
+ * a functional plugin (it only registers a provider; it is never injected).
+ *
+ * @module @deepseek-ai/dsh-subagent-mock
+ */
+
+import type { Context } from 'cordis'
+import z from 'schemastery'
+import { AgentId } from '@deepseek-ai/dsh-agent'
+import type { ContentBlock } from '@deepseek-ai/dsh-llm'
+import type {
+  SubagentCapabilities,
+  SubagentProvider,
+  SubagentResult,
+  SubagentRun,
+  SubagentStartRequest,
+  SubagentStopReason,
+} from '@deepseek-ai/dsh-subagent'
+
+const STOP_REASONS = ['completed', 'aborted', 'error', 'max-tokens', 'refusal'] as const
+
+const DEFAULT_CAPS: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: true }
+
+/**
+ * A scripted provider: every {@link start} returns a run whose `result`
+ * resolves on a microtask with the configured reply (and a structured value
+ * when the request asked for one and the capability is on). `dispose` is a
+ * no-op; a `cancel()` before the result settles flips the stop reason to
+ * `aborted`, so the cancellation path is observable in a test.
+ */
+class MockSubagentProvider implements SubagentProvider {
+  readonly capabilities: SubagentCapabilities
+
+  constructor(
+    readonly name: string,
+    private readonly config: Config,
+  ) {
+    this.capabilities = { ...DEFAULT_CAPS, ...config.capabilities }
+  }
+
+  start(request: SubagentStartRequest): SubagentRun {
+    const reply = this.config.reply ?? 'mock subagent reply'
+    const output: ContentBlock[] = [{ type: 'text', text: reply }]
+    const wantsStructured = request.outputSchema !== undefined && this.capabilities.outputSchema
+    const baseStop: SubagentStopReason = this.config.stopReason ?? 'completed'
+    let cancelled = false
+
+    // A deterministic child id derived from the parent — no clock/random (both
+    // banned in deterministic paths here, and unnecessary for a scripted run).
+    const id = AgentId(`mock-subagent:${this.name}:${request.parent.id}`)
+
+    const resultFor = (): SubagentResult => ({
+      output,
+      structured: wantsStructured ? (this.config.structured ?? { reply }) : undefined,
+      stopReason: cancelled ? 'aborted' : baseStop,
+    })
+
+    return {
+      id,
+      result: Promise.resolve().then(resultFor),
+      cancel() {
+        cancelled = true
+      },
+      async dispose() {
+        // Scripted run holds no resources — nothing to await.
+      },
+    }
+  }
+}
+
+export const name = 'subagent-mock'
+export const inject = ['subagents']
+
+/** Config for the mock provider; all optional with test-friendly defaults. */
+export interface Config {
+  /** Registry name to register under. */
+  name: string
+  /** The text the scripted child "returns" as its final answer. */
+  reply?: string
+  /** The stop reason the run settles with. */
+  stopReason?: SubagentStopReason
+  /** Which start-time capabilities to advertise (default: all `true`). */
+  capabilities?: Partial<SubagentCapabilities>
+  /**
+   * Structured value surfaced when a request carries an `outputSchema` and the
+   * `outputSchema` capability is on (default: `{ reply }`).
+   */
+  structured?: unknown
+}
+
+export const Config: z<Config> = z.object({
+  name: z.string().default('mock'),
+  reply: z.string(),
+  stopReason: z.union(STOP_REASONS),
+  capabilities: z.object({
+    outputSchema: z.boolean(),
+    depthLimit: z.boolean(),
+    toolFilter: z.boolean(),
+  }),
+  structured: z.any(),
+})
+
+export function apply(ctx: Context, config: Config): void {
+  ctx.subagents.registerProvider(new MockSubagentProvider(config.name, config))
+}

+ 95 - 0
packages/support/subagent-mock/tests/subagent-mock.spec.ts

@@ -0,0 +1,95 @@
+import { describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import Loader from '@cordisjs/plugin-loader'
+import { AgentId, type Agent } from '@deepseek-ai/dsh-agent'
+import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
+import * as mock from '../src/index.ts'
+
+/** A minimal parent — the mock provider only reads `parent.id`. */
+function fakeParent(id = 'parent-1'): Agent {
+  return { id: AgentId(id) } as unknown as Agent
+}
+
+function baseRequest(over: Partial<SubagentStartRequest> = {}): SubagentStartRequest {
+  return { prompt: [{ type: 'text', text: 'task' }], parent: fakeParent(), ...over }
+}
+
+async function mount(config: Partial<mock.Config> = {}): Promise<Context> {
+  const ctx = new Context()
+  await ctx.plugin(SubagentService)
+  await ctx.plugin(mock, { name: 'mock', ...config })
+  return ctx
+}
+
+describe('dsh-subagent-mock', () => {
+  it('registers a provider on ctx.subagents and returns the scripted reply', async () => {
+    const ctx = await mount({ reply: 'hello from mock' })
+    expect(ctx.subagents.list()).toEqual(['mock'])
+
+    const run = ctx.subagents.start('mock', baseRequest())
+    await expect(run.result).resolves.toEqual({
+      output: [{ type: 'text', text: 'hello from mock' }],
+      structured: undefined,
+      stopReason: 'completed',
+    })
+  })
+
+  it('registers under a configurable name', async () => {
+    const ctx = await mount({ name: 'spawn' })
+    expect(ctx.subagents.list()).toEqual(['spawn'])
+  })
+
+  it('surfaces a structured result when the request carries an outputSchema', async () => {
+    const ctx = await mount({ reply: 'r', structured: { answer: 42 } })
+    const run = ctx.subagents.start('mock', baseRequest({ outputSchema: { answer: { type: 'number' } } }))
+    await expect(run.result).resolves.toMatchObject({ structured: { answer: 42 } })
+  })
+
+  it('omits structured output when outputSchema capability is off', async () => {
+    const ctx = await mount({ capabilities: { outputSchema: false } })
+    // The service rejects an outputSchema request against a no-cap provider, so
+    // the structured path is only reachable when the cap is on; with it off and
+    // no schema requested, the result has no structured field.
+    const run = ctx.subagents.start('mock', baseRequest())
+    await expect(run.result).resolves.toMatchObject({ structured: undefined })
+  })
+
+  it('honors a configured stop reason', async () => {
+    const ctx = await mount({ stopReason: 'refusal' })
+    const run = ctx.subagents.start('mock', baseRequest())
+    await expect(run.result).resolves.toMatchObject({ stopReason: 'refusal' })
+  })
+
+  it('flips the stop reason to aborted when cancelled before the result settles', async () => {
+    const ctx = await mount()
+    const run = ctx.subagents.start('mock', baseRequest())
+    run.cancel()
+    await expect(run.result).resolves.toMatchObject({ stopReason: 'aborted' })
+  })
+
+  it('unregisters the provider when the owning fiber is disposed (HMR safety)', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SubagentService)
+    const fiber = await ctx.plugin(mock, { name: 'mock' })
+    expect(ctx.subagents.list()).toEqual(['mock'])
+    await fiber.dispose()
+    expect(ctx.subagents.list()).toEqual([])
+  })
+
+  it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/inject/Config/apply', () => {
+    // Postmortem 0001 guard: this plugin HAS `inject = ['subagents']`, so a stray
+    // `export default apply` would collapse the module via `unwrapExports`
+    // (`exports.default ?? exports`), DROP `inject`, and crash at load with
+    // "cannot get property … without inject". Guard the shape directly.
+    expect('default' in mock).toBe(false)
+    expect(mock.name).toBe('subagent-mock')
+    expect(mock.inject).toEqual(['subagents'])
+
+    const loader = Object.create(Loader.prototype) as Loader
+    const unwrapped = loader.unwrapExports(mock) as Record<string, unknown>
+    expect(unwrapped).toBe(mock)
+    expect(unwrapped.name).toBe('subagent-mock')
+    expect(unwrapped.inject).toEqual(['subagents'])
+    expect(typeof unwrapped.apply).toBe('function')
+  })
+})

+ 30 - 0
packages/support/subagent-mock/tsconfig.json

@@ -0,0 +1,30 @@
+{
+  "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": "../../subagent/subagent"
+    }
+  ]
+}

+ 68 - 0
pnpm-lock.yaml

@@ -318,6 +318,52 @@ 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:
+    devDependencies:
+      '@deepseek-ai/dsh-agent':
+        specifier: workspace:^
+        version: link:../../core/agent
+      '@deepseek-ai/dsh-llm':
+        specifier: workspace:^
+        version: link:../../llm/llm
+      '@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:
+        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-llm':
+        specifier: workspace:^
+        version: link:../../llm/llm
+      '@deepseek-ai/dsh-subagent':
+        specifier: workspace:^
+        version: link:../subagent
+      '@deepseek-ai/dsh-subagent-mock':
+        specifier: workspace:^
+        version: link:../../support/subagent-mock
+      '@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/support/invariants:
     devDependencies:
       '@deepseek-ai/dsh-agent':
@@ -345,6 +391,28 @@ 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/support/subagent-mock:
+    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-llm':
+        specifier: workspace:^
+        version: link:../../llm/llm
+      '@deepseek-ai/dsh-subagent':
+        specifier: workspace:^
+        version: link:../../subagent/subagent
+      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/support/ui-stdio:
     dependencies:
       schemastery:

+ 1 - 0
tsconfig.base.json

@@ -43,6 +43,7 @@
         "./packages/core/*/src",
         "./packages/llm/*/src",
         "./packages/bash/*/src",
+        "./packages/subagent/*/src",
         "./packages/session-persistence/*/src",
         "./packages/ui/*/src",
         "./packages/util/*/src",

+ 4 - 1
tsconfig.build.json

@@ -31,6 +31,9 @@
     { "path": "./packages/ui/acp-agent" },
     { "path": "./packages/ui/stdio-agent" },
     { "path": "./packages/support/ui-stdio" },
-    { "path": "./packages/support/llm-replay" }
+    { "path": "./packages/support/llm-replay" },
+    { "path": "./packages/subagent/subagent" },
+    { "path": "./packages/support/subagent-mock" },
+    { "path": "./packages/subagent/tool-subagent" }
   ]
 }

+ 1 - 0
tsconfig.typecheck.json

@@ -20,6 +20,7 @@
         "./packages/core/*/src",
         "./packages/llm/*/src",
         "./packages/bash/*/src",
+        "./packages/subagent/*/src",
         "./packages/session-persistence/*/src",
         "./packages/ui/*/src",
         "./packages/util/*/src",