From 1a81f2cccdd49df5c5a25e208b61c8681d8207d5 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 21 Jun 2026 22:31:56 +0800 Subject: [PATCH 1/4] Add subagent capability seam: interface, mock backend, model-facing tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/cordis-catalog/events-and-services.md | 39 +++- docs/module-graph.md | 13 ++ docs/rfc/README.md | 1 + .../2026-06-21-subagent-capability-seam.md | 72 ++++++ packages/README.md | 7 + packages/subagent/README.md | 12 + packages/subagent/subagent/README.md | 39 ++++ packages/subagent/subagent/package.json | 34 +++ packages/subagent/subagent/src/index.ts | 191 ++++++++++++++++ packages/subagent/subagent/src/types.ts | 172 ++++++++++++++ .../subagent/subagent/tests/service.spec.ts | 182 +++++++++++++++ packages/subagent/subagent/tsconfig.json | 27 +++ packages/subagent/tool-subagent/README.md | 18 ++ packages/subagent/tool-subagent/package.json | 42 ++++ packages/subagent/tool-subagent/src/index.ts | 147 ++++++++++++ .../tool-subagent/tests/tool-subagent.spec.ts | 210 ++++++++++++++++++ packages/subagent/tool-subagent/tsconfig.json | 33 +++ packages/support/subagent-mock/README.md | 19 ++ packages/support/subagent-mock/package.json | 38 ++++ packages/support/subagent-mock/src/index.ts | 112 ++++++++++ .../subagent-mock/tests/subagent-mock.spec.ts | 95 ++++++++ packages/support/subagent-mock/tsconfig.json | 30 +++ pnpm-lock.yaml | 68 ++++++ tsconfig.base.json | 1 + tsconfig.build.json | 5 +- tsconfig.typecheck.json | 1 + 26 files changed, 1605 insertions(+), 3 deletions(-) create mode 100644 docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md create mode 100644 packages/subagent/README.md create mode 100644 packages/subagent/subagent/README.md create mode 100644 packages/subagent/subagent/package.json create mode 100644 packages/subagent/subagent/src/index.ts create mode 100644 packages/subagent/subagent/src/types.ts create mode 100644 packages/subagent/subagent/tests/service.spec.ts create mode 100644 packages/subagent/subagent/tsconfig.json create mode 100644 packages/subagent/tool-subagent/README.md create mode 100644 packages/subagent/tool-subagent/package.json create mode 100644 packages/subagent/tool-subagent/src/index.ts create mode 100644 packages/subagent/tool-subagent/tests/tool-subagent.spec.ts create mode 100644 packages/subagent/tool-subagent/tsconfig.json create mode 100644 packages/support/subagent-mock/README.md create mode 100644 packages/support/subagent-mock/package.json create mode 100644 packages/support/subagent-mock/src/index.ts create mode 100644 packages/support/subagent-mock/tests/subagent-mock.spec.ts create mode 100644 packages/support/subagent-mock/tsconfig.json diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index 083d3295f6..8bb2b6b398 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/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.` 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.` 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. diff --git a/docs/module-graph.md b/docs/module-graph.md index 9efe6e9669..b8d582838f 100644 --- a/docs/module-graph.md +++ b/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` | diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 4eb7900276..41bb1c537f 100644 --- a/docs/rfc/README.md +++ b/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 diff --git a/docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md b/docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md new file mode 100644 index 0000000000..501416b45a --- /dev/null +++ b/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. diff --git a/packages/README.md b/packages/README.md index f39d7b70a1..c2cba3461b 100644 --- a/packages/README.md +++ b/packages/README.md @@ -11,6 +11,7 @@ Packages are grouped by modular role at `packages///`. 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` 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). diff --git a/packages/subagent/README.md b/packages/subagent/README.md new file mode 100644 index 0000000000..0fab2c610e --- /dev/null +++ b/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). diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md new file mode 100644 index 0000000000..de55315a79 --- /dev/null +++ b/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. diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json new file mode 100644 index 0000000000..5e18f5e0ce --- /dev/null +++ b/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" + } +} diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts new file mode 100644 index 0000000000..a1aa2b2d0a --- /dev/null +++ b/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() + + 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; 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 diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts new file mode 100644 index 0000000000..0e04acb317 --- /dev/null +++ b/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 + /** 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 + /** + * 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 +} diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts new file mode 100644 index 0000000000..bdccaecaab --- /dev/null +++ b/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 { + 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') + }) +}) diff --git a/packages/subagent/subagent/tsconfig.json b/packages/subagent/subagent/tsconfig.json new file mode 100644 index 0000000000..eed656aa31 --- /dev/null +++ b/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" + } + ] +} diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md new file mode 100644 index 0000000000..b53fe9ee5f --- /dev/null +++ b/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. diff --git a/packages/subagent/tool-subagent/package.json b/packages/subagent/tool-subagent/package.json new file mode 100644 index 0000000000..a7960db9bb --- /dev/null +++ b/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" + } +} diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts new file mode 100644 index 0000000000..44ed22c7ec --- /dev/null +++ b/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 = 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 => 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 { + 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() + } + }, + })) +} diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts new file mode 100644 index 0000000000..a5e0f26d7f --- /dev/null +++ b/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 = {}) { + 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 }).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 + 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() + }) +}) diff --git a/packages/subagent/tool-subagent/tsconfig.json b/packages/subagent/tool-subagent/tsconfig.json new file mode 100644 index 0000000000..896580883f --- /dev/null +++ b/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" + } + ] +} diff --git a/packages/support/subagent-mock/README.md b/packages/support/subagent-mock/README.md new file mode 100644 index 0000000000..305aea93c4 --- /dev/null +++ b/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. diff --git a/packages/support/subagent-mock/package.json b/packages/support/subagent-mock/package.json new file mode 100644 index 0000000000..ccb4eeb32f --- /dev/null +++ b/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" + } +} diff --git a/packages/support/subagent-mock/src/index.ts b/packages/support/subagent-mock/src/index.ts new file mode 100644 index 0000000000..c1a988fbfe --- /dev/null +++ b/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 + /** + * Structured value surfaced when a request carries an `outputSchema` and the + * `outputSchema` capability is on (default: `{ reply }`). + */ + structured?: unknown +} + +export const Config: z = 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)) +} diff --git a/packages/support/subagent-mock/tests/subagent-mock.spec.ts b/packages/support/subagent-mock/tests/subagent-mock.spec.ts new file mode 100644 index 0000000000..f5a567289d --- /dev/null +++ b/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 { + return { prompt: [{ type: 'text', text: 'task' }], parent: fakeParent(), ...over } +} + +async function mount(config: Partial = {}): Promise { + 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 + expect(unwrapped).toBe(mock) + expect(unwrapped.name).toBe('subagent-mock') + expect(unwrapped.inject).toEqual(['subagents']) + expect(typeof unwrapped.apply).toBe('function') + }) +}) diff --git a/packages/support/subagent-mock/tsconfig.json b/packages/support/subagent-mock/tsconfig.json new file mode 100644 index 0000000000..fd44cbde3a --- /dev/null +++ b/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" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3bca330308..4d56ed4e19 100644 --- a/pnpm-lock.yaml +++ b/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: diff --git a/tsconfig.base.json b/tsconfig.base.json index 8e2070fe28..38091c6142 100644 --- a/tsconfig.base.json +++ b/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", diff --git a/tsconfig.build.json b/tsconfig.build.json index 27a17a3f17..44f984a784 100644 --- a/tsconfig.build.json +++ b/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" } ] } diff --git a/tsconfig.typecheck.json b/tsconfig.typecheck.json index 54769bdbc0..ac9191b34b 100644 --- a/tsconfig.typecheck.json +++ b/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", From 25eccdaedcfc918a32a30592219871a807f1e8a6 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 21 Jun 2026 23:15:43 +0800 Subject: [PATCH 2/4] Fix review findings: lifecycle containment, configurable tool name, coverage, type catalog Address four findings from the first Codex review round: - Contain subagent/start|end listener throws (emitContainedStart/End): a thrown lifecycle listener could escape SubagentService.start() before the caller received the live run to dispose it (a leaked child), and a thrown subagent/end listener could surface as an unhandled rejection on the detached result-settle hook. Both emits now log-and-contain, mirroring the agent registry's agent/created|disposed containment. - Make the model-facing tool name configurable (Config.toolName, default subagent). The docs say to load dsh-tool-subagent once per provider to expose multiple transports, but the hardcoded name made the second load throw a duplicate-tool-name error; a distinct toolName per load is now required and documented. - Reach the per-file 100% coverage gate: tests for the subagent/end error branch, lifecycle-listener containment, every stopReasonError arm + the merge-extensible default, the multi-provider toolName path, agentOptions forwarding, and the direct-apply schema-bypass fallbacks. - Document the seam vocabulary in docs/core-data-structures/subagent.md with verbatim type-equiv blocks + manifest entries, and link it from core.md (a brand-new core/seam type the doc-sync gate cannot detect on its own). --- docs/core-data-structures/core.md | 1 + docs/core-data-structures/subagent.md | 88 +++++++++++++ .../2026-06-21-subagent-capability-seam.md | 2 +- packages/subagent/subagent/src/index.ts | 40 +++++- .../subagent/subagent/tests/service.spec.ts | 54 ++++++++ packages/subagent/tool-subagent/README.md | 3 +- packages/subagent/tool-subagent/src/index.ts | 11 +- .../tool-subagent/tests/tool-subagent.spec.ts | 116 +++++++++++++++++- .../subagent-mock/tests/subagent-mock.spec.ts | 6 + scripts/type-equiv.manifest.json | 9 +- 10 files changed, 319 insertions(+), 11 deletions(-) create mode 100644 docs/core-data-structures/subagent.md diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index b50c3483e4..117d1e3606 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -20,6 +20,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t | [persistence.md](persistence.md) | the durability seam: `SessionPersistence`, JSONL + SQLite backends, `session/flush`, crash recovery, `SessionHeader` | | [tools.md](tools.md) | `ToolDefinition` full fields, the schema DSL, `ToolExecution`/`ToolResult`, tool-presentation UI types, the `tools/execute` waterfall | | [bash.md](bash.md) | the bash executor seam: `BashExecRequest`/`Spec`, `BashRunResult`, background `BashTask`s | +| [subagent.md](subagent.md) | the subagent seam: the named-provider registry, `SubagentStartRequest`/`Result`/`Run`, the start-time-vs-runtime capability split | > Type definitions on this page are pasted **verbatim** from source and drift-checked by `pnpm run verify-type-equiv` (see [development.md](../development.md#documenting-types-verbatim-ts-type-equiv)). Inline JSDoc is omitted for readability; follow the source link for the full contracts. diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md new file mode 100644 index 0000000000..d9fdbfa215 --- /dev/null +++ b/docs/core-data-structures/subagent.md @@ -0,0 +1,88 @@ +# Subagent + +The subagent seam — an agent delegating work to a child agent. Like [bash](bash.md) it is **one optional capability**, not part of the agent-loop spine, so its vocabulary lives here rather than in [core.md](core.md). But it differs from every other seam on one axis: **multiple provider implementations coexist** in one context, registered by name (`ctx.subagents`), where bash allows only one executor. The registry shape mirrors the [LLM adapter registry](llm-streaming.md), not the single-service bash executor. + +Interface: [dsh-subagent](../../packages/subagent/subagent) (`ctx.subagents` + the vocabulary below). Implementations are sibling packages (`dsh-subagent-spawn`, `-fork`, `-acp`); the model-facing consumer is [dsh-tool-subagent](../../packages/subagent/tool-subagent). The proposal and rationale: [the subagent RFC](../rfc/proposed/feature/2026-06-21-subagent-capability-seam.md). + +Source: [`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts) + +## Two kinds of capability, discovered two ways + +A provider advertises its **start-time** features on a static descriptor the service checks BEFORE a run exists; a request that needs one the provider lacks is rejected loud (`SubagentError('UNSUPPORTED_CAPABILITY')`), never accepted-then-ignored. **Runtime** features (steering, resume) are instead optional methods on [`SubagentRun`](#a-live-run-subagentrun) — the method's presence IS the capability, and TS narrowing is the discovery mechanism. + +```ts type-equiv +interface SubagentCapabilities { + outputSchema: boolean + depthLimit: boolean + toolFilter: boolean +} +``` + +## The start request + +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 the start-time capabilities against the named provider, then passes it to `provider.start`. `parent` is REQUIRED — in-process backends read `parent.session.header` for the working directory, the `parentSession` lineage, and the delegation depth. The three optional fields (`outputSchema`, `maxDepth`, `toolFilter`) each gate on the matching `SubagentCapabilities` flag. + +```ts type-equiv +interface SubagentStartRequest { + prompt: ContentBlock[] + parent: Agent + signal?: AbortSignal + agentOptions?: AgentOptions + outputSchema?: SchemaSpec + maxDepth?: number + toolFilter?: { allow?: string[]; deny?: string[] } +} +``` + +## The terminal result: `SubagentResult` + +The outcome of a run, resolved by `SubagentRun.result`. `structured` is present iff the request carried an `outputSchema` AND the provider honored it. A non-`completed` `stopReason` means `output` may be partial — the consumer maps it to an `isError` tool result rather than reporting partial output as success. + +```ts type-equiv +interface SubagentResult { + output: ContentBlock[] + structured?: unknown + stopReason: SubagentStopReason +} +``` + +`SubagentStopReason` is a [merge-extensible derived union](core.md#the-map--derived-union-pattern) — a backend may add variants, so consumers branch on the known cases and treat an unknown terminal reason as a failure: + +```ts type-equiv +interface SubagentStopReasonMap { + completed: 'completed' + aborted: 'aborted' + error: 'error' + 'max-tokens': 'max-tokens' + refusal: 'refusal' +} +``` + +## A live run: `SubagentRun` + +The handle the consumer holds while a child executes. The consumer awaits `result`, may `cancel` mid-flight, and MUST `dispose` on every path to reach child quiescence (no leaked idle child / session). `result` 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` result; it rejects only on an infrastructure fault the seam cannot represent. `sendMessage` and `resume` are OPTIONAL: a provider that supports the runtime capability defines the method; one that doesn't omits it. + +```ts type-equiv +interface SubagentRun { + readonly id: AgentId + readonly result: Promise + cancel(reason?: string): void + dispose(): Promise + sendMessage?(content: ContentBlock[]): void + resume?(content: ContentBlock[]): SubagentRun +} +``` + +## The provider seam: `SubagentProvider` + +One transport for running a child agent. Implementations register under a unique name via `SubagentService.registerProvider`; multiple coexist in one context. The service validates every requested start-time capability before calling `start`, so an implementation may assume e.g. `request.maxDepth` is honorable when present. + +```ts type-equiv +interface SubagentProvider { + readonly name: string + readonly capabilities: SubagentCapabilities + start(request: SubagentStartRequest): SubagentRun +} +``` + +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 (logged, never propagated) so one bad subscriber can neither strand a live run nor surface as an unhandled rejection on the detached settle hook. diff --git a/docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md b/docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md index 501416b45a..553077d35d 100644 --- a/docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md +++ b/docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md @@ -56,7 +56,7 @@ The `dsh-tool-subagent` consumer awaits `run.result` and returns the child's fin ### 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. +`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 and a distinct `toolName` (the tool registry rejects a duplicate name). 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) diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index a1aa2b2d0a..cf42746fef 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -153,19 +153,51 @@ export class SubagentService extends Service { this.assertCapabilities(provider, request) const run = provider.start(request) - this.ctx.emit('subagent/start', { provider: name, id: run.id }) + // CONTAIN lifecycle-listener throws: the run is already live, so a throwing + // `subagent/start` listener must NOT escape `start()` (the caller would + // never receive the run to dispose it — a leaked child). Emit defensively + // and log a thrown listener, mirroring the agent registry's `agent/created` + // /`agent/disposed` containment. + this.emitContainedStart({ 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`). + // (the consumer still observes it via `run.result`). Containment also keeps + // a thrown `subagent/end` listener from becoming an unhandled rejection on + // this detached `.then`. 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' }) }, + (result) => { this.emitContainedEnd({ provider: name, id: run.id, stopReason: result.stopReason }) }, + () => { this.emitContainedEnd({ provider: name, id: run.id, stopReason: 'error' }) }, ) return run } + /** + * Emit `subagent/start`, containing a thrown listener (log, never propagate) + * so one bad subscriber cannot strand the already-live run before the caller + * receives it to dispose. + */ + private emitContainedStart(info: SubagentRunInfo): void { + try { + this.ctx.emit('subagent/start', info) + } catch (error: unknown) { + this.ctx.logger.warn(`subagent: subagent/start listener threw: ${String(error)}`) + } + } + + /** + * Emit `subagent/end`, containing a thrown listener so it cannot surface as an + * unhandled rejection on the detached result-settle hook. + */ + private emitContainedEnd(info: SubagentRunEndInfo): void { + try { + this.ctx.emit('subagent/end', info) + } catch (error: unknown) { + this.ctx.logger.warn(`subagent: subagent/end listener threw: ${String(error)}`) + } + } + /** * Reject a request that needs a start-time capability the provider lacks. * Each optional request field maps to one {@link SubagentCapabilities} flag; diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index bdccaecaab..be743a7a3f 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -173,6 +173,60 @@ describe('SubagentService', () => { expect(ended).toHaveBeenCalledWith(expect.objectContaining({ provider: 'events', id: run.id, stopReason: 'completed' })) }) + it('emits subagent/end with stopReason "error" when the run result promise rejects', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + // A provider whose run.result REJECTS (an infrastructure fault — the seam + // contract says child-level failures resolve with stopReason 'error', but a + // rejection is still surfaced as an 'error' telemetry event). + ctx.subagents.registerProvider({ + name: 'rejecter', + capabilities: NO_CAPS, + start: () => ({ + id: AgentId('rej-child'), + result: Promise.reject(new Error('infra fault')), + cancel() {}, + dispose: async () => {}, + }), + }) + + const ended = vi.fn() + ctx.on('subagent/end', ended) + const run = ctx.subagents.start('rejecter', baseRequest()) + // Observe (and swallow) the rejection the consumer would see, then let the + // detached `.then` settle the telemetry emit. + await run.result.catch(() => {}) + await Promise.resolve() + expect(ended).toHaveBeenCalledWith(expect.objectContaining({ provider: 'rejecter', id: run.id, stopReason: 'error' })) + }) + + it('contains a throwing subagent/start listener so start() still returns the run', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + ctx.subagents.registerProvider(new StubProvider('contain')) + // A bad subscriber must not strand the live run: start() returns it anyway. + ctx.on('subagent/start', () => { throw new Error('bad start listener') }) + + const run = ctx.subagents.start('contain', baseRequest()) + expect(run.id).toBeDefined() + await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' }) + }) + + it('contains a throwing subagent/end listener (no unhandled rejection on the settle hook)', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + ctx.subagents.registerProvider(new StubProvider('contain-end')) + ctx.on('subagent/end', () => { throw new Error('bad end listener') }) + + const run = ctx.subagents.start('contain-end', baseRequest()) + await run.result + // Let the detached `.then` + the contained emit run; a thrown listener here + // must be swallowed (logged), not escape as an unhandled rejection. + await Promise.resolve() + await Promise.resolve() + expect(run.id).toBeDefined() + }) + it('SubagentError extends the shared HarnessError base', () => { const err = new SubagentError('boom', 'NO_PROVIDER') expect(err).toBeInstanceOf(HarnessError) diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index b53fe9ee5f..22b66fbf80 100644 --- a/packages/subagent/tool-subagent/README.md +++ b/packages/subagent/tool-subagent/README.md @@ -4,11 +4,12 @@ The model-facing `subagent` tool: delegate a self-contained task to a child agen ## 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. +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 **and a distinct `toolName`** (the tool registry rejects a duplicate name, so a second load that kept the default `subagent` name would throw). 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`, …). | +| `toolName` | The model-facing tool name to register (default `subagent`). Set a distinct value per load when exposing multiple providers, e.g. `subagent` + `subagent_acp`. | | `agentOptions` | Default per-child `{ model?, systemPrompt? }` applied to every spawned child. | ## Lifecycle (synchronous collect) diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 44ed22c7ec..a19e09db98 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -35,6 +35,14 @@ export const inject = ['tools', 'subagents'] export interface Config { /** The `ctx.subagents` provider name to start runs on (e.g. `spawn`, `acp`). */ provider: string + /** + * The model-facing tool name to register (default `subagent`). To expose more + * than one transport, load this plugin once per provider — each load MUST set + * a distinct `toolName` (the tool registry rejects a duplicate name), e.g. + * `{ provider: 'spawn', toolName: 'subagent' }` and + * `{ provider: 'acp', toolName: 'subagent_acp' }`. + */ + toolName?: 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. @@ -44,6 +52,7 @@ export interface Config { export const Config: z = z.object({ provider: z.string().required(), + toolName: z.string().default('subagent'), agentOptions: z.object({ model: z.string(), systemPrompt: z.string(), @@ -85,7 +94,7 @@ function stopReasonError(result: SubagentResult): string | undefined { export function apply(ctx: Context, config: Config): void { ctx.tools.register(defineTool({ - name: 'subagent', + name: config.toolName ?? '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 ' diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index a5e0f26d7f..521fdfba50 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -68,11 +68,121 @@ describe('dsh-tool-subagent', () => { 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' }) + it.each([ + { stopReason: 'aborted' as const, fragment: 'cancelled' }, + { stopReason: 'error' as const, fragment: 'failed' }, + { stopReason: 'max-tokens' as const, fragment: 'token limit' }, + { stopReason: 'refusal' as const, fragment: 'declined' }, + ])('maps stop reason $stopReason to an isError result (not partial success)', async ({ stopReason, fragment }) => { + const ctx = await setup({ provider: 'mock' }, { stopReason }) const result = await callSubagent(ctx, { description: 'd', prompt: 'p' }) expect(result.isError).toBe(true) - expect(text(result)).toContain('declined') + expect(text(result)).toContain(fragment) + }) + + it('registers under a configurable toolName so multiple providers can coexist', async () => { + // The defining multi-provider use case: two loads, two distinct tool names, + // each bound to a different provider — the tool registry rejects duplicate + // names, so a configurable name is what makes this work. + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(SubagentService) + await ctx.plugin(mock, { name: 'spawn', reply: 'from spawn' }) + await ctx.plugin(mock, { name: 'acp', reply: 'from acp' }) + await ctx.plugin(tool, { provider: 'spawn', toolName: 'subagent' }) + await ctx.plugin(tool, { provider: 'acp', toolName: 'subagent_acp' }) + + const names = ctx.tools.schemas().map(s => s.name).filter(n => n.startsWith('subagent')).sort() + expect(names).toEqual(['subagent', 'subagent_acp']) + + const viaSpawn = await ctx.tools.execute({ callId: CallId('c-spawn'), name: 'subagent', arguments: { description: 'd', prompt: 'p' }, agent: fakeAgent() }) + const viaAcp = await ctx.tools.execute({ callId: CallId('c-acp'), name: 'subagent_acp', arguments: { description: 'd', prompt: 'p' }, agent: fakeAgent() }) + expect(text(viaSpawn)).toBe('from spawn') + expect(text(viaAcp)).toBe('from acp') + }) + + it('treats an unknown (plugin-added) stop reason as an isError result', async () => { + // SubagentStopReason is merge-extensible; the tool's stopReasonError default + // arm must treat an unrecognized terminal reason as a failure, not success. + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(SubagentService) + ctx.subagents.registerProvider({ + name: 'weird', + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false }, + start: () => ({ + id: AgentId('weird-child'), + result: Promise.resolve({ output: [{ type: 'text', text: 'partial' }], stopReason: 'frobnicated' as never }), + cancel() {}, + dispose: async () => {}, + }), + }) + await ctx.plugin(tool, { provider: 'weird' }) + + const result = await callSubagent(ctx, { description: 'd', prompt: 'p' }) + expect(result.isError).toBe(true) + expect(text(result)).toContain('abnormally') + }) + + it('forwards configured agentOptions into the start request', async () => { + // Cover the `config.agentOptions ? … : {}` spread: a provider that captures + // the request lets us assert the agentOptions reached it. + let seen: { agentOptions?: { model?: string } } | undefined + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(SubagentService) + ctx.subagents.registerProvider({ + name: 'capture', + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false }, + start: (request) => { + seen = request + return { + id: AgentId('capture-child'), + result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), + cancel() {}, + dispose: async () => {}, + } + }, + }) + await ctx.plugin(tool, { provider: 'capture', agentOptions: { model: 'child-model', systemPrompt: 'be terse' } }) + + await callSubagent(ctx, { description: 'd', prompt: 'p' }) + expect(seen?.agentOptions).toEqual({ model: 'child-model', systemPrompt: 'be terse' }) + }) + + it('defaults toolName and omits agentOptions when apply() is called directly (schema bypass)', async () => { + // `ctx.plugin` validates+defaults config first (toolName→'subagent', the + // agentOptions object→{}), so the runtime `?? 'subagent'` fallback and the + // no-agentOptions branch are only reachable via a direct apply() that + // bypasses schemastery — the same pattern acp-agent uses for its defaults. + let seen: { agentOptions?: unknown } | undefined + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(SubagentService) + ctx.subagents.registerProvider({ + name: 'bare', + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false }, + start: (request) => { + seen = request + return { + id: AgentId('bare-child'), + result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), + cancel() {}, + dispose: async () => {}, + } + }, + }) + // Direct apply with only `provider` — no toolName, no agentOptions. + tool.apply(ctx, { provider: 'bare' }) + await new Promise(r => setTimeout(r, 10)) + + expect(ctx.tools.schemas().some(s => s.name === 'subagent')).toBe(true) + await callSubagent(ctx, { description: 'd', prompt: 'p' }) + expect(seen?.agentOptions).toBeUndefined() }) it('fails loud when invoked without a calling agent', async () => { diff --git a/packages/support/subagent-mock/tests/subagent-mock.spec.ts b/packages/support/subagent-mock/tests/subagent-mock.spec.ts index f5a567289d..f35ed884eb 100644 --- a/packages/support/subagent-mock/tests/subagent-mock.spec.ts +++ b/packages/support/subagent-mock/tests/subagent-mock.spec.ts @@ -45,6 +45,12 @@ describe('dsh-subagent-mock', () => { await expect(run.result).resolves.toMatchObject({ structured: { answer: 42 } }) }) + it('defaults structured output to { reply } when outputSchema is requested but no structured value is configured', async () => { + const ctx = await mount({ reply: 'fallback reply' }) + const run = ctx.subagents.start('mock', baseRequest({ outputSchema: { answer: { type: 'number' } } })) + await expect(run.result).resolves.toMatchObject({ structured: { reply: 'fallback reply' } }) + }) + 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 diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index f46c4fca6b..7872ec8620 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -35,6 +35,13 @@ { "doc": "docs/core-data-structures/bash.md", "symbol": "BashRunResult", "source": "packages/bash/bash/src/types.ts" }, { "doc": "docs/core-data-structures/bash.md", "symbol": "CollectedOutput", "source": "packages/bash/bash/src/types.ts" }, { "doc": "docs/core-data-structures/bash.md", "symbol": "BashTask", "source": "packages/bash/bash/src/types.ts" }, - { "doc": "docs/core-data-structures/bash.md", "symbol": "BashTaskRead", "source": "packages/bash/bash/src/types.ts" } + { "doc": "docs/core-data-structures/bash.md", "symbol": "BashTaskRead", "source": "packages/bash/bash/src/types.ts" }, + + { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentCapabilities", "source": "packages/subagent/subagent/src/types.ts" }, + { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentStartRequest", "source": "packages/subagent/subagent/src/types.ts" }, + { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentResult", "source": "packages/subagent/subagent/src/types.ts" }, + { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentStopReasonMap", "source": "packages/subagent/subagent/src/types.ts" }, + { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentRun", "source": "packages/subagent/subagent/src/types.ts" }, + { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentProvider", "source": "packages/subagent/subagent/src/types.ts" } ] } From 861791d2d8fdebed536985e2dec015b92792653f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 21 Jun 2026 23:44:07 +0800 Subject: [PATCH 3/4] Contain subagent lifecycle listeners per-listener, not per-emit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single try/catch around ctx.emit prevented a thrown subagent/start or subagent/end listener from propagating, but cordis emit dispatches listeners in a `.map(cb => cb())` that HALTS on the first throw — so a bad subscriber still starved the listeners registered after it, violating the AGENTS.md callback-boundary rule ("one bad subscriber must not starve the listeners after it"). Resolve the listener callbacks via ctx.events.dispatch and contain each call individually, the same per-listener guarantee BashExecutor.notifyTaskDone gives its own listener set. The two containment tests now register TWO listeners where the first throws and assert the second still observes the event (start) and the settle (end) — a regression that fails on the per-emit code (verified: reverted, watched both go red, restored). --- docs/core-data-structures/subagent.md | 2 +- packages/subagent/subagent/src/index.ts | 63 ++++++++++--------- .../subagent/subagent/tests/service.spec.ts | 18 ++++-- 3 files changed, 45 insertions(+), 38 deletions(-) diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index d9fdbfa215..5ae83b40fa 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -85,4 +85,4 @@ 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 (logged, never propagated) so one bad subscriber can neither strand a live run nor surface as an unhandled rejection on the detached settle hook. +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. diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index cf42746fef..c7d954f09c 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -153,48 +153,49 @@ export class SubagentService extends Service { this.assertCapabilities(provider, request) const run = provider.start(request) - // CONTAIN lifecycle-listener throws: the run is already live, so a throwing - // `subagent/start` listener must NOT escape `start()` (the caller would - // never receive the run to dispose it — a leaked child). Emit defensively - // and log a thrown listener, mirroring the agent registry's `agent/created` - // /`agent/disposed` containment. - this.emitContainedStart({ provider: name, id: run.id }) + // Emit `subagent/start` with PER-LISTENER containment (see {@link emitLifecycle}): + // the run is already live, so neither a throwing subscriber escaping + // `start()` (the caller would never receive the run to dispose it — a leaked + // child) NOR one bad subscriber starving the listeners after it is + // acceptable. `ctx.emit` halts the dispatch on the first throw, so a single + // surrounding try/catch is not enough — each listener is invoked and + // contained individually. + this.emitLifecycle('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`). Containment also keeps - // a thrown `subagent/end` listener from becoming an unhandled rejection on - // this detached `.then`. + // (the consumer still observes it via `run.result`). Per-listener + // containment also keeps a thrown `subagent/end` listener from becoming an + // unhandled rejection on this detached `.then`. void run.result.then( - (result) => { this.emitContainedEnd({ provider: name, id: run.id, stopReason: result.stopReason }) }, - () => { this.emitContainedEnd({ provider: name, id: run.id, stopReason: 'error' }) }, + (result) => { this.emitLifecycle('subagent/end', { provider: name, id: run.id, stopReason: result.stopReason }) }, + () => { this.emitLifecycle('subagent/end', { provider: name, id: run.id, stopReason: 'error' }) }, ) return run } /** - * Emit `subagent/start`, containing a thrown listener (log, never propagate) - * so one bad subscriber cannot strand the already-live run before the caller - * receives it to dispose. + * Emit a `subagent/*` lifecycle event with PER-LISTENER containment: dispatch + * each subscriber individually and log (never propagate) a thrown one, so one + * bad subscriber can neither strand the already-live run, surface as an + * unhandled rejection on the detached settle hook, NOR starve the listeners + * registered after it. A single try/catch around `ctx.emit` would not do the + * last part — cordis `emit` runs listeners in a `.map(cb => cb())` that halts + * on the first throw — so this resolves the listener callbacks via + * `ctx.events.dispatch` and contains each call, the same guarantee + * `BashExecutor.notifyTaskDone` gives its own listener set. */ - private emitContainedStart(info: SubagentRunInfo): void { - try { - this.ctx.emit('subagent/start', info) - } catch (error: unknown) { - this.ctx.logger.warn(`subagent: subagent/start listener threw: ${String(error)}`) - } - } - - /** - * Emit `subagent/end`, containing a thrown listener so it cannot surface as an - * unhandled rejection on the detached result-settle hook. - */ - private emitContainedEnd(info: SubagentRunEndInfo): void { - try { - this.ctx.emit('subagent/end', info) - } catch (error: unknown) { - this.ctx.logger.warn(`subagent: subagent/end listener threw: ${String(error)}`) + private emitLifecycle( + name: 'subagent/start' | 'subagent/end', + info: SubagentRunInfo | SubagentRunEndInfo, + ): void { + for (const callback of this.ctx.events.dispatch('emit', [name, info])) { + try { + callback(info) + } catch (error: unknown) { + this.ctx.logger.warn(`subagent: ${name} listener threw: ${String(error)}`) + } } } diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index be743a7a3f..6876c6cd80 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -200,31 +200,37 @@ describe('SubagentService', () => { expect(ended).toHaveBeenCalledWith(expect.objectContaining({ provider: 'rejecter', id: run.id, stopReason: 'error' })) }) - it('contains a throwing subagent/start listener so start() still returns the run', async () => { + it('contains a throwing subagent/start listener per-listener: a later listener still observes the event and start() returns the run', async () => { const ctx = new Context() await ctx.plugin(SubagentService) ctx.subagents.registerProvider(new StubProvider('contain')) - // A bad subscriber must not strand the live run: start() returns it anyway. + // Two listeners; the FIRST throws. Per-listener containment means the second + // must STILL run (a single try/catch around ctx.emit would let the first + // throw halt the dispatch and starve the second — the round-2 regression). + const second = vi.fn() ctx.on('subagent/start', () => { throw new Error('bad start listener') }) + ctx.on('subagent/start', second) const run = ctx.subagents.start('contain', baseRequest()) expect(run.id).toBeDefined() + expect(second).toHaveBeenCalledWith(expect.objectContaining({ provider: 'contain', id: run.id })) await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' }) }) - it('contains a throwing subagent/end listener (no unhandled rejection on the settle hook)', async () => { + it('contains a throwing subagent/end listener per-listener: a later listener still observes the settle, no unhandled rejection', async () => { const ctx = new Context() await ctx.plugin(SubagentService) ctx.subagents.registerProvider(new StubProvider('contain-end')) + const second = vi.fn() ctx.on('subagent/end', () => { throw new Error('bad end listener') }) + ctx.on('subagent/end', second) const run = ctx.subagents.start('contain-end', baseRequest()) await run.result - // Let the detached `.then` + the contained emit run; a thrown listener here - // must be swallowed (logged), not escape as an unhandled rejection. + // Let the detached `.then` + the contained emit run. await Promise.resolve() await Promise.resolve() - expect(run.id).toBeDefined() + expect(second).toHaveBeenCalledWith(expect.objectContaining({ provider: 'contain-end', id: run.id, stopReason: 'completed' })) }) it('SubagentError extends the shared HarnessError base', () => { From 87231863984cd63a52e782849229c62bf0ce0852 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 22 Jun 2026 16:32:10 +0800 Subject: [PATCH 4/4] Honor an already-aborted signal in the subagent tool bridge (review feedback) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit addEventListener('abort') does not fire for a signal already aborted before the listener is added, so a parent step cancelled before the subagent tool ran would never reach the child — the tool leaned on each provider re-checking request.signal itself, leaving the bridge's own claim incomplete for any provider that relies on run.cancel(). Re-check exec.signal.aborted right after registering and cancel explicitly. Regression test uses a spy provider that only reacts to cancel() (never inspects the signal); proven to hang without the fix (result never settles) and settle aborted with it. --- packages/subagent/tool-subagent/src/index.ts | 5 +++ .../tool-subagent/tests/tool-subagent.spec.ts | 37 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index a19e09db98..05490127ea 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -136,6 +136,11 @@ export function apply(ctx: Context, config: Config): void { // 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 }) + // `addEventListener` does NOT fire for a signal already aborted before this + // line, so a step cancelled before the tool ran would never reach the + // child. Cancel explicitly in that case — the bridge must honor an + // already-aborted signal, not lean on each provider re-checking it. + if (exec.signal?.aborted) run.cancel('parent step aborted') try { const result = await run.result diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 521fdfba50..dda4e7c3d0 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -282,6 +282,43 @@ describe('dsh-tool-subagent', () => { expect(result.isError).toBe(true) }) + it('cancels the run when the tool signal is ALREADY aborted before execute (no missed abort)', async () => { + // `addEventListener('abort')` does not fire for a signal already aborted + // before the listener is added, so a step cancelled before the tool ran + // would never reach the child unless the bridge re-checks `signal.aborted`. + // A provider that leans only on the abort EVENT (this spy never inspects + // request.signal) proves the bridge itself must cancel. + 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() + controller.abort() // already aborted BEFORE the tool runs + const result = await callSubagent(ctx, { description: 'd', prompt: 'p' }, { signal: controller.signal }) + 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)