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).
This commit is contained in:
Tianyi Cui
2026-06-21 23:15:43 +08:00
parent 1a81f2cccd
commit 25eccdaedc
10 files changed
+319 -11

No files matched your search

+1
View File
@@ -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.
+88
View File
@@ -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<SubagentResult>
cancel(reason?: string): void
dispose(): Promise<void>
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.
@@ -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)
+36 -4
View File
@@ -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;
@@ -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)
+2 -1
View File
@@ -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)
+10 -1
View File
@@ -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<Config> = 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 '
@@ -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 () => {
@@ -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
+8 -1
View File
@@ -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" }
]
}