Merge PR #224 updates into prose cleanup

This commit is contained in:
Tianyi Cui
2026-07-12 23:36:49 +08:00
165 changed files with 11693 additions and 6395 deletions

View File

@@ -1,32 +1,31 @@
# @deepseek-ai/dsh-subagent-acp
The out-of-process **ACP subagent backend**: runs each child agent in a spawned subprocess, driven over the [Agent Client Protocol](https://github.com/zed-industries/agent-client-protocol) (ACP) as the *client*. Registers a `SubagentProvider` on `ctx.subagents` (the [subagent seam](../subagent)), alongside the in-process [`-spawn`](../subagent-spawn)/[`-fork`](../subagent-fork) backends — multiple backends coexist by name.
The ACP provider runs each subagent in a fresh subprocess and drives it as an Agent Client Protocol client. It is the out-of-process alternative to spawn and fork: the child has its own runtime, session, model configuration, and tools.
It is the direction-inverted twin of the server-side bridge in [`@deepseek-ai/dsh-acp`](../../ui/acp): that package is the ACP *agent* (it answers `initialize`/`newSession`/`prompt`); this one is the ACP *client* (it *calls* them and implements the `sessionUpdate`/`requestPermission` callbacks). Point the configured command at the `acp-agent` example to "talk to our own process".
## Start and ownership
## What it does
`start(request)` performs `spawn` → ACP `initialize``newSession` before it fulfills. Fulfillment therefore means a remote session is ready and ownership has transferred to the caller. A spawn, initialization, new-session, or pre-publication cancellation failure rejects only after the subprocess has been reaped.
`start(request)` spawns the configured command, wraps its stdio in an ACP `ClientSideConnection`, and drives one session: `initialize``newSession``prompt`. `run.started` resolves after `newSession` publishes the remote session and rejects when initialization fails or cancellation wins first; the service emits no start/end pair for a child that never became live. The child's streamed `agent_message_chunk` text becomes the `SubagentResult.output`; the prompt's terminal `StopReason` maps to the stop reason. `dispose()` kills the subprocess and awaits its exit.
After publication, the provider sends the prompt and collects streamed `agent_message_chunk` text into `SubagentResult.output`. A prompt/transport failure resolves with `stopReason: 'error'`, or `aborted` when the required request signal or disposal requested cancellation.
**Fresh process per run.** Each `start` spawns a new child, runs exactly one ACP session, and disposes it. Persistent-process pooling is a future optimization (see the RFC).
`dispose()` is idempotent. It removes the signal listener, requests ACP cancellation when possible, closes stdin, waits `disposeEofGraceMs`, escalates to SIGTERM, waits `disposeGraceMs`, and finally uses SIGKILL if necessary. Every run uses a fresh process; process pooling is not implemented.
Unlike the in-process backends, the child does NOT share this cordis context — it is a separate process with its own session, model client, and tools. So this backend:
- injects only `subagents` (no `ctx.agents`);
- advertises NO start-time capabilities (an out-of-process child can't enforce the parent's depth/tool-filter);
- ignores `request.parent`.
## Capabilities and context
## Config
ACP advertises no start-time capabilities because this process cannot enforce the remote child's depth, tool filter, persona, or structured-output runtime. It also reports `inheritsParentContext: false`: the remote session starts fresh and ignores `request.parent` beyond the seam's required attribution field.
| Key | Type | Default | Notes |
|---|---|---|---|
| `providerName` | string | `acp` | Registry name on `ctx.subagents`. |
| `command` | string | — (required) | The executable to spawn for each run (the child ACP agent). |
| `args` | string[] | `[]` | Arguments passed to `command`. |
| `cwd` | string | process cwd | Working directory for the child process and its ACP session. |
| `permission` | `'allow' \| 'reject'` | `reject` | How to auto-answer the child's `session/request_permission` prompts. `reject` declines every prompt (answer `cancelled`); `allow` approves via the first allow-shaped option. The first cut surfaces no prompt to a human. |
| `env` | Record<string,string> | `{}` | Extra env vars for the child (e.g. its own `DEEPSEEK_API_KEY`). Forwarded on top of a credential-scrubbed copy of the parent env, so an explicit key reaches the child while ambient secrets do not leak implicitly. |
| `disposeEofGraceMs` | number | `6000` | Dispose ladder tier 1: how long the child gets to quiesce on its own after stdin EOF (flush persistence, tear down its nested subprocesses) before SIGTERM. |
| `disposeGraceMs` | number | `3000` | Dispose ladder tier 2: grace between SIGTERM and the SIGKILL escalation. |
## Configuration
| Key | Default | Meaning |
|---|---|---|
| `providerName` | `acp` | Registry name on `ctx.subagents`. |
| `command` | required | Executable spawned for each run. |
| `args` | `[]` | Command arguments. |
| `cwd` | process cwd | Child process and ACP session working directory. |
| `permission` | `reject` | Auto-answer permission requests by rejecting or choosing the first allow-shaped option. |
| `env` | `{}` | Explicit child environment layered over a credential-scrubbed parent environment. |
| `disposeEofGraceMs` | `6000` | Grace after stdin EOF before SIGTERM. |
| `disposeGraceMs` | `3000` | Grace after SIGTERM before SIGKILL. |
```yaml
- id: subagent-acp
@@ -40,32 +39,20 @@ Unlike the in-process backends, the child does NOT share this cordis context —
DEEPSEEK_API_KEY: !!js process.env.DEEPSEEK_API_KEY
```
## StopReason mapping
## Stop-reason mapping
ACP `StopReason` → harness `SubagentStopReason`:
| ACP | harness |
| ACP | Harness |
|---|---|
| `end_turn` | `completed` |
| `max_tokens` | `max-tokens` |
| `refusal` | `refusal` |
| `cancelled` | `aborted` |
| `max_turn_requests` | `error` (no clean equivalent; the task did not finish) |
| _(unknown)_ | `error` |
| `max_turn_requests` or unknown | `error` |
A spawn/transport/RPC failure resolves `error` (or `aborted` if a cancel was requested) — `result` never rejects on a child-level failure, per the seam contract.
## Process boundary
## Environment scrub
The child environment is built by [`buildChildEnv`](../subagent-subprocess/README.md): credential-shaped ambient variables are removed, then explicit `config.env` values are applied. The ACP wire is the real serialization boundary; same-process subagent values are not defensively cloned.
The child env is built by [`buildChildEnv` from `@deepseek-ai/dsh-subagent-subprocess`](../subagent-subprocess/README.md) — the ambient env minus credential-shaped vars, with `config.env` layered on top after the scrub; the pattern and full semantics live there. For this backend that means the parent harness's own secrets never leak into the spawned agent implicitly, while the child's OWN `DEEPSEEK_API_KEY` is supplied deliberately via `config.env` and survives.
The package has no default export. Cordis loader unwrapping would otherwise hide the named `inject` metadata; see [postmortem 0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md).
## Testing
- **Keyless** (`subagent-acp.spec.ts`): spawns a scripted mock ACP server subprocess (`tests/mock-acp-server.ts`) and drives it through the real backend over real ACP stdio — connection setup, client callbacks, the prompt round-trip, stop-reason mapping, cancellation (including the early-cancel race and a torn-pipe-after-cancel), permission auto-answer, and quiescent disposal. No model, no key.
- **With-key e2e** (`subagent-acp.e2e.ts`): the harness drives ITSELF — the backend spawns the real `acp-agent` example process and a real model in that child answers a prompt and does real file work (verified on disk). Self-skips without `DEEPSEEK_API_KEY`.
`TODO(acp-subagent-replay)`: snapshot-tier coverage of an ACP child is a separate replay shape (each child is its own PROCESS with its own single-agent replay, distinct from the in-process per-session keying), deferred — see the RFC.
## Plugin export shape
Named `name` / `inject` / `Config` / `apply`, with **no default export**: the cordis Loader's `unwrapExports` does `exports.default ?? exports`, so a stray default would collapse the module to the bare function and drop the `inject` namespace (see [docs/postmortem/0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md)).
Keyless tests drive a scripted ACP subprocess over real stdio. The with-key e2e drives the repository's real ACP agent and self-skips without `DEEPSEEK_API_KEY`.

View File

@@ -1,5 +1,24 @@
/**
* The out-of-process ACP subagent run driver.
* The out-of-process ACP subagent run driver. Spawns a child agent as a
* subprocess, speaks the Agent Client Protocol (ACP) to it over stdio as the
* CLIENT, drives one session to completion, and shapes the result into a
* {@link SubagentResult}. The mirror image of the server-side bridge in
* `@deepseek-ai/dsh-acp` (which is the ACP *agent* side): here we are the ACP
* *client*, so we CALL `initialize`/`newSession`/`prompt`/`cancel` and we
* IMPLEMENT the `Client` callbacks (`sessionUpdate`, `requestPermission`).
*
* One subprocess per run (fresh-process-per-run): `start` spawns, runs exactly
* one ACP session, and `dispose` kills the subprocess and awaits its exit.
* Persistent-process pooling is a future optimization (see the RFC).
*
* TODO(acp-subagent-replay): snapshot-tier coverage of an ACP child is a
* distinct replay shape — each child is its own PROCESS with its own
* single-agent replay (the child boots under `DSH_SNAPSHOT=replay` with its own
* sessions-root + fixture), unlike the in-process per-session keying in
* `dsh-llm-replay`. Deferred to a follow-up; keyless coverage here is via a
* scripted mock ACP server subprocess, and the with-key e2e drives the real
* `acp-agent` example. See the ACP-subagent-backend RFC.
*
* @module @deepseek-ai/dsh-subagent-acp/run
*/
@@ -77,9 +96,16 @@ export interface AcpRunSpec {
}
/**
* Default grace for the child's EOF-driven quiesce on dispose (the `disposeEofGraceMs` config)
* — the window for it to flush persistence and tear down its own nested subprocesses (which
* may run their own `SIGTERM`→`SIGKILL` escalation) before the parent escalates to a signal.
* Default grace for the child's EOF-driven quiesce on dispose (the
* `disposeEofGraceMs` config) — the window for it to flush persistence and tear
* down its OWN nested subprocesses (which may run their own `SIGTERM`→`SIGKILL`
* escalation) before the parent escalates to a signal. Deliberately LARGER than
* {@link DEFAULT_DISPOSE_GRACE_MS}: a cooperative child whose teardown is itself
* waiting on a signal-trapping grandchild (e.g. a bash subprocess in its own ~3s
* SIGTERM→SIGKILL grace) plus a final flush needs MORE than a single
* signal-grace of headroom, or the parent's SIGTERM cuts it off exactly as it
* reaches its own SIGKILL+flush. The child is an arbitrary ACP agent, so this is
* a standalone generous default, NOT derived from any child's internals.
*/
export const DEFAULT_DISPOSE_EOF_GRACE_MS = 6_000
@@ -150,32 +176,23 @@ function toError(value: unknown): Error {
/**
* Start an out-of-process ACP child for `request` and return a {@link SubagentRun}.
*
* @param request - the start request; the driver consumes `prompt` and `signal`
* (an already-aborted signal yields an inert `aborted` run with no spawn).
* Spawns the configured command, wraps its stdio in an ACP `ClientSideConnection`,
* and drives one session: `initialize` → `newSession` → `prompt`. The accumulated
* `agent_message_chunk` text is the result output; the prompt's terminal
* `StopReason` maps to the stop reason. `result` never REJECTS on a child-level
* failure after publication resolves with `stopReason: 'error'`. A spawn,
* initialize, new-session, or pre-publication cancellation failure instead
* rejects only after the process has been reaped. `dispose()` requests ACP
* cancellation, then kills and reaps the subprocess.
* @param request - the start request; its signal is the cancellation channel.
* @param spec - the resolved spawn spec: command/args/cwd, env, permission
* policy, dispose graces, and the optional error sink.
* @returns the live run handle for the child subprocess.
* @returns the ready run handle for the child subprocess.
*/
export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): SubagentRun {
export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Promise<SubagentRun> {
const id = AgentId(randomUUID())
// A request already aborted before it starts never spawns the child at all —
// return an inert run that settled `aborted`, rather than launching the
// configured binary just to tear it down. `dispose`/`cancel` are no-ops.
if (request.signal?.aborted) {
const started = Promise.reject(new Error('subagent request was aborted before the ACP child started'))
// The result is derived from the same boundary so the readiness rejection
// is observed even when this provider is driven directly rather than
// through SubagentService.
const result: Promise<SubagentResult> = started.catch(() => ({ output: [], stopReason: 'aborted' }))
return {
id,
started,
result,
cancel(_reason?: string): void { /* nothing was started */ },
dispose(): Promise<void> { return Promise.resolve() },
}
}
if (request.signal.aborted) throw new Error('subagent request was aborted before the ACP child started')
// Spawn the child ACP agent. stdin = ACP request channel, stdout = ACP
// response channel, stderr = INHERIT so the child's diagnostics surface on the
@@ -192,10 +209,23 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su
// `error` like any child failure.
const spawnFailed = spawnFailure(child)
// One memoized quiescence transaction is shared by startup rollback and the
// published run's disposer. Once start fulfills, only the holder can invoke
// it; before fulfillment the provider invokes it on every failure path.
let processDisposal: Promise<void> | undefined
const disposeProcess = (): Promise<void> => (processDisposal ??= disposeChildProcess(child, {
disposeEofGraceMs: spec.disposeEofGraceMs,
disposeGraceMs: spec.disposeGraceMs,
}))
// Accumulate the child's streamed assistant text — the SubagentResult output.
const output: string[] = []
// `cancelled` records that a cancel was requested (signal or cancel()), so a run torn down
// before the prompt resolves settles `aborted` rather than the generic error mapping.
// `cancelled` records that the required signal or disposal requested cancel, so a
// run torn down before the prompt resolves settles `aborted` rather than the
// generic error mapping. Held on a mutable object so the async closures that
// set it (the abort listener) and the IIFE that reads it don't fight TS's
// control-flow narrowing of a bare `let` (which would type the catch-time read
// as always-`false`).
const flags = { cancelled: false }
const makeClient = (_agent: AcpAgent): Client => ({
@@ -231,19 +261,33 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su
)
let sessionId: string | undefined
// Resolves when a cancel is requested, so `result` can settle `aborted` even if the child
// never cooperates with `session/cancel` (it ignores the notify, or the prompt wedges).
// Resolves when a cancel is requested, so `result` can settle `aborted` even
// if the child never cooperates with `session/cancel` (it ignores the notify,
// or the prompt wedges). The result path races this against the ACP drive: the
// FIRST to settle wins, so signal/dispose cancellation always honors the contract (`result`
// settles `aborted`) without waiting on a non-cooperative child. `dispose`
// still kills the process and reaps it; this only unblocks `result`. The
// executor runs synchronously, so `signalCancelSettled` is assigned before the
// Promise constructor returns (the `!` asserts the definite assignment).
let signalCancelSettled!: () => void
const cancelSettled = new Promise<void>((resolve) => { signalCancelSettled = resolve })
const requestCancel = (): void => {
if (flags.cancelled) return
flags.cancelled = true
signalCancelSettled()
// Best-effort: tell the child to cancel the in-flight turn.
// Best-effort: tell the child to cancel the in-flight turn. Swallows a
// rejection — the session may not exist yet, or the pipe may be gone; the
// dispose path kills the process regardless. If the session has NOT been
// created yet (cancel raced ahead of `newSession`), the `cancelled` flag
// alone carries it: the result path re-checks the flag after each await and
// settles `aborted` without running the prompt. The `.catch` swallow is
// defensive for a narrow transport race (child gone mid-send) — v8-ignored
// because dispose kills the process regardless, so it can't be hit in tests.
/* v8 ignore next */
if (sessionId !== undefined) void conn.cancel({ sessionId }).catch(() => { /* child gone / no session */ })
}
const onAbort = (): void => { requestCancel() }
request.signal?.addEventListener('abort', onAbort, { once: true })
request.signal.addEventListener('abort', onAbort, { once: true })
// The accumulated child text as harness ContentBlocks (empty array when the
// child streamed nothing). Read at every return so a partial answer survives
@@ -253,36 +297,41 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su
return text.length > 0 ? [{ type: 'text', text }] : []
}
// A provider is "started" only once the remote child has completed ACP initialization and
// published a session.
const started: Promise<void> = Promise.race([
(async (): Promise<void> => {
await conn.initialize({
protocolVersion: PROTOCOL_VERSION,
// Advertise NO optional client capabilities (no fs, no terminal): the
// child self-serves in its own process.
clientCapabilities: {},
})
const session = await conn.newSession({ cwd: spec.cwd, mcpServers: [] })
sessionId = session.sessionId
if (flags.cancelled) throw new Error('subagent cancelled before the ACP session started')
})(),
spawnFailed.then((err): never => { throw err }),
cancelSettled.then((): never => { throw new Error('subagent cancelled before the ACP session started') }),
])
// Establish the remote session before publishing a handle. Any failure owns
// the still-private process and therefore reaps it before rejecting.
try {
await Promise.race([
(async (): Promise<void> => {
await conn.initialize({
protocolVersion: PROTOCOL_VERSION,
// Advertise NO optional client capabilities (no fs, no terminal): the
// child self-serves in its own process.
clientCapabilities: {},
})
const session = await conn.newSession({ cwd: spec.cwd, mcpServers: [] })
sessionId = session.sessionId
if (flags.cancelled) throw new Error('subagent cancelled before the ACP session started')
})(),
spawnFailed.then((err): never => { throw err }),
cancelSettled.then((): never => { throw new Error('subagent cancelled before the ACP session started') }),
])
} catch (error: unknown) {
request.signal.removeEventListener('abort', onAbort)
await disposeProcess()
if (flags.cancelled) throw new Error('subagent request was aborted before the ACP child started')
throw toError(error)
}
const result: Promise<SubagentResult> = (async (): Promise<SubagentResult> => {
try {
// Readiness is the initialize → newSession phase above.
await started
// Race two post-start outcomes, first to settle wins: - prompt: the normal remote turn; -
// cancelSettled: a cancel was requested — settle `aborted` immediately rather than
// waiting on a child that may ignore `session/cancel` or wedge the prompt (the `cancel()`
// contract: `result` settles `aborted`).
// Race two post-publication outcomes, first to settle wins:
// - prompt: the normal remote turn;
// - cancelSettled: a cancel was requested — settle `aborted` immediately
// rather than waiting on a child that may ignore `session/cancel` or
// wedge the prompt (`result` settles `aborted`). After `newSession`
// succeeds, transport/process failure rejects the in-flight prompt RPC.
const prompt = async (): Promise<SubagentResult> => {
// `started` cannot fulfill without assigning the session id; the cast
// records that local invariant without an unreachable defensive arm.
// The startup phase cannot fulfill without assigning the session id.
const promptResult = await conn.prompt({ sessionId: sessionId as string, prompt: toAcpPrompt(request.prompt) })
return { output: collectOutput(), stopReason: acpStopReason(promptResult.stopReason) }
}
@@ -291,8 +340,17 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su
cancelSettled.then((): SubagentResult => ({ output: collectOutput(), stopReason: 'aborted' })),
])
} catch (error: unknown) {
// A deterministic cancellation resolves `cancelSettled` before its
// best-effort ACP cancel can reject the prompt. This fallback is only for
// a process/pipe rejection already queued when the abort event fires; its
// first-outcome ordering cannot be forced without a timing-dependent test.
/* v8 ignore next */
if (flags.cancelled) return { output: collectOutput(), stopReason: 'aborted' }
// The seam contract: result resolves (never rejects) on a child-level failure.
// The seam contract: result resolves (never rejects) on a child-level
// failure. Startup failures were already rejected before publication;
// every rejection here is a prompt transport/RPC failure.
// Flatten to `error` and surface the original via onError so a real fault
// is preserved rather than silently lost.
try {
spec.onError?.(toError(error), 'error')
} catch {
@@ -301,24 +359,30 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su
// The child-level failure being reported still settles as `error`.
}
return { output: collectOutput(), stopReason: 'error' }
} finally {
request.signal.removeEventListener('abort', onAbort)
}
})()
let disposal: Promise<void> | undefined
return {
id,
started,
result,
cancel(_reason?: string): void {
dispose(): Promise<void> {
if (disposal !== undefined) return disposal
request.signal.removeEventListener('abort', onAbort)
requestCancel()
},
async dispose(): Promise<void> {
request.signal?.removeEventListener('abort', onAbort)
// Quiescent teardown via the shared ladder (stdin EOF → SIGTERM → SIGKILL, awaiting the
// actual exit).
await disposeChildProcess(child, {
disposeEofGraceMs: spec.disposeEofGraceMs,
disposeGraceMs: spec.disposeGraceMs,
})
// Quiescent teardown via the shared ladder (stdin EOF → SIGTERM →
// SIGKILL, awaiting the actual exit). For THIS child the EOF tier is the
// one that matters: our acp-agent has NO SIGTERM handler in a normal
// session — it tears down via the server bridge's connection-close path
// (conn.closed → per-agent dispose → final session/flush), driven by the
// stdin EOF, NOT by a signal — and a prompt response can resolve from a
// turn/end BEFORE that post-turn flush lands, so the child still has
// durable work owed when dispose runs (hence the wide EOF grace; see
// DEFAULT_DISPOSE_EOF_GRACE_MS).
disposal = disposeProcess()
return disposal
},
}
}

View File

@@ -31,6 +31,7 @@ const WANT_PERMISSION = process.env.MOCK_PERMISSION === '1'
const NO_ALLOW = process.env.MOCK_NO_ALLOW === '1'
const THOUGHT = process.env.MOCK_THOUGHT === '1'
const CRASH_ON_CANCEL = process.env.MOCK_CRASH_ON_CANCEL === '1'
const CRASH_ON_PROMPT = process.env.MOCK_CRASH_ON_PROMPT === '1'
const IGNORE_CANCEL = process.env.MOCK_IGNORE_CANCEL === '1'
const READY_FILE = process.env.MOCK_READY_FILE
const FLUSH_ON_EOF = process.env.MOCK_FLUSH_ON_EOF
@@ -68,6 +69,7 @@ function makeAgent(conn: AgentSideConnection): Agent {
return Promise.resolve()
},
async prompt(params: PromptRequest): Promise<PromptResponse> {
if (CRASH_ON_PROMPT) process.exit(1)
if (WANT_PERMISSION) {
// Ask the client to approve before answering; honor its decision. Under
// MOCK_NO_ALLOW the only options are reject-shaped, so an `allow`-policy

View File

@@ -51,9 +51,10 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('ACP backend with-key e2e (drive
},
})
const run = ctx.subagents.start('acp', {
const run = await ctx.subagents.start('acp', {
prompt: [{ type: 'text', text: 'Reply with exactly the word PONG and nothing else. Do not use any tools.' }],
parent: fakeParent,
signal: new AbortController().signal,
})
const result = await run.result
await run.dispose()
@@ -84,11 +85,12 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('ACP backend with-key e2e (drive
},
})
const run = ctx.subagents.start('acp', {
const run = await ctx.subagents.start('acp', {
prompt: [{ type: 'text', text:
'Use the bash tool to write the text ACP_CHILD_WAS_HERE into a file named proof.txt '
+ 'in the current directory. Then reply DONE.' }],
parent: fakeParent,
signal: new AbortController().signal,
})
const result = await run.result
await run.dispose()

View File

@@ -27,6 +27,10 @@ const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.m
/** A throwaway parent Agent — the ACP backend ignores it, but the seam requires one. */
const fakeParent = { id: 'parent', session: { header: {} } } as unknown as Agent
function request(text = 'p', signal = new AbortController().signal) {
return { prompt: [{ type: 'text' as const, text }], parent: fakeParent, signal }
}
interface SetupEnv {
/** Mock-server scripting env: MOCK_TEXT / MOCK_STOP / MOCK_HANG / MOCK_PERMISSION. */
[key: string]: string
@@ -119,16 +123,18 @@ describe('buildChildEnv', () => {
describe('dsh-subagent-acp', () => {
it('drives a child process to completion and returns its streamed output', async () => {
const ctx = await setup({ MOCK_TEXT: 'hello from acp child', MOCK_STOP: 'end_turn' })
const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'do X' }], parent: fakeParent })
const run = await ctx.subagents.start('acp', request('do X'))
const result = await run.result
expect(result.stopReason).toBe('completed')
expect(text(result.output)).toBe('hello from acp child')
await run.dispose()
const disposal = run.dispose()
expect(run.dispose()).toBe(disposal)
await disposal
})
it('maps a max_tokens stop reason', async () => {
const ctx = await setup({ MOCK_TEXT: 'cut off', MOCK_STOP: 'max_tokens' })
const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent })
const run = await ctx.subagents.start('acp', request())
const result = await run.result
expect(result.stopReason).toBe('max-tokens')
await run.dispose()
@@ -136,22 +142,23 @@ describe('dsh-subagent-acp', () => {
it('maps a refusal stop reason', async () => {
const ctx = await setup({ MOCK_TEXT: '', MOCK_STOP: 'refusal' })
const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent })
const run = await ctx.subagents.start('acp', request())
const result = await run.result
expect(result.stopReason).toBe('refusal')
await run.dispose()
})
it('cancelling a running child settles aborted (session/cancel via run.cancel)', async () => {
it('aborting the required signal cancels a running child', async () => {
const tmp = mkdtempSync(join(tmpdir(), 'acp-cancel-'))
const readyFile = join(tmp, 'ready')
try {
const ctx = await setup({ MOCK_TEXT: 'partial', MOCK_HANG: '1', MOCK_READY_FILE: readyFile })
const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent })
const controller = new AbortController()
const run = await ctx.subagents.start('acp', request('p', controller.signal))
// Wait until the child's prompt is in flight (condition, not a sleep),
// then cancel — so we exercise the mid-run session/cancel path.
await waitForFile(readyFile)
run.cancel('test')
controller.abort('test')
const result = await run.result
expect(result.stopReason).toBe('aborted')
await run.dispose()
@@ -160,7 +167,7 @@ describe('dsh-subagent-acp', () => {
}
})
it('settles aborted WITHOUT spawning the child when the signal is already aborted', async () => {
it('rejects WITHOUT spawning the child when the signal is already aborted', async () => {
// A pre-aborted request must not even launch the configured binary. Point
// the command at one that would create a sentinel file if it ever ran, and
// assert the sentinel never appears.
@@ -169,17 +176,11 @@ describe('dsh-subagent-acp', () => {
try {
const controller = new AbortController()
controller.abort()
const run = startAcpRun(
{ prompt: [{ type: 'text', text: 'p' }], parent: fakeParent, signal: controller.signal },
await expect(startAcpRun(
request('p', controller.signal),
// `touch <sentinel>` — runs only if the process is actually spawned.
{ command: 'touch', args: [sentinel], cwd: tmp, permission: 'reject', env: {}, disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS, disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS },
)
const result = await run.result
expect(result.stopReason).toBe('aborted')
expect(result.output).toEqual([])
// cancel/dispose on the inert run are safe no-ops.
run.cancel('noop')
await run.dispose()
)).rejects.toThrow('aborted before the ACP child started')
// The binary was never launched — no sentinel.
expect(existsSync(sentinel)).toBe(false)
} finally {
@@ -188,8 +189,9 @@ describe('dsh-subagent-acp', () => {
})
it('dispose escalates SIGTERM → SIGKILL for a child that traps SIGTERM (bounded quiescence)', async () => {
// The child traps SIGTERM and keeps its event loop alive, so a graceful term alone would
// hang dispose forever.
// The child traps SIGTERM and keeps its event loop alive, so a graceful
// term alone would hang dispose forever. With a short grace, dispose must
// escalate to SIGKILL and return once the process is actually gone.
const tmp = mkdtempSync(join(tmpdir(), 'acp-trap-'))
const ready = join(tmp, 'trap-armed')
try {
@@ -205,7 +207,7 @@ describe('dsh-subagent-acp', () => {
disposeEofGraceMs: 150,
disposeGraceMs: 150,
}
const run = startAcpRun({ prompt: [{ type: 'text', text: 'p' }], parent: fakeParent }, spec)
const run = await startAcpRun(request(), spec)
// Wait until the child has BOOTED AND ARMED THE TRAP (a condition, not a
// sleep) — otherwise SIGTERM races the trap install and the default handler
// terminates the child, never exercising the escalation.
@@ -223,8 +225,15 @@ describe('dsh-subagent-acp', () => {
})
it('dispose gives the child an EOF window that outlasts the SIGTERM grace (graceful flush)', async () => {
// The real acp-agent flushes ASYNCHRONOUSLY on stdin EOF (its bridge tears down on
// connection close, not on a signal) — and it has no SIGTERM handler.
// The real acp-agent flushes ASYNCHRONOUSLY on stdin EOF (its bridge tears
// down on connection close, NOT on a signal) — and it has no SIGTERM handler.
// Its EOF teardown can itself await a signal-trapping grandchild (a bash
// subprocess in its own SIGTERM→SIGKILL grace) plus a flush, so the EOF window
// must be a SEPARATE, WIDER grace than the SIGTERM tier — not the same value.
// The mock models a flush that takes LONGER than the SIGTERM grace but well
// under the EOF grace: it lands only because tier 1 waits eofGraceMs, not
// graceMs. (If dispose reused the small SIGTERM grace for the EOF wait — the
// round-2 bug — SIGTERM would fire mid-flush and the marker would be missing.)
const tmp = mkdtempSync(join(tmpdir(), 'acp-eof-'))
const ready = join(tmp, 'ready')
const flushed = join(tmp, 'flushed')
@@ -234,7 +243,10 @@ describe('dsh-subagent-acp', () => {
args: ['--import', tsxLoader, mockServer],
cwd: process.cwd(),
permission: 'reject',
// MOCK_HANG so the prompt never resolves on its own — we tear down a live child.
// MOCK_HANG so the prompt never resolves on its own — we tear down a live
// child. The flush beat (400ms) outlasts the 50ms SIGTERM grace but fits
// the 2000ms EOF grace; the marker lands iff the EOF tier honored its own
// wider grace.
env: {
MOCK_HANG: '1', MOCK_TEXT: 'x', MOCK_READY_FILE: ready,
MOCK_FLUSH_ON_EOF: flushed, MOCK_FLUSH_DELAY_MS: '400', TSX_TSCONFIG_PATH: repoTsconfig,
@@ -242,7 +254,7 @@ describe('dsh-subagent-acp', () => {
disposeEofGraceMs: 2000,
disposeGraceMs: 50,
}
const run = startAcpRun({ prompt: [{ type: 'text', text: 'p' }], parent: fakeParent }, spec)
const run = await startAcpRun(request(), spec)
// Wait until the child is fully booted with its prompt in flight (its ACP
// stdin reader is attached), so dispose's stdin EOF reaches a live child.
await waitForFile(ready)
@@ -256,9 +268,12 @@ describe('dsh-subagent-acp', () => {
})
it('escalates to SIGTERM for a child that ignores EOF but is not SIGTERM-trapping', async () => {
// A child that keeps its loop alive past stdin EOF (so the graceful window times out) but
// exits cooperatively on SIGTERM must die on the SIGTERM tier — dispose returns there,
// never reaching the SIGKILL tier.
// A child that keeps its loop alive past stdin EOF (so the graceful window
// times out) but exits cooperatively on SIGTERM must die on the SIGTERM tier
// — dispose returns there, never reaching the SIGKILL tier. The child touches
// a SIGTERM marker from its signal handler: SIGKILL is uncatchable, so if
// dispose had skipped the middle rung (EOF→SIGKILL) the handler would never
// run and the marker would be absent — making this a GENUINE middle-tier guard.
const tmp = mkdtempSync(join(tmpdir(), 'acp-ignore-eof-'))
const ready = join(tmp, 'ready')
const sigterm = join(tmp, 'sigterm')
@@ -276,7 +291,7 @@ describe('dsh-subagent-acp', () => {
disposeEofGraceMs: 150,
disposeGraceMs: 2000,
}
const run = startAcpRun({ prompt: [{ type: 'text', text: 'p' }], parent: fakeParent }, spec)
const run = await startAcpRun(request(), spec)
await waitForFile(ready)
// Bound it so a hang fails loud rather than stalling the suite.
await expect(Promise.race([
@@ -291,21 +306,22 @@ describe('dsh-subagent-acp', () => {
}
})
it('honors a cancel that races AHEAD of newSession (no session id yet) without running the prompt', async () => {
it('rejects after cleanup when the signal aborts during newSession', async () => {
// Gate the child at newSession: it signals `ready` and blocks until `go`.
// We cancel WHILE newSession is pending (sessionId still undefined, so the
// backend cannot send session/cancel) — the `cancelled` flag alone must
// settle the run aborted after newSession resolves, never issuing the prompt.
const tmp = mkdtempSync(join(tmpdir(), 'acp-early-'))
const ready = join(tmp, 'ready')
const go = join(tmp, 'go')
try {
const ctx = await setup({ MOCK_NEWSESSION_READY: ready, MOCK_NEWSESSION_GO: go, MOCK_TEXT: 'should not run' })
const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent })
const controller = new AbortController()
const starting = ctx.subagents.start('acp', request('p', controller.signal))
await waitForFile(ready) // newSession is now in flight, sessionId undefined
run.cancel('early') // sets cancelled; cannot send session/cancel yet
controller.abort('early')
writeFileSync(go, 'go') // let newSession resolve
const result = await run.result
expect(result.stopReason).toBe('aborted')
expect(result.output).toEqual([])
await run.dispose()
await expect(starting).rejects.toThrow('aborted before the ACP child started')
} finally {
rmSync(tmp, { recursive: true, force: true })
}
@@ -317,7 +333,7 @@ describe('dsh-subagent-acp', () => {
try {
const controller = new AbortController()
const ctx = await setup({ MOCK_TEXT: 'partial', MOCK_HANG: '1', MOCK_READY_FILE: readyFile })
const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent, signal: controller.signal })
const run = await ctx.subagents.start('acp', request('p', controller.signal))
await waitForFile(readyFile)
controller.abort()
const result = await run.result
@@ -330,7 +346,7 @@ describe('dsh-subagent-acp', () => {
it('auto-rejects a permission prompt by default (child settles cancelled→aborted)', async () => {
const ctx = await setup({ MOCK_TEXT: 'x', MOCK_PERMISSION: '1' }, 'reject')
const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent })
const run = await ctx.subagents.start('acp', request())
const result = await run.result
// The child asked permission, the backend rejected, the child returned cancelled.
expect(result.stopReason).toBe('aborted')
@@ -339,7 +355,7 @@ describe('dsh-subagent-acp', () => {
it('auto-approves a permission prompt under the allow policy', async () => {
const ctx = await setup({ MOCK_TEXT: 'approved answer', MOCK_PERMISSION: '1', MOCK_STOP: 'end_turn' }, 'allow')
const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent })
const run = await ctx.subagents.start('acp', request())
const result = await run.result
expect(result.stopReason).toBe('completed')
expect(text(result.output)).toBe('approved answer')
@@ -350,7 +366,7 @@ describe('dsh-subagent-acp', () => {
// The child asks permission but offers ONLY reject-shaped options, so an
// allow-policy client finds nothing to select and must answer cancelled.
const ctx = await setup({ MOCK_PERMISSION: '1', MOCK_NO_ALLOW: '1' }, 'allow')
const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent })
const run = await ctx.subagents.start('acp', request())
const result = await run.result
expect(result.stopReason).toBe('aborted')
await run.dispose()
@@ -360,7 +376,7 @@ describe('dsh-subagent-acp', () => {
// The child streams an agent_thought_chunk before its answer; the backend
// must consume it but NOT include it in the result output.
const ctx = await setup({ MOCK_THOUGHT: '1', MOCK_TEXT: 'final answer', MOCK_STOP: 'end_turn' })
const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent })
const run = await ctx.subagents.start('acp', request())
const result = await run.result
expect(result.stopReason).toBe('completed')
// Only the message text, NOT the thought.
@@ -368,18 +384,11 @@ describe('dsh-subagent-acp', () => {
await run.dispose()
})
it('resolves error (not reject) when the spawn command does not exist', async () => {
// Direct startAcpRun with NO onError sink — the catch must still flatten the
// spawn failure to `error` (the onError call is optional, covering the
// absent-sink branch).
const run = startAcpRun(
{ prompt: [{ type: 'text', text: 'p' }], parent: fakeParent },
it('rejects a spawn failure after provider-owned cleanup', async () => {
await expect(startAcpRun(
request(),
{ command: '/nonexistent/acp-agent-binary', args: [], cwd: process.cwd(), permission: 'reject', env: {}, disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS, disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS },
)
const result = await run.result
// The seam contract: a child-level failure resolves error, never rejects.
expect(result.stopReason).toBe('error')
await run.dispose()
)).rejects.toThrow()
})
it('plugin-config dispose graces reach the run (SIGKILL escalation through the provider)', async () => {
@@ -401,7 +410,7 @@ describe('dsh-subagent-acp', () => {
disposeEofGraceMs: 150,
disposeGraceMs: 150,
})
const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent })
const run = await ctx.subagents.start('acp', request())
await waitForFile(ready)
await expect(Promise.race([
run.dispose(),
@@ -423,7 +432,7 @@ describe('dsh-subagent-acp', () => {
}
})
it('resolves error via the provider (real load path) when the command does not exist', async () => {
it('rejects a startup failure via the provider load path', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
await ctx.plugin(acp, {
@@ -433,25 +442,23 @@ describe('dsh-subagent-acp', () => {
permission: 'reject',
env: {},
})
const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent })
const result = await run.result
expect(result.stopReason).toBe('error')
await run.dispose()
await expect(ctx.subagents.start('acp', request())).rejects.toThrow()
})
it('reports a flattened child failure through onError (preserved, not silently lost)', async () => {
// The seam forbids `result` rejecting, so a child-level failure is flattened to a stop
// reason — onError must still surface the original error so a real fault is logged, not
// swallowed.
// The seam forbids `result` rejecting, so a child-level failure is flattened
// to a stop reason — onError must still surface the original error so a real
// fault is logged, not swallowed. The child exits after its session is
// published but while prompt is in flight.
const errors: { message: string; stopReason: string }[] = []
const run = startAcpRun(
{ prompt: [{ type: 'text', text: 'p' }], parent: fakeParent },
const run = await startAcpRun(
request(),
{
command: '/nonexistent/acp-agent-binary',
args: [],
command: process.execPath,
args: ['--import', tsxLoader, mockServer],
cwd: process.cwd(),
permission: 'reject',
env: {},
env: { MOCK_CRASH_ON_PROMPT: '1', TSX_TSCONFIG_PATH: repoTsconfig },
disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS,
disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS,
onError: (error, stopReason) => { errors.push({ message: error.message, stopReason }) },
@@ -465,18 +472,31 @@ describe('dsh-subagent-acp', () => {
await run.dispose()
})
it('logs a flattened child failure through the registered provider', async () => {
const ctx = await setup({ MOCK_CRASH_ON_PROMPT: '1' })
const warnings: string[] = []
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
const run = await ctx.subagents.start('acp', request())
const result = await run.result
expect(result.stopReason).toBe('error')
expect(warnings).toEqual([
expect.stringContaining('subagent-acp "acp": child run failed (error):'),
])
await run.dispose()
})
it('resolves error (never rejects) even when the onError sink itself throws', async () => {
// onError is a caller-supplied callback boundary: its own exception must be
// contained, or it would reject `result` and break the seam's "result never
// rejects" contract that the flattening above exists to uphold.
const run = startAcpRun(
{ prompt: [{ type: 'text', text: 'p' }], parent: fakeParent },
const run = await startAcpRun(
request(),
{
command: '/nonexistent/acp-agent-binary',
args: [],
command: process.execPath,
args: ['--import', tsxLoader, mockServer],
cwd: process.cwd(),
permission: 'reject',
env: {},
env: { MOCK_CRASH_ON_PROMPT: '1', TSX_TSCONFIG_PATH: repoTsconfig },
disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS,
disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS,
onError: () => { throw new Error('sink boom') },
@@ -488,15 +508,18 @@ describe('dsh-subagent-acp', () => {
})
it('settles aborted when the child crashes (tears the pipe) AFTER a cancel', async () => {
// The child hangs, we cancel, and instead of answering the child exits hard — the pending
// prompt RPC rejects.
// The child hangs, we cancel, and instead of answering the child exits hard
// — the pending prompt RPC rejects. With a cancel already requested, the
// backend's catch path must settle `aborted` (the failure is the cancel
// surfacing as a torn pipe), not `error`.
const tmp = mkdtempSync(join(tmpdir(), 'acp-crash-'))
const ready = join(tmp, 'ready')
try {
const ctx = await setup({ MOCK_TEXT: 'partial', MOCK_HANG: '1', MOCK_CRASH_ON_CANCEL: '1', MOCK_READY_FILE: ready })
const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent })
const controller = new AbortController()
const run = await ctx.subagents.start('acp', request('p', controller.signal))
await waitForFile(ready)
run.cancel('crash it')
controller.abort('crash it')
const result = await run.result
expect(result.stopReason).toBe('aborted')
await run.dispose()
@@ -505,15 +528,19 @@ describe('dsh-subagent-acp', () => {
}
})
it('settles aborted on cancel even when the child IGNORES session/cancel (non-cooperative)', async () => {
// The contract: run.cancel() → result settles `aborted`.
it('settles aborted on signal even when the child IGNORES session/cancel', async () => {
// The signal contract requires `result` to settle `aborted`. A child that hangs
// its prompt AND ignores session/cancel must not wedge the parent — the
// backend's own cancel-settle path resolves `aborted` without the child's
// cooperation, and dispose() still reaps the process.
const tmp = mkdtempSync(join(tmpdir(), 'acp-ignorecancel-'))
const ready = join(tmp, 'ready')
try {
const ctx = await setup({ MOCK_TEXT: 'partial', MOCK_HANG: '1', MOCK_IGNORE_CANCEL: '1', MOCK_READY_FILE: ready })
const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent })
const controller = new AbortController()
const run = await ctx.subagents.start('acp', request('p', controller.signal))
await waitForFile(ready)
run.cancel('test')
controller.abort('test')
// Bound it: a regression (cancel only notifies the child, which ignores it)
// would hang result forever — fail loud instead of stalling the suite.
const result = await Promise.race([

View File

@@ -1,14 +1,20 @@
# @deepseek-ai/dsh-subagent-fork
In-process provider that starts a child [`Agent`](../../core/agent) from the parent's completed conversation prefix. It shares [`startInProcessRun`](../subagent-inprocess/README.md) with the [spawn provider](../subagent-spawn/README.md); the seed is the only backend difference.
The fork provider creates an in-process child seeded with the parent's completed conversation turns. It shares all run mechanics with spawn; the session seed is the only behavioral difference.
## Seed boundary
The delegating tool runs inside an open parent turn whose tool call has no result yet. Forking that tail would create an invalid, unbalanced child log, so the provider copies only the prefix through the last `turn/end`. A first-turn fork therefore starts with an empty seed. `CreateAgentOptions.seed` carries the contiguous prefix into session preparation.
The parent's current tool-calling turn is still open when a subagent starts: its log contains the assistant tool call but not the matching tool result or `turn/end`. Copying that raw log would give the child an invalid, unbalanced session.
## Capabilities
Fork therefore uses `completedTurnPrefix(parent.session.events)`: the contiguous prefix ending at the last `turn/end`. The child sees all completed parent turns and none of the in-flight turn. If the parent has not completed a turn yet, the seed is empty and the child behaves like a fresh spawn.
`{ outputSchema: true, depthLimit: true, toolFilter: true, persona: true }`
The seed transfers conversation history only. The child still receives a fresh flat registration scope; it does not inherit the parent's tool restrictions or authority.
## Start and capabilities
`start(request)` passes the completed-turn seed to [`startInProcessRun`](../subagent-inprocess/README.md) and awaits child publication. The shared driver owns cancellation, depth, customization, result reading, and disposal.
Fork advertises `{ outputSchema: true, depthLimit: true, toolFilter: true, persona: true }`, identical to spawn.
## Config

View File

@@ -55,11 +55,11 @@ class ForkProvider implements SubagentProvider {
// Context contract: a forked child IS seeded with the parent's completed-turn prefix.
readonly inheritsParentContext = true
constructor(readonly name: string, private readonly ctx: Context) {}
constructor(readonly name: string) {}
start(request: SubagentStartRequest) {
const seed = completedTurnPrefix(request.parent)
return startInProcessRun(this.ctx, request, {
return startInProcessRun(request, {
// Only pass a seed when there's a completed turn to inherit; an empty seed
// is equivalent to a fresh child, so omit it to keep the session unseeded.
...seed.length > 0 ? { seed } : {},
@@ -68,5 +68,5 @@ class ForkProvider implements SubagentProvider {
}
export function apply(ctx: Context, config: Config): void {
ctx.subagents.registerProvider(new ForkProvider(config.providerName, ctx))
ctx.subagents.registerProvider(new ForkProvider(config.providerName))
}

View File

@@ -7,13 +7,17 @@ import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import * as Invariants from '@deepseek-ai/dsh-invariants'
import SubagentService from '@deepseek-ai/dsh-subagent'
import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
import * as Spawn from '@deepseek-ai/dsh-subagent-spawn'
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import * as fork from '../src/index.ts'
type Script = ConstructorParameters<typeof MockAdapter>[0]
function start(ctx: Context, provider: string, request: Omit<SubagentStartRequest, 'signal'> & { signal?: AbortSignal }) {
return ctx.subagents.start(provider, { signal: request.signal ?? new AbortController().signal, ...request })
}
/**
* The two in-process backends coexist on one context: the SAME parent agent
* delegates to a `spawn` child (fresh) and a `fork` child (seeded with its log),
@@ -62,13 +66,13 @@ describe('multi-subagent coexistence (spawn + fork on one context)', () => {
const parentPrefixLen = parent.session.events.length
// Delegate to a fresh spawn child.
const spawnRun = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'spawn task' }], parent })
const spawnRun = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'spawn task' }], parent })
const spawnResult = await spawnRun.result
expect(spawnResult.stopReason).toBe('completed')
expect(text(spawnResult.output)).toBe('spawn child reply')
// Delegate to a fork child (seeded with the parent's turn-1 prefix).
const forkRun = ctx.subagents.start('fork', { prompt: [{ type: 'text', text: 'fork task' }], parent })
const forkRun = await start(ctx, 'fork', { prompt: [{ type: 'text', text: 'fork task' }], parent })
const forkResult = await forkRun.result
expect(forkResult.stopReason).toBe('completed')
expect(text(forkResult.output)).toBe('fork child reply')

View File

@@ -8,7 +8,7 @@ import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import * as Invariants from '@deepseek-ai/dsh-invariants'
import SubagentService from '@deepseek-ai/dsh-subagent'
import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
import * as fork from '../src/index.ts'
@@ -17,16 +17,19 @@ import { completedTurnPrefix } from '../src/index.ts'
type Script = ConstructorParameters<typeof MockAdapter>[0]
function start(ctx: Context, provider: string, request: Omit<SubagentStartRequest, 'signal'> & { signal?: AbortSignal }) {
return ctx.subagents.start(provider, { signal: request.signal ?? new AbortController().signal, ...request })
}
/** A bare `stop` finish that streams no content → the turn ends `completed`
* with NO `assistant/message` of its own. */
const emptyStop: StreamChunk[] = [{ type: 'finish', reason: { kind: 'stop' } }]
/**
* Drives the REAL fork backend with a real loop + scripted mock MODEL + the
* real dsh-invariants plugin. The invariants plugin re-replays a seeded child
* log on `session/created` (its freeze-check), so a malformed (unbalanced) fork
* seed makes these tests THROW — that is the regression guard for the
* completed-turn-prefix boundary.
* real dsh-invariants plugin. The plugin replays a seeded child log on
* `session/created`, so a malformed (unbalanced) fork seed makes these tests
* THROW — that is the regression guard for the completed-turn-prefix boundary.
*/
async function setup(script: Script) {
const ctx = new Context()
@@ -78,9 +81,9 @@ describe('dsh-subagent-fork', () => {
if (info.provider === 'fork') childAtStart = ctx.agents.get(info.id)
})
const run = ctx.subagents.start('fork', { prompt: [{ type: 'text', text: 'child q' }], parent })
const starting = start(ctx, 'fork', { prompt: [{ type: 'text', text: 'child q' }], parent })
expect(childAtStart).toBeUndefined()
await run.started
const run = await starting
expect(childAtStart).toBe(ctx.agents.get(run.id))
expect(childAtStart?.id).toBe(run.id)
@@ -93,7 +96,7 @@ describe('dsh-subagent-fork', () => {
// the seed → the child runs fresh. Exercises the `seed.length > 0` false arm.
const { ctx, parent } = await setup([textResponse('fresh child')])
expect(completedTurnPrefix(parent)).toEqual([])
const run = ctx.subagents.start('fork', { prompt: [{ type: 'text', text: 'child q' }], parent })
const run = await start(ctx, 'fork', { prompt: [{ type: 'text', text: 'child q' }], parent })
const result = await run.result
expect(result.stopReason).toBe('completed')
expect(text(result.output)).toBe('fresh child')
@@ -111,7 +114,7 @@ describe('dsh-subagent-fork', () => {
await parent.whenIdle()
const parentPrefixLen = parent.session.events.length
const run = ctx.subagents.start('fork', { prompt: [{ type: 'text', text: 'child question' }], parent })
const run = await start(ctx, 'fork', { prompt: [{ type: 'text', text: 'child question' }], parent })
const result = await run.result
expect(result.stopReason).toBe('completed')
expect(text(result.output)).toBe('child answer')
@@ -142,7 +145,7 @@ describe('dsh-subagent-fork', () => {
await new Promise(r => setTimeout(r, 20)) // let the hanging turn open
// Forking now must NOT throw (the open second turn is excluded from the seed).
const run = ctx.subagents.start('fork', { prompt: [{ type: 'text', text: 'child q' }], parent })
const run = await start(ctx, 'fork', { prompt: [{ type: 'text', text: 'child q' }], parent })
const result = await run.result
expect(result.stopReason).toBe('completed')
expect(text(result.output)).toBe('child')
@@ -164,7 +167,7 @@ describe('dsh-subagent-fork', () => {
])
parent.send([{ type: 'text', text: 'warm up' }])
await parent.whenIdle()
const run = ctx.subagents.start('fork', {
const run = await start(ctx, 'fork', {
prompt: [{ type: 'text', text: 'report structured' }],
parent,
outputSchema: { type: 'object', properties: { answer: { type: 'number' } }, required: ['answer'] },
@@ -183,7 +186,7 @@ describe('dsh-subagent-fork', () => {
parent.send([{ type: 'text', text: 'parent question' }])
await parent.whenIdle()
const run = ctx.subagents.start('fork', { prompt: [{ type: 'text', text: 'child question' }], parent })
const run = await start(ctx, 'fork', { prompt: [{ type: 'text', text: 'child question' }], parent })
const result = await run.result
// The child completed its own (empty) turn — completed, but with NO output
// borrowed from the seeded parent prefix.

View File

@@ -1,25 +1,41 @@
# @deepseek-ai/dsh-subagent-inprocess
Shared run driver for the in-process [spawn](../subagent-spawn/README.md) and [fork](../subagent-fork/README.md) providers. It creates a child agent on the same Cordis application; the providers differ only in the optional session seed.
This package is the shared run driver for the two in-process providers. Spawn passes no session seed; fork passes the parent's completed-turn prefix. Everything else—depth, child creation, optional child customization, result reading, cancellation, and disposal—has one implementation here.
## `startInProcessRun(ctx, request, options)`
## Start contract
The driver snapshots mutable request data, checks delegation depth, and creates one run-owner fiber under the parent. Parent teardown, provider teardown, manual disposal, and cancellation during creation converge on that owner.
`startInProcessRun(request, options): Promise<SubagentRun>` fulfills only after the child is published in `ctx.agents`. A rejected start has already quiesced the agent factory's unpublished creation transaction, so the caller never receives a half-created handle.
Child creation uses fresh IDs, lineage, an inherited or overridden model, and an unpublished setup callback for persona, tool restriction, and structured output. `run.started` resolves after the child is published. The result path sends one prompt, waits for idle, and derives output only from events after the seed boundary; a seeded parent answer cannot become the child's result.
The driver follows this sequence:
`dispose()` awaits creation or rollback and then the child handle's quiescent disposal. `cancel()` records pre-publication cancellation and applies it when the child exists. A cancelled attempt with no completed turn reports `aborted`.
1. Validate the parent depth and optional absolute `maxDepth`, then derive child depth as parent depth plus one.
2. Call `parent.ctx.agents.create` directly, passing the required request signal into the factory's creation transaction.
3. During that transaction's unpublished setup window, install the requested persona, tool restriction, and structured-output runtime.
4. Publish the child, retain the returned `AgentHandle`, and drive one task with `child.send(prompt)` followed by `child.whenIdle()`.
5. Read the child's own last assistant message and terminal turn reason, excluding any fork seed.
`InProcessRunOptions` is `{ seed?: SessionEvent[] }`: absent for spawn and the completed-turn prefix for fork.
The child gets the parent's working-directory/session lineage and inherits the parent model unless `request.agentOptions` overrides it. It gets a fresh flat registration scope: parent ownership does not import parent tool restrictions or establish an authority subset.
## Cancellation and ownership
The required request signal covers both startup and the live run. Before publication, `AgentCreationTransaction` observes it, rolls back, and rejects. The factory detaches that creation-only listener before returning; the driver immediately checks the signal once more before installing a minimal live-run listener, closing the handoff race. After publication, abort cancels the child.
After fulfillment, the caller owns the run. Provider-plugin unload does not revoke it. `dispose()` removes the live abort listener, records cancellation, and delegates to the returned `AgentHandle.dispose()`, whose memoized quiescence transaction stops the loop, removes the agent and session, and unwinds scoped registrations. Cancellation owns every non-completed in-flight outcome and reports `aborted`; an already-completed turn remains completed.
## Spawn and fork inputs
`InProcessRunOptions` is `{ seed?: SessionEvent[] }`. Spawn omits it. Fork supplies a balanced completed-turn prefix and records its length so the result reader never mistakes a seeded parent message for child output.
`depthOf(agent)` reads `AgentOptions.subagentDepth`, treating absence as top-level depth zero and rejecting malformed stored values. `SubagentDepthError` reports an attempted child depth above `maxDepth`; an unrepresentable depth above the safe-integer domain is a `RangeError`.
## Structured output
`attachStructuredRuntime(childCtx, schema)` installs a child-scoped capture tool, prompt instruction, protection, result observer, guard, and terminal turn policy. The actual schema is registered only for that child.
`attachStructuredRuntime(childCtx, schema)` installs the whole contract in the child's scope:
A validated value is staged by immutable execution identity and committed only after the authoritative `tools/result` succeeds. Code Mode also waits for the enclosing `run_code` result. Once pending or committed, later tool calls are denied; after commit, `agent/turn-stop` prevents another model step. A child that finishes without a committed value reports an error.
- A `structured_output` tool registered with the requested schema validates and stages the model's value.
- An order-190 system-prompt section tells the child that the tool call is the terminal answer.
- Both contributions use `ownerFinal: true`, so their owners control their final presence after prompt/tool assembly while unrelated contributions remain extensible.
- A `tools/result` observer commits a staged value only after that execution's authoritative final tool result succeeds, including the enclosing `run_code` result for Code Mode sub-dispatch.
- A monotonic tool guard blocks later calls after capture, and `agent/turn-stop` ends the turn after the structured result commits.
## Depth
`depthOf(agent)` reads merge-extensible `AgentOptions.subagentDepth` (default `0`). `startInProcessRun` throws `SubagentDepthError` when the next depth exceeds `maxDepth`.
See [the agent-scope RFC](../../../docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md) for ownership and final-policy rationale.
A clean turn that never commits the required structured value reports `error`; the driver does not re-prompt. All registrations ride the child fiber and disappear with it.

View File

@@ -1,25 +1,24 @@
/**
* The shared in-process subagent run driver: run a child as a child {@link Agent} on the same
* cordis context (`ctx.agents`) — the cheapest transport, reusing the agent factory's
* quiescent {@link AgentHandle} teardown.
* Shared driver for in-process subagent providers. The agent factory's
* creation transaction owns unpublished setup and rollback; after publication
* the returned AgentHandle is the one quiescent lifecycle owner held by the
* provider's caller.
*
* @module @deepseek-ai/dsh-subagent-inprocess
*/
import { randomUUID } from 'node:crypto'
import type { Context, Fiber } from 'cordis'
import { AgentId, type Agent, type AgentHandle, type AgentOptions } from '@deepseek-ai/dsh-agent'
import { SessionId, isJsonValue, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
import type { Context } from 'cordis'
import { AgentId, type Agent, type AgentOptions } from '@deepseek-ai/dsh-agent'
import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { assertSupportedOutputSchema } from '@deepseek-ai/dsh-tools'
import { assertSubagentMaxDepth } from '@deepseek-ai/dsh-subagent'
import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent'
import {
attachStructuredRuntime,
type StructuredAttachment,
} from './structured.ts'
// The runtime itself (attach) is package-internal: runs attach it inside
// startInProcessRun's setup window, and no other package drives it. Only the
// model-facing vocabulary is public.
export {
STRUCTURED_OUTPUT_TOOL,
STRUCTURED_OUTPUT_INSTRUCTION,
@@ -27,28 +26,26 @@ export {
declare module '@deepseek-ai/dsh-agent' {
interface AgentOptions {
/**
* The agent's delegation depth in the subagent tree — 0 for a top-level
* (config/ACP-created) agent, parent depth + 1 for a subagent. Set by the
* in-process backends on every child they create so a nested spawn reads its
* parent's depth from `parent.options.subagentDepth` and the `depthLimit`
* capability can cap the tree. Merge-extensible field (the seam owns it; the
* loop neither sets nor reads it).
*/
/** Delegation depth: zero for a top-level agent and parent depth + 1 for a child. */
subagentDepth?: number
}
}
/**
* Read an agent's delegation depth (absent ⇒ a top-level agent, depth 0).
* @param agent - the agent whose options may carry `subagentDepth`.
* @returns 0 for a top-level agent, its parent's depth + 1 for a subagent.
* Read an agent's delegation depth, treating absence as top-level depth zero.
* @param agent - the agent whose options carry the depth.
* @returns its non-negative safe-integer depth.
*/
export function depthOf(agent: Agent): number {
return agent.options.subagentDepth ?? 0
const depth = agent.options.subagentDepth
if (depth === undefined) return 0
if (!Number.isSafeInteger(depth) || depth < 0 || Object.is(depth, -0)) {
throw new TypeError('agent subagentDepth must be a non-negative safe integer')
}
return depth
}
/** Thrown when a spawn would exceed the request's `maxDepth` cap. */
/** Thrown when starting a child would exceed the requested depth cap. */
export class SubagentDepthError extends Error {
constructor(public readonly attemptedDepth: number, public readonly maxDepth: number) {
super(`subagent depth ${attemptedDepth} exceeds maxDepth ${maxDepth}`)
@@ -56,7 +53,7 @@ export class SubagentDepthError extends Error {
}
}
/** Map a session `turn/end` reason to a {@link SubagentStopReason}. */
/** Map a session turn outcome to the subagent seam's terminal vocabulary. */
function toStopReason(reason: TurnEndReason | undefined): SubagentStopReason {
switch (reason?.kind) {
case 'completed':
@@ -65,8 +62,6 @@ function toStopReason(reason: TurnEndReason | undefined): SubagentStopReason {
return 'max-tokens'
case 'aborted':
return 'aborted'
// `disposed` (torn down mid-turn) and `interrupted` (crash-closed) both mean the turn did
// not finish cleanly; surface them as a generic failure rather than a clean completion.
case 'error':
case 'disposed':
case 'interrupted':
@@ -75,228 +70,120 @@ function toStopReason(reason: TurnEndReason | undefined): SubagentStopReason {
}
}
/** Extra inputs the spawn/fork backends supply to {@link startInProcessRun}. */
/** Extra inputs the spawn and fork providers supply to the shared driver. */
export interface InProcessRunOptions {
/**
* The child session's seed: a balanced, contiguous-from-0 prefix of the
* parent's log (FORK), or `undefined` for a fresh child (SPAWN).
*/
/** Completed-turn seed for fork, or undefined for a fresh spawn. */
readonly seed?: SessionEvent[]
}
/** Dispose a run-owner fiber and follow an already-started unload to quiescence. */
async function quiesceFiber(fiber: Fiber): Promise<void> {
await Promise.resolve(fiber.dispose())
while (fiber.inertia !== undefined) await fiber.inertia
/** Error used when cancellation wins before the child publication boundary. */
function prePublicationAbort(): Error {
return new Error('subagent request was aborted before child publication')
}
/**
* Start an in-process child agent for `request` and return a {@link SubagentRun}.
*
* @param ctx - the provider context that owns the live run as a second
* structured-concurrency boundary alongside the parent agent.
* @param request - the start request (prompt, parent, signal, per-child options).
* @param options - the backend's optional child-session seed.
* @returns the live run handle for the child agent.
* Establish and drive one in-process child. Fulfillment means the agent is
* already published in the registry; rejection means the agent factory's
* creation transaction and any partially-created child have reached quiescence.
* @param request - the trusted typed start request, including its required signal.
* @param options - the optional fork seed.
* @returns a ready holder-owned run.
*/
export function startInProcessRun(
ctx: Context,
export async function startInProcessRun(
request: SubagentStartRequest,
options: InProcessRunOptions,
): SubagentRun {
// Snapshot the accepted request synchronously. The parent and signal are
// identity capabilities (kept live but never reread from the mutable request
// record); every data field is detached before asynchronous owner setup.
): Promise<SubagentRun> {
assertSubagentMaxDepth(request.maxDepth)
if (request.signal.aborted) throw prePublicationAbort()
const parent = request.parent
const signal = request.signal
const persona = request.persona
const toolFilter = request.toolFilter === undefined ? undefined : structuredClone(request.toolFilter)
const seed = options.seed === undefined ? undefined : structuredClone(options.seed)
const childDepth = depthOf(parent) + 1
if (!Number.isSafeInteger(childDepth)) {
throw new RangeError('subagent child depth exceeds the safe-integer range')
}
if (request.maxDepth !== undefined && childDepth > request.maxDepth) {
throw new SubagentDepthError(childDepth, request.maxDepth)
}
// Assert, then snapshot, the schema subset before any child exists (the service has already
// capability-gated; this rejects a schema outside the enforced subset loud).
if (request.outputSchema !== undefined) assertSupportedOutputSchema(request.outputSchema)
const schema = request.outputSchema === undefined ? undefined : structuredClone(request.outputSchema)
// The accepted request owns a value snapshot, not the caller's mutable content array.
if (!isJsonValue(request.prompt)) {
throw new TypeError('subagent prompt must be losslessly JSON-serializable')
}
const prompt = structuredClone(request.prompt)
if (!isJsonValue(prompt)) {
throw new TypeError('subagent prompt must be stable losslessly JSON-serializable data')
}
const childId = AgentId(randomUUID())
// The child's OWN events begin after the seed (fork seeds the parent's
// completed-turn prefix; spawn seeds nothing). `readResult` scopes to this
// boundary so a child that produces no message of its own never returns the
// SEEDED parent's last assistant message as its result.
const seedLength = options.seed?.length ?? 0
const parentHeader = parent.session.header
// Inherit the parent's model by default (a child with no model cannot run); an explicit
// `request.agentOptions.model` overrides it.
const agentOptions: AgentOptions = structuredClone({
...parent.options.model !== undefined ? { model: parent.options.model } : {},
const parentModel = parent.options.model
const agentOptions: AgentOptions = {
...parentModel !== undefined ? { model: parentModel } : {},
...request.agentOptions,
subagentDepth: childDepth,
})
}
// The child's scoped world, composed in the factory's unpublished setup window.
let structured: StructuredAttachment | undefined
const setup = (childCtx: Context): void => {
if (persona !== undefined) {
childCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: persona })
if (request.persona !== undefined) {
childCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: request.persona })
}
if (toolFilter !== undefined) {
childCtx.tools.restrict(toolFilter)
}
if (schema !== undefined) {
structured = attachStructuredRuntime(childCtx, schema)
if (request.toolFilter !== undefined) childCtx.tools.restrict(request.toolFilter)
if (request.outputSchema !== undefined) {
structured = attachStructuredRuntime(childCtx, request.outputSchema)
}
}
// Bridge the request's abort signal to the child (the consumer also bridges its own
// exec.signal, but a backend-level bridge keeps the contract local).
let cancelled = false
// An accessor, not an inline read: `cancelled` mutates from closures (the
// abort listener, run.cancel), which control-flow narrowing cannot see — an
// inline read at the result mapping would narrow to the initializer.
const isCancelled = (): boolean => cancelled
let child: Agent | undefined
let handle: AgentHandle | undefined
let disposeRequested = false
const isDisposeRequested = (): boolean => disposeRequested
const requestCancel = (reason: string): void => {
cancelled = true
child?.cancel(reason)
}
const onAbort = (): void => { requestCancel('subagent cancelled') }
// One run-owned Cordis fiber is the common ownership node.
let ownerCtx: Context | undefined
function subagentRunOwner(inner: Context): void { ownerCtx = inner }
let ownerFiber: (Fiber & PromiseLike<Fiber>) | undefined
let ownerSetupError: unknown
let ownerDisposing: Promise<void> | undefined
const disposeOwner = (): Promise<void> => (ownerDisposing ??= ownerFiber === undefined
? Promise.resolve()
: quiesceFiber(ownerFiber))
let manualDisposeRequested = false
const isManualDisposeRequested = (): boolean => manualDisposeRequested
const unlinkProvider = ctx.effect(() => () => {
requestCancel('subagent provider disposed')
return disposeOwner()
}, 'subagent-inprocess.run()')
signal?.addEventListener('abort', onAbort, { once: true })
if (signal?.aborted) requestCancel('subagent cancelled')
try {
ownerFiber = parent.ctx.plugin(Object.assign(subagentRunOwner, {
inject: ['agents', 'sessions', 'llm', 'tools', 'systemPrompt'],
}))
} catch (error: unknown) {
ownerSetupError = error
const flags = { cancelled: false }
const handle = await parent.ctx.agents.create({
agentId: childId,
sessionId: SessionId(randomUUID()),
meta: {
...parentHeader.cwd !== undefined ? { cwd: parentHeader.cwd } : {},
parentSession: parentHeader.id,
...seedLength > 0 ? { seedLength } : {},
},
...options.seed !== undefined ? { seed: options.seed } : {},
agentOptions,
signal: request.signal,
setup,
})
const child = handle.agent
// Agent creation detaches its creation-only abort listener before returning.
// Close the narrow handoff race before installing the live-run listener.
// Static analysis does not model the abort that may land between the
// factory's listener detachment and this continuation.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (request.signal.aborted) {
flags.cancelled = true
await handle.dispose()
throw prePublicationAbort()
}
const creation: Promise<Agent> = (async () => {
if (ownerSetupError !== undefined) {
throw ownerSetupError instanceof Error
? ownerSetupError
: new Error('subagent run owner setup failed with a non-Error value', { cause: ownerSetupError })
}
await ownerFiber
if (ownerCtx === undefined) {
throw new Error('subagent run owner became inactive before child creation')
}
// Invoke the factory THROUGH the parent scope.
const created = await ownerCtx.agents.create({
agentId: childId,
sessionId: SessionId(randomUUID()),
meta: {
...parentHeader.cwd !== undefined ? { cwd: parentHeader.cwd } : {},
parentSession: parentHeader.id,
...seedLength > 0 ? { seedLength } : {},
},
...seed !== undefined ? { seed } : {},
agentOptions,
setup,
})
handle = created
child = created.agent
if (isCancelled()) created.agent.cancel('subagent cancelled')
return created.agent
})()
// Provider readiness is a distinct lifecycle boundary from accepting the request.
const started: Promise<void> = creation.then(() => undefined)
const onAbort = (): void => {
flags.cancelled = true
child.cancel('subagent request aborted')
}
request.signal.addEventListener('abort', onAbort, { once: true })
const result: Promise<SubagentResult> = (async () => {
try {
let liveChild: Agent
try {
await started
// `creation` assigns `child` before it fulfills, and `started` is its
// direct fulfillment projection. The cast records that local invariant
// without manufacturing an unreachable runtime branch.
liveChild = child as Agent
} catch (error: unknown) {
if (isManualDisposeRequested()) return { output: [], stopReason: 'aborted' }
throw error instanceof Error ? error : new Error('subagent child creation failed with a non-Error value', { cause: error })
}
if (isCancelled() || isDisposeRequested()) return { output: [], stopReason: 'aborted' }
liveChild.send(prompt)
await liveChild.whenIdle()
// Deliberately NO re-prompt when a structured child finishes cleanly
// without calling structured_output: readResult maps that to `error` —
// the shortfall goes to the parent instead of buying extra model turns.
return readResult(liveChild, seedLength, isCancelled(), structured ? { captured: structured.captured() } : undefined)
child.send(request.prompt)
await child.whenIdle()
return readResult(
child,
seedLength,
flags.cancelled,
structured ? { captured: structured.captured() } : undefined,
)
} finally {
signal?.removeEventListener('abort', onAbort)
request.signal.removeEventListener('abort', onAbort)
}
})()
let disposing: Promise<void> | undefined
return {
id: childId,
started,
result,
cancel(reason?: string): void {
requestCancel(reason ?? 'subagent cancelled')
},
async dispose(): Promise<void> {
return (disposing ??= (async () => {
signal?.removeEventListener('abort', onAbort)
disposeRequested = true
manualDisposeRequested = true
requestCancel('subagent disposed during creation')
// Removing provider ownership and disposing the common run-owner fiber
// are the same quiescence transaction; parent disposal may already have
// claimed it, in which case disposeOwner follows fiber inertia.
await unlinkProvider()
try {
await creation
} catch {
// Creation rollback already reached quiescence; there is no handle
// left to dispose, and dispose must not mask result's infrastructure
// rejection with the same error from a finally block.
return
}
await disposeOwner()
await handle?.dispose()
})())
dispose(): Promise<void> {
request.signal.removeEventListener('abort', onAbort)
flags.cancelled = true
return handle.dispose()
},
}
}
/**
* Read a settled child's terminal result from its session log, scoped to the child's own
* events (everything at or after `seedLength` — fork seeds the parent's completed-turn prefix,
* so a child that produced no message of its own must not return the seeded parent's last
* assistant message).
*/
/** Read one settled child's result from events after its optional fork seed. */
function readResult(
child: Agent,
seedLength: number,
@@ -304,17 +191,20 @@ function readResult(
structured?: { captured?: { value: unknown } | undefined },
): SubagentResult {
const own = child.session.events.slice(seedLength)
const lastMessage = own.findLast((e): e is SessionEvent<'assistant/message'> => e.type === 'assistant/message')
const lastEnd = own.findLast((e): e is SessionEvent<'turn/end'> => e.type === 'turn/end')
const output: ContentBlock[] = lastMessage ? structuredClone(lastMessage.data.content) : []
const stopReason: SubagentStopReason = lastEnd === undefined && cancelled
const lastMessage = own.findLast((event): event is SessionEvent<'assistant/message'> => event.type === 'assistant/message')
const lastEnd = own.findLast((event): event is SessionEvent<'turn/end'> => event.type === 'turn/end')
const output: ContentBlock[] = lastMessage?.data.content ?? []
const recorded = toStopReason(lastEnd?.data.reason)
// Disposal can tear the owner down before the loop records its ordinary
// `aborted` end, yielding `disposed` instead. A requested cancellation owns
// every non-completed in-flight outcome; a turn already completed stays so.
const stopReason: SubagentStopReason = cancelled && recorded !== 'completed'
? 'aborted'
: toStopReason(lastEnd?.data.reason)
if (structured) {
if (structured.captured) return { output, structured: structured.captured.value, stopReason }
// No capture on a cleanly-completed turn: an ERROR when the run was left
// to finish (the nudges ran out), but ABORTED when a cancel is why the
// nudging stopped — the cancel contract outranks the schema shortfall.
: recorded
if (structured !== undefined) {
if (structured.captured !== undefined) {
return { output, structured: structured.captured.value, stopReason }
}
if (stopReason === 'completed') return { output, stopReason: cancelled ? 'aborted' : 'error' }
}
return { output, stopReason }

View File

@@ -1,6 +1,45 @@
/**
* Child-scoped structured-output capture. Values commit only after the final
* tool outcome; guards and terminal turn policy prevent work after capture.
* Structured-output support for the in-process subagent backends: the
* mechanism behind `SubagentStartRequest.outputSchema` for children that run
* as agents on the same context.
*
* Everything is a SCOPED registration on the child agent's context
* (`child.ctx`, the dsh-scope seam): the `structured_output` capture tool
* carries the run's REAL schema as its registered parameters (each child sees
* exactly its own schema — two concurrent structured runs never interact), the
* demand instruction is an ordinary order-190 scoped section, and the
* enforcement listeners fire only for this child (scope-filtered dispatch).
* Registration lifetime rides the child's fiber, so a backend hot-reload
* mid-run cannot unregister the capture tool out from under a live child, and
* a disposed child leaves no residue — no placeholder schema,
* strip-for-everyone-else pass, or refcounted global runtime.
*
* The child scope's registrations enforce the contract:
*
* - `ownerFinal: true` on the capture tool and instruction declares that the
* owning registrations control their final presence. Prompt assembly restores their canonical state
* after EVERY assembly listener. Canonical absence is protected too: pure
* Code Mode keeps `structured_output` in the SDK only and never grows a
* second native wire tool. Code Mode independently declares its SDK section
* and `run_code` transport owner-final. The loop logs the finalized assembly as the
* request header, so the demand is reconstructable log state, never a
* wire-only mutation.
* - `agent/turn-stop` (serial, scoped): stop the child's turn once its output
* is captured. This terminal checkpoint runs after the ordinary continuation
* waterfall and steering folding, so listener order cannot resurrect a
* completed structured run or carry terminal steering into another turn.
* - `tools.guard()` is the monotonic terminal gate after the extensible
* pre-execute waterfall: once capture commits, no later listener can turn
* the denial back into a dispatched side effect.
* - `tools/result` is the capture COMMIT point. The tool body only STAGES the
* validated value in a WeakMap keyed by the execution object; the awaited,
* non-transforming notification promotes it only when the authoritative
* result after the whole pre/execute/post pipeline succeeds. For a Code Mode
* sub-dispatch, promotion waits again for the enclosing `run_code` result, so
* a runtime failure or outer post-policy block cannot report structured
* success. Execution identity makes call-id reuse and orphaned stages
* irrelevant.
*
* @module @deepseek-ai/dsh-subagent-inprocess/structured
*/
@@ -13,7 +52,11 @@ import { ToolArgsError, validateStructuredValue, type StructuredOutputSchema } f
/** The model-facing tool name a structured child must call to finish. */
export const STRUCTURED_OUTPUT_TOOL = 'structured_output'
/** Prompt instruction paired with the child-scoped capture tool. */
/**
* The instruction registered as the child's trailing (order-190, the end of
* the tool-guidance band) scoped prompt section: the demand travels with the
* tool, as ordinary prompt state of exactly one agent.
*/
export const STRUCTURED_OUTPUT_INSTRUCTION
= 'When you have your final answer, you MUST report it by calling the '
+ `\`${STRUCTURED_OUTPUT_TOOL}\` tool with arguments matching its parameter schema exactly. `
@@ -21,18 +64,35 @@ export const STRUCTURED_OUTPUT_INSTRUCTION
/** One structured run's live handle: read the captured value once the child settles. */
export interface StructuredAttachment {
/** @returns the committed value, or `undefined` until one is accepted. */
/**
* The captured value, once the child called the tool with valid arguments
* and the authoritative final tool result accepted that call.
* @returns the committed value, or undefined while none was accepted.
*/
captured(): { value: unknown } | undefined
}
/**
* Install structured-output capture in a child's setup scope.
* @param childCtx - child agent scope context.
* @param schema - validated schema enforced by the capture tool.
* @returns handle for reading the committed value after settlement.
* Attach the structured-output runtime to a child for `schema`: register the
* scoped capture tool (real schema), the scoped instruction section, and the
* scoped enforcement registrations (see the module doc). Call from the
* agent-creation `setup` window with the child's scope context — every
* registration rides the child's fiber and unwinds with the child.
* @param childCtx - the child agent's scope context (`setup`'s argument).
* @param schema - the trusted, already-asserted schema subset to enforce (see
* `assertSupportedOutputSchema` in dsh-tools).
* @returns the attachment handle (read `captured()` after the child settles).
*/
export function attachStructuredRuntime(childCtx: Context, schema: StructuredOutputSchema): StructuredAttachment {
// Stages are keyed by pipeline identity, not reusable model call ids.
/**
* Validated values staged by the capture tool body, awaiting THEIR OWN
* authoritative `tools/result` notification. The execution object's identity
* uniquely identifies a trip through the pipeline: adapter call ids may
* repeat across steps, but another execution can never reach this WeakMap
* entry. This is distinct from the opaque `ToolExecutionToken` used to
* correlate nested transports. The final notification always deletes its own
* stage, whether the result succeeded or failed.
*/
const staged = new WeakMap<ToolExecution, { value: unknown }>()
/** Successful nested capture waiting for its enclosing transport to commit. */
let pending: { parent: ToolExecution['token']; value: unknown } | undefined
@@ -43,17 +103,23 @@ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOut
description:
'Report your final structured result. Call this exactly once, when your answer is complete; '
+ 'the arguments must match this tool\'s parameter schema exactly.',
// The validated subset is a wire-level JSON Schema object.
// ToolSchema.parameters is the wire-level JSON Schema object; the
// asserted subset type is structurally exactly that.
parameters: schema as unknown as Record<string, unknown>,
}
childCtx.tools.register({
...schemaEntry,
ownerFinal: true,
execute(args: unknown, exec: ToolExecution): Promise<ContentBlock[]> {
const violations = validateStructuredValue(schema, args)
// ToolArgsError → isError result with INVALID_ARGS: the model retries
// within the same turn, exactly like a schema-validated defineTool call.
if (violations.length > 0) throw new ToolArgsError(violations)
// Commit waits for this execution's final result.
staged.set(exec, { value: structuredClone(args) })
// Two-phase commit, keyed by THIS execution: later transformable
// waterfalls may still turn the success into an error. ToolRegistry has
// already frozen model-bound arguments at the actual input boundary.
staged.set(exec, { value: args })
return Promise.resolve([{ type: 'text', text: 'Structured output recorded.' }])
},
})
@@ -62,23 +128,27 @@ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOut
name: `tool:${STRUCTURED_OUTPUT_TOOL}`,
order: 190,
text: STRUCTURED_OUTPUT_INSTRUCTION,
ownerFinal: true,
})
// Protection preserves the mode-appropriate canonical presence or absence.
childCtx.systemPrompt.protect({
sections: [`tool:${STRUCTURED_OUTPUT_TOOL}`],
tools: [STRUCTURED_OUTPUT_TOOL],
})
// Stop the child's turn once its output is captured. This monotonic serial
// checkpoint runs after the ordinary continuation waterfall, its reason,
// and late-steering folding, so no ordering trick can resume a finished run.
childCtx.on('agent/turn-stop', function (this: unknown): ContinuationStop | undefined {
return captured === undefined ? undefined : { action: 'stop' }
})
// Calls earlier in the same response remain valid; later calls are terminally denied.
// Terminal WITHIN the step. Guards run after the whole pre-execute
// waterfall and compose monotonically (deny or abstain, never allow), so a
// later prepended listener cannot resurrect dispatch. Calls that precede
// capture in the same response remain untouched.
childCtx.tools.guard(exec => captured === undefined && pending === undefined
? undefined
: `structured output already recorded: the run is complete, so \`${exec.name}\` is not executed`)
// The capture COMMIT observes the immutable, authoritative result after the
// complete pipeline and outer error normalization. This notification cannot
// transform the outcome, so there is no wrapper outside the commit verdict.
childCtx.on('tools/result', function (this: unknown, exec, result): void {
if (exec.name === STRUCTURED_OUTPUT_TOOL) {
const entry = staged.get(exec)

View File

@@ -36,7 +36,7 @@ const SCHEMA: StructuredOutputSchema = {
}
/**
* Real loop + scripted mock model + an INLINE spawn-shaped provider over the
* Real loop + scripted mock model + an INLINE fresh-conversation provider over the
* shared driver. The concrete backend plugins are deliberately NOT loaded —
* they would devDep-cycle this package (spawn/fork already depend on the
* driver), and the runtime under test is the driver's; plugin-level structured
@@ -65,7 +65,7 @@ async function setup(script: Script, options: SetupOptions = {}) {
name: 'spawn',
capabilities: { outputSchema: true, depthLimit: true, toolFilter: false, persona: false },
inheritsParentContext: false,
start: (request: SubagentStartRequest) => startInProcessRun(ctx, request, {}),
start: (request: SubagentStartRequest) => startInProcessRun(request, {}),
})
ctx.llm.registerAdapter(['mock'], adapter)
const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' })
@@ -73,7 +73,13 @@ async function setup(script: Script, options: SetupOptions = {}) {
}
function structuredRequest(parent: SubagentStartRequest['parent'], extra?: Partial<SubagentStartRequest>): SubagentStartRequest {
return { prompt: [{ type: 'text', text: 'produce the answer' }], parent, outputSchema: SCHEMA, ...extra }
return {
prompt: [{ type: 'text', text: 'produce the answer' }],
parent,
signal: new AbortController().signal,
outputSchema: SCHEMA,
...extra,
}
}
/** The tool names of one recorded model request. */
@@ -86,7 +92,7 @@ describe('in-process structured output', () => {
const { ctx, parent } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 42, note: 'done' }),
])
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
expect(result.stopReason).toBe('completed')
expect(result.structured).toEqual({ answer: 42, note: 'done' })
@@ -98,7 +104,7 @@ describe('in-process structured output', () => {
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
textResponse('MUST NOT BE CONSUMED'),
])
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
await run.result
// Default continuation would run a second step after the tool call; the
// structured runtime's turn-continuation veto stops the turn instead.
@@ -129,7 +135,7 @@ describe('in-process structured output', () => {
return Promise.resolve([{ type: 'text', text: 'ran' }])
},
})
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
expect(result.stopReason).toBe('completed')
expect(result.structured).toEqual({ answer: 5 })
@@ -157,7 +163,7 @@ describe('in-process structured output', () => {
return Promise.resolve([{ type: 'text', text: 'ran' }])
},
})
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
// Registered after the child and prepended: this listener returns allow
// after every downstream pre-execute decision. The service-owned guard
// runs after the waterfall and can only deny, so the body still cannot run.
@@ -194,7 +200,7 @@ describe('in-process structured output', () => {
return Promise.resolve([{ type: 'text', text: 'ran' }])
},
})
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
// The call ran BEFORE captured was set: the deny gate only guards the
// window after the terminal answer landed.
@@ -203,42 +209,20 @@ describe('in-process structured output', () => {
await run.dispose()
})
it('snapshots the schema at start(): caller mutation after start cannot drift enforcement', async () => {
const mutable: StructuredOutputSchema = {
type: 'object',
properties: { answer: { type: 'number' } },
required: ['answer'],
additionalProperties: false,
}
const pristine = structuredClone(mutable)
const { ctx, parent, adapter } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 3 }),
])
const run = ctx.subagents.start('spawn', structuredRequest(parent, { outputSchema: mutable }))
// Mutate the caller's object AFTER start() returned but before the child's
// first request assembles: with a live reference this would reach both the
// model-visible parameters and validateStructuredValue.
;(mutable.properties as Record<string, unknown>).answer = { type: 'string' }
const result = await run.result
expect(result.structured).toEqual({ answer: 3 })
// The child's request carried the PRISTINE schema, not the mutated one.
const childRequest = adapter.requests.at(-1)
const captureTool = (childRequest?.tools ?? []).find(tool => tool.name === STRUCTURED_OUTPUT_TOOL)
expect(captureTool?.parameters).toEqual(pristine)
await run.dispose()
})
it('a later-prepended continuation wrapper cannot resurrect a captured turn', async () => {
const { ctx, parent, adapter } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 7 }),
textResponse('MUST NOT BE CONSUMED'),
])
ctx.on('agent/turn-continuation', () => Promise.resolve<ContinuationDecision>({ action: 'stop' }))
const run = ctx.subagents.start('spawn', structuredRequest(parent))
let wrapperInstalled = false
// Register this observer only after start() returns.
// Register before the ready-only start. The child session-start boundary is
// after unpublished setup attached structured output but before the loop
// can run. The wrapper awaits the
// explicit downstream stop above, then overwrites that result with continue.
// The later terminal checkpoint still wins.
ctx.on('agent/session-start', (child) => {
if (child.id !== run.id) return
if (child === parent) return
wrapperInstalled = true
child.ctx.on('agent/turn-continuation', async (_subject, _turn, _decision, next): Promise<ContinuationDecision> => {
const downstream = await next()
@@ -246,6 +230,7 @@ describe('in-process structured output', () => {
return { action: 'continue' }
}, { prepend: true })
})
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
expect(wrapperInstalled).toBe(true)
expect(result.structured).toEqual({ answer: 7 })
@@ -259,9 +244,12 @@ describe('in-process structured output', () => {
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 9 }),
textResponse('MUST NOT BE CONSUMED'),
])
// The downstream ordinary policy says stop.
// The downstream ordinary policy says stop. A wrapper registered after
// start() delegates to that stop, then queues steering; ordinary folding
// would turn the stop back into continue. The terminal checkpoint runs
// afterwards and discards that steering.
ctx.on('agent/turn-continuation', () => Promise.resolve<ContinuationDecision>({ action: 'stop' }))
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
ctx.on('agent/session-start', (child) => {
if (child.id !== run.id) return
child.ctx.on('agent/turn-continuation', async (subject, _turn, _decision, next): Promise<ContinuationDecision> => {
@@ -287,7 +275,7 @@ describe('in-process structured output', () => {
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 'not-a-number' }),
toolCallResponse('c2', STRUCTURED_OUTPUT_TOOL, { answer: 7 }),
])
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
expect(result.structured).toEqual({ answer: 7 })
expect(result.stopReason).toBe('completed')
@@ -304,7 +292,7 @@ describe('in-process structured output', () => {
textResponse('here is my answer in prose'),
textResponse('MUST NOT BE CONSUMED'),
])
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
expect(result.stopReason).toBe('error')
expect(result.structured).toBeUndefined()
@@ -318,7 +306,7 @@ describe('in-process structured output', () => {
it('an errored child keeps its honest error result (no capture expected)', async () => {
// Script exhaustion on the first call → the child turn errors.
const { ctx, parent, adapter } = await setup([])
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
expect(result.stopReason).toBe('error')
expect(adapter.requests.length).toBe(1)
@@ -327,12 +315,13 @@ describe('in-process structured output', () => {
it('a cancel landing after a clean capture-less turn settles aborted, not error', async () => {
const { ctx, parent } = await setup([textResponse('prose, no capture')])
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const controller = new AbortController()
const run = await ctx.subagents.start('spawn', structuredRequest(parent, { signal: controller.signal }))
// Cancel synchronously inside the turn's end recording: the cancel
// contract outranks the schema shortfall, so the result maps to aborted.
ctx.on('session/event', (session, event) => {
const child = ctx.agents.get(run.id)
if (session === child?.session && event.type === 'turn/end') run.cancel('cancelled at turn end')
if (session === child?.session && event.type === 'turn/end') controller.abort('cancelled at turn end')
})
const result = await run.result
expect(result.stopReason).toBe('aborted')
@@ -341,20 +330,18 @@ describe('in-process structured output', () => {
it('rejects a schema outside the subset loud, before any child exists', async () => {
const { ctx, parent } = await setup([])
expect(() => ctx.subagents.start('spawn', structuredRequest(parent, {
await expect(ctx.subagents.start('spawn', structuredRequest(parent, {
outputSchema: { type: 'object', oneOf: [] } as unknown as StructuredOutputSchema,
}))).toThrow(/unsupported output schema/)
}))).rejects.toThrow(/unsupported output schema/)
expect(ctx.agents.get(AgentId('parent'))).toBeDefined()
})
it('a schema carrying non-JSON values fails as OutputSchemaError, never as a raw clone error', async () => {
it('a schema carrying non-JSON values fails as OutputSchemaError at the validation boundary', async () => {
const { ctx, parent } = await setup([])
// Assertion runs BEFORE the defensive structuredClone: a function-valued
// annotation must surface as the subset violation it is, not escape as
// structuredClone's DataCloneError.
expect(() => ctx.subagents.start('spawn', structuredRequest(parent, {
// Semantic assertion runs before provider startup.
await expect(ctx.subagents.start('spawn', structuredRequest(parent, {
outputSchema: { type: 'object', default: () => {} } as unknown as StructuredOutputSchema,
}))).toThrow(/unsupported output schema.*annotation must be JSON data/)
}))).rejects.toThrow(/unsupported output schema.*annotation must be JSON data/)
})
it('a post-execute BLOCK on the capture call denies the capture: log and result agree on failure', async () => {
@@ -370,7 +357,7 @@ describe('in-process structured output', () => {
}
return next()
})
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
// No capture was committed: the run reports the schema shortfall...
expect(result.structured).toBeUndefined()
@@ -396,7 +383,7 @@ describe('in-process structured output', () => {
}
return next()
})
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
expect(result.stopReason).toBe('completed')
expect(result.structured).toEqual({ answer: 8 })
@@ -408,7 +395,7 @@ describe('in-process structured output', () => {
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 8 }),
textResponse('capture was rejected'),
])
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
// Registered after attachment and prepended, so it wraps every listener
// the child installed. It delegates first, then converts the apparent
// capture success into the pipeline's authoritative failure.
@@ -435,7 +422,7 @@ describe('in-process structured output', () => {
// replace it (AgentOptions has no prompt field — the instruction is
// per-request wire state added by the final-request listener).
ctx.systemPrompt.section({ name: 'test:persona', order: 10, text: 'You are a counter.' })
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
await run.result
const childRequest = adapter.requests.at(-1)!
expect(childRequest.system).toContain('You are a counter.')
@@ -456,7 +443,7 @@ describe('in-process structured output', () => {
return { logs: [], value: 'captured' }
},
})
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
// This listener is registered after the child's protection and prepended.
// Service finalization still restores the stripped transport and prompt
@@ -500,7 +487,7 @@ describe('in-process structured output', () => {
} as never
},
})
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
expect(result.structured).toBeUndefined()
@@ -529,7 +516,7 @@ describe('in-process structured output', () => {
ctx.on('tools/post-execute', (exec, _result, next) => exec.name === RUN_CODE_NAME
? Promise.resolve({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'outer blocked' }] })
: next())
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
expect(result.structured).toBeUndefined()
@@ -546,7 +533,7 @@ describe('in-process structured output', () => {
parent.send([{ type: 'text', text: 'hello' }])
await parent.whenIdle()
expect(adapter.requests[0]!.system ?? '').not.toContain(STRUCTURED_OUTPUT_INSTRUCTION)
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
await run.result
// The loop always assembles a base prompt (the harness identity section),
// so the instruction APPENDS — never replaces.
@@ -577,7 +564,7 @@ describe('in-process structured output', () => {
await parent.whenIdle()
expect(toolNames(adapter.requests[0]!)).not.toContain(STRUCTURED_OUTPUT_TOOL)
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
await run.result
const childRequest = adapter.requests[1]!
expect(toolNames(childRequest)).toContain(STRUCTURED_OUTPUT_TOOL)
@@ -610,8 +597,8 @@ describe('in-process structured output', () => {
return toolCallResponse('c2', STRUCTURED_OUTPUT_TOOL, args)
},
])
const runA = ctx.subagents.start('spawn', structuredRequest(parent))
const runB = ctx.subagents.start('spawn', structuredRequest(parent, { outputSchema: otherSchema }))
const runA = await ctx.subagents.start('spawn', structuredRequest(parent))
const runB = await ctx.subagents.start('spawn', structuredRequest(parent, { outputSchema: otherSchema }))
const [a, b] = await Promise.all([runA.result, runB.result])
expect(a.structured).toEqual({ answer: 1 })
expect(b.structured).toEqual({ verdict: 'real' })
@@ -640,7 +627,7 @@ describe('in-process structured output', () => {
variables: { ...replaced.variables },
}
})
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
expect(result.structured).toEqual({ answer: 5 })
const entries = adapter.requests[0]!.tools!.filter(tool => tool.name === STRUCTURED_OUTPUT_TOOL)
@@ -665,7 +652,7 @@ describe('in-process structured output', () => {
variables: { ...replaced.variables },
}
})
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
expect(result.structured).toEqual({ answer: 5 })
const entry = adapter.requests[0]!.tools!.find(tool => tool.name === STRUCTURED_OUTPUT_TOOL)
@@ -690,7 +677,7 @@ describe('in-process structured output', () => {
execute: () => Promise.resolve([{ type: 'text', text: 'x' }]),
})
ctx.systemPrompt.section({ name: 'after-band', order: 200, text: 'AFTER-BAND' })
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
await run.result
const request = adapter.requests[0]!
const names = toolNames(request)
@@ -724,7 +711,7 @@ describe('in-process structured output', () => {
variables: { ...replaced.variables },
}
})
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
expect(result.structured).toEqual({ answer: 3 })
const request = adapter.requests[0]!
@@ -752,7 +739,7 @@ describe('in-process structured output', () => {
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 4 }),
])
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
// A backend hot-reload mid-run must not unregister the capture tool out
// from under the live child: the registration rides the CHILD's fiber.
await disposeProvider()
@@ -793,7 +780,7 @@ describe('in-process structured output', () => {
const { ctx, parent } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
])
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
// A prepended post-execute listener blocks the first capture without
// delegating. The final-result notification discards that execution's
// stage when it observes the error.
@@ -834,7 +821,7 @@ describe('in-process structured output', () => {
const { ctx, parent } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
])
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
// Block the first capture after its body stages a value. Its final error
// discards that execution's stage.
let blocks = 1
@@ -872,7 +859,7 @@ describe('in-process structured output', () => {
const { ctx, parent } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
])
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
// Discard the first capture's stage via a final post-execute block.
let blocks = 1
ctx.on('tools/post-execute', (exec, _result, next) => {

View File

@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { Context, type Fiber } from 'cordis'
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
@@ -13,13 +13,6 @@ import { depthOf, SubagentDepthError, startInProcessRun } from '../src/index.ts'
type Script = ConstructorParameters<typeof MockAdapter>[0]
/**
* Drives the shared in-process run driver DIRECTLY (no provider package), so the
* driver's own contract — depth read/cap, the one-shot drive, the result read —
* is covered independently of which backend (spawn/fork) calls it. The only
* mocked boundary is the model; the real agent loop, SubagentService, and
* dsh-invariants are mounted, so a malformed child session log fails the test.
*/
async function setup(script: Script) {
const ctx = new Context()
await ctx.plugin(LlmService)
@@ -35,224 +28,127 @@ async function setup(script: Script) {
return { ctx, parent }
}
function text(blocks: { type: string; text?: string }[]): string {
return blocks.filter(b => b.type === 'text').map(b => b.text).join('')
function request(parent: Agent, signal = new AbortController().signal) {
return { prompt: [{ type: 'text' as const, text: 'child task' }], parent, signal }
}
function text(blocks: readonly { type: string; text?: string }[]): string {
return blocks.filter(block => block.type === 'text').map(block => block.text).join('')
}
describe('depthOf', () => {
it('reads 0 for an agent with no subagentDepth, the set value otherwise', async () => {
it('reads zero for a top-level agent and an explicit child depth', async () => {
const { parent } = await setup([])
expect(depthOf(parent)).toBe(0)
const withDepth = { options: { subagentDepth: 3 } } as unknown as Agent
expect(depthOf(withDepth)).toBe(3)
expect(depthOf({ options: { subagentDepth: 3 } } as unknown as Agent)).toBe(3)
})
it.each([Number.NaN, 1.5, -1, -0, Number.MAX_SAFE_INTEGER + 1])('rejects malformed depth %s', (value) => {
expect(() => depthOf({ options: { subagentDepth: value } } as unknown as Agent))
.toThrow('non-negative safe integer')
})
})
describe('startInProcessRun', () => {
it('rejects a non-JSON prompt before acquiring any run ownership', async () => {
const { ctx, parent } = await setup([])
expect(() => startInProcessRun(ctx, {
prompt: [{ type: 'text', text: Number.NaN as unknown as string }],
parent,
}, {})).toThrow('subagent prompt must be losslessly JSON-serializable')
})
it('rejects a prompt whose getter becomes non-JSON while it is snapshotted', async () => {
const { ctx, parent } = await setup([])
let reads = 0
const prompt = [{
type: 'text' as const,
get text(): string {
reads += 1
return reads === 1 ? 'valid during pre-check' : Number.NaN as unknown as string
},
}]
expect(() => startInProcessRun(ctx, { prompt, parent }, {}))
.toThrow('subagent prompt must be stable losslessly JSON-serializable data')
expect(reads).toBe(2)
})
it('rejects when the run-owner fiber settles without installing its context', async () => {
const { ctx, parent } = await setup([])
function inertOwner(): void {}
const inertFiber = ctx.plugin(inertOwner)
await inertFiber
const parentWithoutOwnerContext = {
options: parent.options,
session: parent.session,
ctx: { plugin: () => inertFiber },
} as unknown as Agent
const run = startInProcessRun(ctx, {
prompt: [{ type: 'text', text: 'must never start' }],
parent: parentWithoutOwnerContext,
}, {})
await expect(run.result).rejects.toThrow('subagent run owner became inactive before child creation')
await run.dispose()
})
it('normalizes a non-Error thrown while installing the run-owner fiber', async () => {
const { ctx, parent } = await setup([])
const setupFailure = 'non-Error owner setup failure'
const parentWithFailingOwnerSetup = {
options: parent.options,
session: parent.session,
ctx: { plugin: () => { throw setupFailure } },
} as unknown as Agent
const run = startInProcessRun(ctx, {
prompt: [{ type: 'text', text: 'must never start' }],
parent: parentWithFailingOwnerSetup,
}, {})
await expect(run.result).rejects.toMatchObject({
message: 'subagent run owner setup failed with a non-Error value',
cause: setupFailure,
})
await run.dispose()
})
it('normalizes a non-Error rejected by asynchronous child creation', async () => {
const { ctx, parent } = await setup([])
const creationFailure = 'non-Error child creation failure'
function inertOwner(): void {}
const ownerFiber = ctx.plugin(inertOwner)
await ownerFiber
const rejectWithNonError = (): Promise<never> => {
// Deliberately violate the promise contract to exercise boundary normalization.
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
return Promise.reject(creationFailure)
}
const rejectingOwnerCtx = {
agents: { create: rejectWithNonError },
} as unknown as Context
const parentWithRejectingFactory = {
options: parent.options,
session: parent.session,
ctx: {
plugin(plugin: (inner: Context) => void) {
plugin(rejectingOwnerCtx)
return ownerFiber
},
},
} as unknown as Agent
const run = startInProcessRun(ctx, {
prompt: [{ type: 'text', text: 'must never start' }],
parent: parentWithRejectingFactory,
}, {})
await expect(run.result).rejects.toMatchObject({
message: 'subagent child creation failed with a non-Error value',
cause: creationFailure,
})
await run.dispose()
})
it('follows owner-fiber inertia when raw teardown was already in flight', async () => {
const { ctx, parent } = await setup([])
const gate = Promise.withResolvers<undefined>()
let inertia: Promise<undefined> | undefined = gate.promise
const fakeFiber = {
dispose: vi.fn(() => undefined),
get inertia() { return inertia },
} as unknown as Fiber & PromiseLike<Fiber>
const rejectingOwnerCtx = {
agents: { create: () => Promise.reject(new Error('creation stopped by teardown')) },
} as unknown as Context
const parentWithDisposingOwner = {
options: parent.options,
session: parent.session,
ctx: {
plugin(plugin: (inner: Context) => void) {
plugin(rejectingOwnerCtx)
return fakeFiber
},
},
} as unknown as Agent
const run = startInProcessRun(ctx, {
prompt: [{ type: 'text', text: 'must never start' }],
parent: parentWithDisposingOwner,
}, {})
let settled = false
const disposing = run.dispose().then(() => { settled = true })
await Promise.resolve()
await Promise.resolve()
expect(fakeFiber.dispose).toHaveBeenCalledOnce()
expect(settled).toBe(false)
inertia = undefined
gate.resolve(undefined)
await disposing
await expect(run.result).resolves.toEqual({ output: [], stopReason: 'aborted' })
})
it('does not attach an abort listener when provider ownership is already inactive', async () => {
const { ctx, parent } = await setup([])
let providerCtx: Context | undefined
function provider(inner: Context): void { providerCtx = inner }
const providerFiber = await ctx.plugin(provider)
await providerFiber.dispose()
if (providerCtx === undefined) throw new Error('provider context was not captured')
const inactiveProviderCtx = providerCtx
const controller = new AbortController()
const addListener = vi.spyOn(controller.signal, 'addEventListener')
expect(() => startInProcessRun(inactiveProviderCtx, {
prompt: [{ type: 'text', text: 'must never start' }],
parent,
signal: controller.signal,
}, {})).toThrow(/inactive context/)
expect(addListener).not.toHaveBeenCalled()
})
it('drives a fresh child (no seed) to completion and returns its output', async () => {
const { ctx, parent } = await setup([textResponse('driver child answer')])
const run = startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'do X' }], parent }, {})
it('returns only after publication, drives a fresh child, and disposes it', async () => {
const { ctx, parent } = await setup([textResponse('driver answer')])
const run = await startInProcessRun(request(parent), {})
expect(ctx.agents.get(run.id)).toBeDefined()
const result = await run.result
expect(result.stopReason).toBe('completed')
expect(text(result.output)).toBe('driver child answer')
expect(text(result.output)).toBe('driver answer')
expect(depthOf(ctx.agents.get(run.id)!)).toBe(1)
await run.dispose()
})
it('snapshots the prompt before asynchronous child creation', async () => {
const { ctx, parent } = await setup([textResponse('done')])
const prompt = [{ type: 'text' as const, text: 'original prompt' }]
const run = startInProcessRun(ctx, { prompt, parent }, {})
prompt[0]!.text = 'mutated after start'
prompt.push({ type: 'text', text: 'also injected' })
await run.result
const child = ctx.agents.get(run.id)!
const userMessage = child.session.events.find(event => event.type === 'user/message')
expect(userMessage?.type === 'user/message' && userMessage.data.content)
.toEqual([{ type: 'text', text: 'original prompt' }])
await run.dispose()
expect(ctx.agents.get(run.id)).toBeUndefined()
})
it('throws SubagentDepthError when the child would exceed maxDepth', async () => {
const { ctx, parent } = await setup([])
expect(() => startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'p' }], parent, maxDepth: 0 }, {}))
.toThrow(SubagentDepthError)
})
it('seeds the child session when a seed is supplied', async () => {
// Drive the parent through one real turn, then seed the child with that
// completed-turn prefix — the child must SEE the parent's history but its
// result is scoped to its OWN events (not the seeded parent message).
const { ctx, parent } = await setup([textResponse('parent turn'), textResponse('seeded child reply')])
parent.send([{ type: 'text', text: 'parent q' }])
it('seeds a forked child but reads only the child-owned output', async () => {
const { ctx, parent } = await setup([textResponse('parent answer'), textResponse('child answer')])
parent.send([{ type: 'text', text: 'parent question' }])
await parent.whenIdle()
const seed = parent.session.events.slice()
const run = startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'child q' }], parent }, { seed })
const run = await startInProcessRun(request(parent), { seed })
const result = await run.result
expect(result.stopReason).toBe('completed')
expect(text(result.output)).toBe('seeded child reply')
expect(text(result.output)).toBe('child answer')
const child = ctx.agents.get(run.id)!
// The child inherited the parent's prefix.
expect(child.session.events.slice(0, seed.length).some(e => e.type === 'user/message')).toBe(true)
expect(child.session.header.seedLength).toBe(seed.length)
expect(child.session.events.slice(0, seed.length)).toEqual(seed)
await run.dispose()
})
it('rejects invalid and exceeded depth before publication', async () => {
const { parent } = await setup([])
await expect(startInProcessRun({ ...request(parent), maxDepth: -1 }, {}))
.rejects.toThrow('non-negative safe integer')
await expect(startInProcessRun({ ...request(parent), maxDepth: 0 }, {}))
.rejects.toBeInstanceOf(SubagentDepthError)
const maxParent = { options: { subagentDepth: Number.MAX_SAFE_INTEGER } } as unknown as Agent
await expect(startInProcessRun(request(maxParent), {})).rejects.toBeInstanceOf(RangeError)
})
it('rejects an already-aborted request without publishing a child', async () => {
const { ctx, parent } = await setup([])
const beforeAgents = ctx.agents.list().length
const beforeSessions = ctx.sessions.list().length
const controller = new AbortController()
controller.abort('too late')
await expect(startInProcessRun(request(parent, controller.signal), {}))
.rejects.toThrow('aborted before child publication')
expect(ctx.agents.list()).toHaveLength(beforeAgents)
expect(ctx.sessions.list()).toHaveLength(beforeSessions)
})
it('uses the request signal after publication and dispose as cancellation paths', async () => {
const { parent } = await setup(['hang', 'hang'])
const controller = new AbortController()
const signalled = await startInProcessRun(request(parent, controller.signal), {})
await new Promise(resolve => setTimeout(resolve, 30))
controller.abort('stop child')
await expect(signalled.result).resolves.toMatchObject({ stopReason: 'aborted' })
await signalled.dispose()
const disposed = await startInProcessRun(request(parent), {})
await new Promise(resolve => setTimeout(resolve, 30))
await disposed.dispose()
await expect(disposed.result).resolves.toMatchObject({ stopReason: 'aborted' })
})
it('cleans a failed unpublished setup before rejecting', async () => {
const { ctx, parent } = await setup([])
const beforeAgents = ctx.agents.list().length
const beforeSessions = ctx.sessions.list().length
await expect(startInProcessRun({
...request(parent),
toolFilter: { deny: ['unknown-tool'] },
}, {})).rejects.toThrow('unknown global tool')
expect(ctx.agents.list()).toHaveLength(beforeAgents)
expect(ctx.sessions.list()).toHaveLength(beforeSessions)
})
it('closes the abort handoff after the factory detaches its creation listener', async () => {
const { ctx, parent } = await setup([])
const controller = new AbortController()
const beforeAgents = ctx.agents.list().length
const beforeSessions = ctx.sessions.list().length
const parentWithAbortAtHandoff = {
options: parent.options,
session: parent.session,
ctx: {
agents: {
create: async (options: Parameters<typeof ctx.agents.create>[0]) => {
const handle = await ctx.agents.create(options)
// `create()` has detached its creation-only listener, but the
// provider continuation has not installed its live-run listener.
controller.abort('handoff race')
return handle
},
},
},
} as unknown as Agent
await expect(startInProcessRun(request(parentWithAbortAtHandoff, controller.signal), {}))
.rejects.toThrow('aborted before child publication')
expect(ctx.agents.list()).toHaveLength(beforeAgents)
expect(ctx.sessions.list()).toHaveLength(beforeSessions)
})
})

View File

@@ -1,12 +1,16 @@
# @deepseek-ai/dsh-subagent-spawn
In-process provider that runs each request as a fresh child [`Agent`](../../core/agent) on the same Cordis application. The child has a new session and no inherited conversation; it uses the parent model unless overridden.
The spawn provider creates a fresh child `Agent` in the current process. The child has its own session, sees no parent conversation history, and reuses the host's agent factory and LLM/tool services.
The package delegates lifecycle work to [`startInProcessRun`](../subagent-inprocess/README.md) with no seed. Child creation, persona, tool filtering, structured output, cancellation, and quiescent disposal are owned by the shared driver. `run.started` resolves only after publication, so `subagent/start` observers can resolve the child from `ctx.agents`.
## Behavior
`start(request)` delegates to [`startInProcessRun`](../subagent-inprocess/README.md) with no seed and awaits publication before returning. The child receives parent working-directory/session lineage and inherits the parent model unless overridden, but starts with an empty conversation.
The shared driver owns depth checking, persona and tool-filter setup, structured output, required-signal cancellation, one-shot execution, result reading, and quiescent disposal. A startup rejection leaves no published child; provider unload after fulfillment does not revoke the holder-owned run.
## Capabilities
`{ outputSchema: true, depthLimit: true, toolFilter: true, persona: true }`
Spawn advertises `{ outputSchema: true, depthLimit: true, toolFilter: true, persona: true }` because it controls the child's creation window and can enforce all four features.
## Config

View File

@@ -38,16 +38,16 @@ class SpawnProvider implements SubagentProvider {
// Context contract: a spawned child starts fresh — it never sees the parent conversation.
readonly inheritsParentContext = false
constructor(readonly name: string, private readonly ctx: Context) {}
constructor(readonly name: string) {}
start(request: SubagentStartRequest) {
// Fresh child: no seed. The shared driver mints ids, stamps cwd/lineage/
// depth, drives the one-shot (including the structured capture when the
// request carries an outputSchema), and maps the result.
return startInProcessRun(this.ctx, request, {})
return startInProcessRun(request, {})
}
}
export function apply(ctx: Context, config: Config): void {
ctx.subagents.registerProvider(new SpawnProvider(config.providerName, ctx))
ctx.subagents.registerProvider(new SpawnProvider(config.providerName))
}

View File

@@ -9,7 +9,7 @@ import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import * as Invariants from '@deepseek-ai/dsh-invariants'
import SubagentService from '@deepseek-ai/dsh-subagent'
import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import * as spawn from '../src/index.ts'
import { depthOf, STRUCTURED_OUTPUT_TOOL, SubagentDepthError } from '@deepseek-ai/dsh-subagent-inprocess'
@@ -44,11 +44,15 @@ function text(blocks: { type: string; text?: string }[]): string {
return blocks.filter(b => b.type === 'text').map(b => b.text).join('')
}
function start(ctx: Context, provider: string, request: Omit<SubagentStartRequest, 'signal'> & { signal?: AbortSignal }) {
return ctx.subagents.start(provider, { signal: request.signal ?? new AbortController().signal, ...request })
}
describe('dsh-subagent-spawn', () => {
it('runs a fresh child to completion and returns its final assistant output', async () => {
// One model call for the child: a plain text answer.
const { ctx, parent } = await setup([textResponse('child answer')])
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'do X' }], parent })
const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'do X' }], parent })
const result = await run.result
expect(result.stopReason).toBe('completed')
expect(text(result.output)).toBe('child answer')
@@ -62,11 +66,11 @@ describe('dsh-subagent-spawn', () => {
if (info.provider === 'spawn') childAtStart = ctx.agents.get(info.id)
})
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'do X' }], parent })
const starting = start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'do X' }], parent })
// Creation is asynchronous; no lifecycle claim is made while the child is
// still inside its unpublished setup transaction.
expect(childAtStart).toBeUndefined()
await run.started
const run = await starting
expect(childAtStart).toBe(ctx.agents.get(run.id))
expect(childAtStart?.id).toBe(run.id)
@@ -76,7 +80,7 @@ describe('dsh-subagent-spawn', () => {
it('gives the child its OWN session (not the parent\'s), with parentSession lineage', async () => {
const { ctx, parent } = await setup([textResponse('hi')])
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
await run.result
const child = ctx.agents.get(run.id)!
expect(child.session.header.id).not.toBe(parent.session.header.id)
@@ -92,7 +96,7 @@ describe('dsh-subagent-spawn', () => {
const parentEventCount = parent.session.events.length
expect(parentEventCount).toBeGreaterThan(0)
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'child prompt' }], parent })
const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'child prompt' }], parent })
await run.result
const child = ctx.agents.get(run.id)!
// The child's first user/message is its OWN prompt, not the parent's history.
@@ -103,7 +107,7 @@ describe('dsh-subagent-spawn', () => {
it('disposes the child to quiescence (agent removed from the registry)', async () => {
const { ctx, parent } = await setup([textResponse('x')])
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
await run.result
expect(ctx.agents.get(run.id)).toBeDefined()
await run.dispose()
@@ -114,7 +118,7 @@ describe('dsh-subagent-spawn', () => {
it('stamps child depth = parent depth + 1 (via the merged AgentOptions field)', async () => {
const { ctx, parent } = await setup([textResponse('x')])
expect(depthOf(parent)).toBe(0)
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
await run.result
const child = ctx.agents.get(run.id)!
expect(depthOf(child)).toBe(1)
@@ -124,13 +128,13 @@ describe('dsh-subagent-spawn', () => {
it('refuses to spawn past maxDepth (depthLimit capability)', async () => {
const { ctx, parent } = await setup([])
// parent is depth 0, child would be depth 1 — cap at 0 forbids any child.
expect(() => ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent, maxDepth: 0 }))
.toThrow(SubagentDepthError)
await expect(start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent, maxDepth: 0 }))
.rejects.toThrow(SubagentDepthError)
})
it('maps a child that hit its token ceiling to stopReason "max-tokens"', async () => {
const { ctx, parent } = await setup([maxTokensResponse('cut off')])
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
const result = await run.result
expect(result.stopReason).toBe('max-tokens')
await run.dispose()
@@ -140,52 +144,31 @@ describe('dsh-subagent-spawn', () => {
// Empty script: the child's first model call throws "script exhausted", the
// turn ends `error`, and there is no assistant/message → empty output.
const { ctx, parent } = await setup([])
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
const result = await run.result
expect(result.stopReason).toBe('error')
expect(result.output).toEqual([])
await run.dispose()
})
it('settles aborted (without running the child) when the request signal is ALREADY aborted', async () => {
// Regression: a signal aborted before the run starts never fires an `abort` event, so the
// listener can't catch it.
it('rejects without publishing when the request signal is already aborted', async () => {
// Regression: a signal aborted BEFORE the run starts never fires an `abort`
// event, so the listener can't catch it. The driver must check the
// already-aborted case up front and settle `aborted` without running the
// child — otherwise an already-cancelled request runs to `completed`. The
// empty script proves the child's model is never called.
const controller = new AbortController()
controller.abort()
const { ctx, parent } = await setup([])
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent, signal: controller.signal })
const result = await run.result
expect(result.stopReason).toBe('aborted')
expect(result.output).toEqual([])
await run.dispose()
await expect(start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent, signal: controller.signal }))
.rejects.toThrow('aborted before child publication')
})
it('cancelling BEFORE the child turn starts settles aborted, not error', async () => {
// Regression: a cancel landing in the pre-turn window clears the queued prompt before any
// `turn/end` is logged.
const { ctx, parent } = await setup([])
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
run.cancel('early')
const result = await run.result
expect(result.stopReason).toBe('aborted')
expect(result.output).toEqual([])
await run.dispose()
})
it('a cancel from agent/queued maps a no-turn child log to aborted', async () => {
const { ctx, parent } = await setup([])
ctx.on('agent/queued', (agent) => {
if (agent.id === run.id) run.cancel('queued-window')
})
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
const result = await run.result
expect(result).toMatchObject({ stopReason: 'aborted', output: [] })
const child = ctx.agents.get(run.id)!
expect(child.session.events.some(event => event.type === 'turn/end')).toBe(false)
await run.dispose()
})
it('dispose during async child creation waits for rollback and leaves no orphan', async () => {
it('same-tick cancellation rejects start and prevents child publication', async () => {
// Regression: cancellation before publication used to set a flag but let the
// async factory publish a child anyway, so `started` fulfilled and lifecycle
// observers saw an agent for an attempt the caller had already cancelled.
// The empty script also proves no model turn can run.
const { ctx, parent } = await setup([])
const beforeAgents = ctx.agents.list().length
const beforeSessions = ctx.sessions.list().length
@@ -193,22 +176,36 @@ describe('dsh-subagent-spawn', () => {
ctx.on('session/created', () => void published.push('session/created'))
ctx.on('agent/created', () => void published.push('agent/created'))
ctx.on('agent/session-start', () => void published.push('agent/session-start'))
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
ctx.on('subagent/start', () => void published.push('subagent/start'))
ctx.on('subagent/end', () => void published.push('subagent/end'))
const controller = new AbortController()
const starting = start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent, signal: controller.signal })
controller.abort('early')
// Same tick: the factory has reserved ids and entered its async setup
// transaction, but has not published the child yet.
await run.dispose()
await expect(run.result).resolves.toMatchObject({ stopReason: 'aborted', output: [] })
await expect(starting).rejects.toThrow()
await Promise.resolve()
expect(ctx.agents.list()).toHaveLength(beforeAgents)
expect(ctx.sessions.list()).toHaveLength(beforeSessions)
expect(published).toEqual([])
})
it('a cancel from agent/queued maps a no-turn child log to aborted', async () => {
const { ctx, parent } = await setup([])
const controller = new AbortController()
ctx.on('agent/queued', () => { controller.abort('queued-window') })
const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent, signal: controller.signal })
const result = await run.result
expect(result).toMatchObject({ stopReason: 'aborted', output: [] })
const child = ctx.agents.get(run.id)!
expect(child.session.events.some(event => event.type === 'turn/end')).toBe(false)
await run.dispose()
})
it('cancelling a running child settles the run as aborted (the abort bridge + cancel())', async () => {
// 'hang' makes the child's model stream one chunk then wait until aborted.
const controller = new AbortController()
const { ctx, parent } = await setup(['hang'])
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent, signal: controller.signal })
const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent, signal: controller.signal })
// Let the child's turn start, then abort via the request signal (the
// backend bridges it to child.cancel()).
await new Promise(r => setTimeout(r, 30))
@@ -218,29 +215,18 @@ describe('dsh-subagent-spawn', () => {
await run.dispose()
})
it('run.cancel() also cancels the child directly', async () => {
it('dispose cancels the child and reaches quiescence', async () => {
const { ctx, parent } = await setup(['hang'])
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
await new Promise(r => setTimeout(r, 30))
run.cancel('test cancel')
await run.dispose()
const result = await run.result
expect(result.stopReason).toBe('aborted')
await run.dispose()
})
it('run.cancel() with no reason uses the default cancel reason', async () => {
const { ctx, parent } = await setup(['hang'])
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
await new Promise(r => setTimeout(r, 30))
run.cancel()
const result = await run.result
expect(result.stopReason).toBe('aborted')
await run.dispose()
})
it('does not expose the optional runtime methods (sendMessage/resume) in this cut', async () => {
const { ctx, parent } = await setup([textResponse('x')])
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
expect('sendMessage' in run).toBe(false)
expect('resume' in run).toBe(false)
await run.result
@@ -256,7 +242,7 @@ describe('dsh-subagent-spawn', () => {
meta: { cwd: '/tmp/parent-workspace' },
agentOptions: { model: 'mock' },
})
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent: parentHandle.agent })
const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent: parentHandle.agent })
await run.result
const child = ctx.agents.get(run.id)!
expect(child.session.header.cwd).toBe('/tmp/parent-workspace')
@@ -273,7 +259,7 @@ describe('dsh-subagent-spawn', () => {
agentOptions: {},
})
// The request supplies the child's model explicitly.
const run = ctx.subagents.start('spawn', {
const run = await start(ctx, 'spawn', {
prompt: [{ type: 'text', text: 'p' }],
parent: parentHandle.agent,
agentOptions: { model: 'mock' },
@@ -305,7 +291,7 @@ describe('dsh-subagent-spawn', () => {
const { ctx, parent } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 42 }),
])
const run = ctx.subagents.start('spawn', {
const run = await start(ctx, 'spawn', {
prompt: [{ type: 'text', text: 'produce the answer' }],
parent,
outputSchema: { type: 'object', properties: { answer: { type: 'number' } }, required: ['answer'] },
@@ -318,7 +304,7 @@ describe('dsh-subagent-spawn', () => {
await run.dispose()
})
it('a backend unload mid-structured-run settles the run and releases the runtime', async () => {
it('a backend unload does not revoke an accepted holder-owned run', async () => {
// Rebuild the stack by hand so we hold the backend's fiber.
const ctx = new Context()
const adapter = new MockAdapter(['hang'])
@@ -333,50 +319,27 @@ describe('dsh-subagent-spawn', () => {
const fiber = await ctx.plugin(spawn, { providerName: 'spawn' })
ctx.llm.registerAdapter(['mock'], adapter)
const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' })
const run = ctx.subagents.start('spawn', {
const controller = new AbortController()
const run = await start(ctx, 'spawn', {
prompt: [{ type: 'text', text: 'q' }],
parent,
signal: controller.signal,
outputSchema: { type: 'object', properties: { a: { type: 'number' } } },
})
// Let the child's step start streaming, then unload the backend.
// Provider removal prevents new starts but the returned run belongs to its
// holder and remains live.
await new Promise(resolve => setTimeout(resolve, 30))
await fiber.dispose()
expect(ctx.subagents.getProvider('spawn')).toBeUndefined()
expect(ctx.agents.get(run.id)).toBeDefined()
controller.abort('test complete')
const result = await run.result
expect(result.stopReason).toBe('error')
expect(result.stopReason).toBe('aborted')
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
await run.dispose()
})
it('a backend unload during child creation prevents every publication notification', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(Invariants)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SubagentService)
const fiber = await ctx.plugin(spawn, { providerName: 'spawn' })
ctx.llm.registerAdapter(['mock'], new MockAdapter([]))
const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' })
const published: string[] = []
ctx.on('session/created', () => void published.push('session/created'))
ctx.on('agent/created', () => void published.push('agent/created'))
ctx.on('agent/session-start', () => void published.push('agent/session-start'))
const run = ctx.subagents.start('spawn', {
prompt: [{ type: 'text', text: 'must never run' }], parent,
})
await fiber.dispose()
await run.result.catch(() => undefined)
await run.dispose()
expect(ctx.agents.get(run.id)).toBeUndefined()
expect(published).toEqual([])
})
it('a start racing an already-unloading backend cannot mint a run-owner fiber', async () => {
it('a start racing an already-unloading backend cannot begin child creation', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
@@ -393,10 +356,10 @@ describe('dsh-subagent-spawn', () => {
ctx.on('agent/created', () => void published.push('agent/created'))
const unloading = fiber.dispose()
expect(() => ctx.subagents.start('spawn', {
prompt: [{ type: 'text', text: 'must never start' }], parent,
})).toThrow(/inactive context/)
await unloading
await expect(start(ctx, 'spawn', {
prompt: [{ type: 'text', text: 'must never start' }], parent,
})).rejects.toThrow(/no subagent provider/)
expect(parent.ctx.fiber.getEffects()).toHaveLength(parentEffects)
expect(published).toEqual([])
@@ -423,7 +386,7 @@ describe('dsh-subagent-spawn', () => {
parent.send([{ type: 'text', text: 'hi' }])
await parent.whenIdle()
const run = ctx.subagents.start('spawn', {
const run = await start(ctx, 'spawn', {
prompt: [{ type: 'text', text: 'do X' }],
parent,
persona: 'You are the tersest test runner.',
@@ -446,7 +409,7 @@ describe('dsh-subagent-spawn', () => {
name: 'forbidden_tool', description: 'global', parameters: {},
execute: () => Promise.resolve([{ type: 'text', text: 'ran' }]),
})
const run = ctx.subagents.start('spawn', {
const run = await start(ctx, 'spawn', {
prompt: [{ type: 'text', text: 'do X' }],
parent,
toolFilter: { deny: ['forbidden_tool'] },
@@ -466,13 +429,11 @@ describe('dsh-subagent-spawn', () => {
it('an unknown toolFilter name fails the spawn loudly with no orphaned child', async () => {
const { ctx, parent } = await setup([])
const before = ctx.agents.list().length
const run = ctx.subagents.start('spawn', {
await expect(start(ctx, 'spawn', {
prompt: [{ type: 'text', text: 'do X' }],
parent,
toolFilter: { deny: ['no_such_tool'] },
})
await expect(run.result).rejects.toThrow(/unknown tool "no_such_tool"/)
await run.dispose()
})).rejects.toThrow(/unknown global tool "no_such_tool"/)
expect(ctx.agents.list().length).toBe(before)
})
})
@@ -492,12 +453,10 @@ describe('dsh-subagent-spawn', () => {
ctx.on('session/created', () => void published.push('session/created'))
ctx.on('agent/created', () => void published.push('agent/created'))
ctx.on('agent/session-start', () => void published.push('agent/session-start'))
const run = ctx.subagents.start('spawn', {
await expect(start(ctx, 'spawn', {
prompt: [{ type: 'text', text: 'do X' }],
parent: parentHandle.agent,
})
await expect(run.result).rejects.toThrow(/inactive context/)
await run.dispose()
})).rejects.toThrow(/inactive context/)
expect(ctx.agents.list().length).toBe(before)
expect(ctx.sessions.list()).toHaveLength(sessionsBefore)
expect(published).toEqual([])
@@ -515,18 +474,16 @@ describe('dsh-subagent-spawn', () => {
ctx.on('agent/created', () => void published.push('agent/created'))
ctx.on('agent/session-start', () => void published.push('agent/session-start'))
const run = ctx.subagents.start('spawn', {
const starting = start(ctx, 'spawn', {
prompt: [{ type: 'text', text: 'must never run' }],
parent: parentHandle.agent,
})
// The factory has entered its awaited unpublished setup transaction. Parent
// ownership was installed before that await, so disposal wins without an
// The factory has entered its awaited unpublished setup transaction. The
// parent context owns that transaction, so disposal wins without an
// observer ever seeing the child.
await parentHandle.dispose()
await expect(run.result).rejects.toThrow(/owner disposed during setup|inactive context/)
await run.dispose()
await expect(starting).rejects.toThrow(/owner disposed during setup|inactive context/)
expect(ctx.agents.get(run.id)).toBeUndefined()
expect(published).toEqual([])
})
})

View File

@@ -1,43 +1,61 @@
# @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.
The subagent seam lets one agent delegate work to a child through a named provider. Callers use one service API (`ctx.subagents`); providers decide whether the child runs in this process, in another process, or through a future transport.
This package is the interface third of the capability seam, split so each concern evolves (and swaps) independently:
## Package roles
The family separates the stable interface from implementations and model-facing tools:
| 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` |
| `@deepseek-ai/dsh-subagent` | Provider registry, request/result types, and lifecycle events. |
| `@deepseek-ai/dsh-subagent-spawn` | Fresh in-process child. |
| `@deepseek-ai/dsh-subagent-fork` | In-process child seeded with completed parent turns. |
| `@deepseek-ai/dsh-subagent-acp` | Fresh out-of-process ACP child. |
| `@deepseek-ai/dsh-tool-subagent` | Model-facing tool over one configured provider. |
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.
Multiple providers may coexist under different names. This lets a deployment expose, for example, a cheap in-process child and an isolated ACP child without changing the service contract.
## Service API (`ctx.subagents`)
## Service API
| Member | Semantics |
`SubagentService` has four main operations:
| Member | Meaning |
|---|---|
| `registerProvider(provider)` | Register a frozen acceptance snapshot under `provider.name`; later caller mutation cannot change registry behavior or HMR cleanup, while `start` stays bound to the original provider receiver. Throws `SubagentError('DUPLICATE_PROVIDER')` on a name clash. Effect-scoped (HMR-safe); returns the disposer. |
| `getProvider(name)` | Look up the frozen registry snapshot (`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`. Emit `subagent/start` only after `run.started` fulfills and the paired `subagent/end` after that started run settles; a pre-publication readiness rejection emits neither. |
| `registerProvider(provider)` | Register one trusted same-process implementation by name. Registration is effect-scoped; removing it prevents new starts but does not revoke runs already returned to callers. Duplicate names fail loud. |
| `getProvider(name)` | Return the provider, or `undefined` when absent. |
| `list()` | Return provider names in insertion order. |
| `start(name, request)` | Validate requested capabilities and semantic values, then await the provider until a real child is ready. Fulfillment returns a holder-owned `SubagentRun`; rejection means the provider has already cleaned every partial startup resource. |
## Capabilities: two kinds, discovered two ways
`SubagentStartRequest.signal` is required and is the canonical cancellation channel. An abort before publication makes `start()` reject after rollback; an abort after publication cancels the live child. The request may also select a model, require structured output, cap delegation depth, restrict child tools, or set a child persona.
- **Start-time features** (`outputSchema`, `depthLimit`, `toolFilter/persona`) 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.
Same-process requests, descriptors, results, and event payloads are trusted typed values borrowed as immutable. The service does not clone or freeze them; serialization and hostile-input validation belong at actual process, worker, persistence, and model boundaries.
Beside `capabilities` sits one DESCRIPTIVE fact, not validated by the service: `provider.inheritsParentContext` — whether a child sees the parent conversation (`fork`: true — seeded with the completed-turn prefix; `spawn`/`acp`: false). The model-facing consumer (`dsh-tool-subagent`) derives truthful tool wording from it.
## Capabilities
## Run lifecycle
Start-time features are advertised in `provider.capabilities` because the service must reject an unsupported request before child creation:
`provider.start(request)` returns a `SubagentRun`: a handle with `started` (the publication/readiness promise), `result` (the terminal outcome), `cancel()`, `dispose()`, and the optional runtime methods. `started` resolves only after the provider has established a real child and rejects if the attempt fails or is cancelled first. `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.
- `outputSchema` — enforce a structured final result.
- `depthLimit` — enforce `maxDepth`.
- `toolFilter` — apply the requested child tool restriction.
- `persona` — apply a per-child persona.
The service emits provider-added and provider-removed after registry changes, so consumers track membership without assuming sibling load order. A run emits `subagent/start` only after readiness and `subagent/end` only after that announced run settles; readiness rejection emits neither. Both are observe-only. Result settlement is observed immediately, cloned, and buffered until start to prevent unhandled rejection, preserve start-before-end ordering, and isolate listener mutation. Settled output appears as `lastAssistantMessage`; infrastructure rejection omits it. Remote providers need not publish a local agent.
Runtime features are optional methods on `SubagentRun`: `sendMessage?` steers a live child, while `resume?` asynchronously creates a continuation run. Method presence is the capability check.
## Scope (first cut)
`inheritsParentContext` is descriptive rather than enforceable. It says only whether the child sees completed parent conversation history (`fork` does; `spawn` and ACP do not), not whether it inherits tools, services, or authority.
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/implemented/feature/2026-06-21-subagent-capability-seam.md](../../../docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md).
## Ownership and lifecycle
See `src/types.ts` for the full contracts.
`provider.start(request): Promise<SubagentRun>` is the ownership-transfer boundary. Before fulfillment, the provider owns setup and must cancel, roll back, and quiesce partial resources on every failure. After fulfillment, the caller owns the run and must call `dispose()` on every path.
`SubagentRun.result` resolves to `{ output, structured?, stopReason }`. Child-level failures resolve with a non-`completed` reason; only an infrastructure fault that the seam cannot represent may reject. `dispose()` is idempotent, cancels remaining work, and waits for the child resources to quiesce.
The service emits `subagent/start` only after `start()` has fulfilled. It attaches the result observer before that synchronous notification, so even an already-settled child still produces `subagent/start` before `subagent/end`. In-process start observers can resolve the published child through `ctx.agents.get(info.id)`; remote providers need not publish a local agent.
Run events are scoped to the delegating parent. Every listener is independently contained: a synchronous throw or rejected returned promise is logged without starving peer listeners or changing the run.
Provider additions and removals also emit `subagent/provider-added` and `subagent/provider-removed`. Consumers such as the model-facing tool use those events because Cordis may load sibling plugins concurrently; configuration order does not prove registration order.
## Collection model
The current model-facing tool collects synchronously: it awaits the child result and disposes the run before returning. Background collection and polling remain outside this seam. See the [capability-seam RFC](../../../docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md) and `src/types.ts` for the complete contracts.

View File

@@ -1,15 +1,21 @@
/**
* 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).
* The subagent seam (`ctx.subagents`): a named-provider registry plus a
* capability-validating asynchronous start surface. Providers establish a
* child before returning its run, so fulfillment is the single publication and
* ownership-transfer boundary.
*
* Same-process providers are trusted typed collaborators. Requests, provider
* descriptors, results, and lifecycle payloads are borrowed immutable values;
* serialization and hostile-input validation belong at real process, worker,
* persistence, and model boundaries.
*
* @module @deepseek-ai/dsh-subagent
*/
import { Context, Service } from 'cordis'
import { scopeTarget } from '@deepseek-ai/dsh-scope'
import { assertSupportedOutputSchema } from '@deepseek-ai/dsh-tools'
import type { Scoped } from '@deepseek-ai/dsh-scope'
import { assertSupportedOutputSchema } from '@deepseek-ai/dsh-tools'
import { HarnessError } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { Agent, AgentId } from '@deepseek-ai/dsh-agent'
@@ -31,6 +37,21 @@ export type {
SubagentStopReasonMap,
} from './types.ts'
/**
* Reject a recursion cap that cannot represent an exact delegation depth.
* @param maxDepth - the optional runtime value to validate.
*/
export function assertSubagentMaxDepth(maxDepth: unknown): void {
if (maxDepth !== undefined && (
typeof maxDepth !== 'number'
|| !Number.isSafeInteger(maxDepth)
|| maxDepth < 0
|| Object.is(maxDepth, -0)
)) {
throw new TypeError('subagent maxDepth must be a non-negative safe integer')
}
}
declare module 'cordis' {
interface Context {
subagents: SubagentService
@@ -38,80 +59,59 @@ declare module 'cordis' {
interface Events {
/**
* A provider became resolvable in the {@link SubagentService} registry.
* Consumers that derive state from a named provider (e.g. the model-facing
* tool wording in `dsh-tool-subagent`) react HERE instead of assuming load
* order — the cordis Loader starts sibling plugins concurrently, so
* "listed earlier in cordis.yml" does not mean "registered earlier".
* @param provider - the registry's frozen acceptance snapshot of the provider.
* A provider became resolvable in the registry.
* @param provider - the registered provider.
* @mode emit
*/
'subagent/provider-added'(provider: SubagentProvider): void
/**
* A provider left the registry (its plugin's fiber was disposed — an
* unload or an HMR reload). Consumers holding provider-derived state drop
* it here; a reload re-fires `subagent/provider-added` with the fresh
* provider. Delivered with per-listener containment: a throwing
* subscriber is logged, never starves later subscribers, and never
* disrupts the provider's teardown.
* @param name - the registry name that no longer resolves.
* A provider left the registry. Accepted runs remain holder-owned.
* @param name - the provider name that no longer resolves.
* @mode emit
*/
'subagent/provider-removed'(name: string): void
/**
* A subagent run started — emitted only after {@link SubagentRun.started} fulfills, when
* the provider has established a live child.
*
* Scope-filtered dispatch: keyed to the delegating parent.
* @param info - which provider started which child agent.
* A provider established a ready child. For in-process providers,
* `ctx.agents.get(info.id)` resolves during this notification.
* Scope-filtered dispatch keys the carrier by the delegating parent, so a
* parent-scoped listener observes only its own delegations. Paired with
* `subagent/end`.
* @param info - the provider and ready child identity.
* @mode emit
*/
'subagent/start'(this: Scoped<SubagentService>, info: SubagentRunInfo): void
/**
* A started subagent run settled — emitted when {@link SubagentRun.result}
* resolves (any stop reason) or rejects (reported as `error`). Paired with
* {@link Events['subagent/start']}; a run whose readiness rejected emits
* neither event.
* Dispatch is scoped to the delegating parent.
* Scope-filtered dispatch: keyed to the delegating parent.
* @param info - the run identity plus stop reason and final output.
* A ready child settled. Scope-filtered dispatch uses the same delegating
* parent carrier as `subagent/start`, so the lifecycle pair reaches the
* same scoped audience.
* @param info - the run identity and terminal outcome.
* @mode emit
*/
'subagent/end'(this: Scoped<SubagentService>, info: SubagentRunEndInfo): void
}
}
/** Identifying detail for a started subagent run (the `subagent/start` payload). */
/** Observe-only identifying detail for a ready subagent run. */
export interface SubagentRunInfo {
/** The provider that started the run. */
provider: string
/** The provider that established the run. */
readonly provider: string
/** The child agent's id. */
id: AgentId
readonly id: AgentId
}
/** Outcome detail for a settled subagent run (the `subagent/end` payload). */
/** Observe-only outcome detail for a settled subagent run. */
export interface SubagentRunEndInfo {
/** The provider that ran it. */
provider: string
readonly provider: string
/** The child agent's id. */
id: AgentId
readonly id: AgentId
/** The terminal stop reason. */
stopReason: SubagentResult['stopReason']
/**
* The child's final assistant output ({@link SubagentResult.output}), carried
* onto the end event so an observer sees WHAT the subagent produced without
* holding the run. Absent when the run rejected at the infrastructure level
* (no {@link SubagentResult} was produced — the seam only knows `stopReason:
* 'error'`).
*/
lastAssistantMessage?: ContentBlock[]
readonly stopReason: SubagentResult['stopReason']
/** The child's final assistant output, absent on infrastructure rejection. */
readonly lastAssistantMessage?: ContentBlock[]
}
/**
* Typed error for subagent-seam failures. Extends {@link HarnessError}, so the
* `code` string (`DUPLICATE_PROVIDER`, `NO_PROVIDER`, `UNSUPPORTED_CAPABILITY`)
* is shared, machine-routable taxonomy.
*/
/** Typed error for provider lookup, registration, and capability failures. */
export class SubagentError extends HarnessError {
constructor(message: string, code: string, options?: ErrorOptions) {
super(message, code, options)
@@ -119,10 +119,7 @@ export class SubagentError extends HarnessError {
}
}
/**
* The `subagents` service: a registry of named {@link SubagentProvider}s and a
* capability-checked {@link start} surface.
*/
/** Named provider registry and capability-checked start surface. */
export class SubagentService extends Service {
private providers = new Map<string, SubagentProvider>()
@@ -131,148 +128,88 @@ export class SubagentService extends Service {
}
/**
* Register a provider under its `provider.name`.
*
* @param provider - the provider; its `name` is the registry key.
* @returns the disposer that unregisters the provider. The exact
* Cordis effect disposer (single-shot): composite (generator) effects may
* yield it directly — exact identity nests the teardown in order.
* Register a provider under its name. Registration is effect-scoped and HMR
* safe; removing a provider blocks new starts but does not revoke runs that
* were already returned to their holders.
* @param provider - the trusted provider implementation.
* @returns the exact Cordis effect disposer.
*/
registerProvider(provider: SubagentProvider): () => Promise<void> | void {
// Snapshot the accepted registration contract before entering the effect.
const capabilities: SubagentCapabilities = Object.freeze({
outputSchema: provider.capabilities.outputSchema,
depthLimit: provider.capabilities.depthLimit,
toolFilter: provider.capabilities.toolFilter,
persona: provider.capabilities.persona,
})
const snapshot: SubagentProvider = Object.freeze({
name: provider.name,
capabilities,
inheritsParentContext: provider.inheritsParentContext,
start: provider.start.bind(provider),
})
const dispose = this.ctx.effect(function* (this: SubagentService) {
if (this.providers.has(snapshot.name)) {
throw new SubagentError(`a subagent provider named "${snapshot.name}" is already registered`, 'DUPLICATE_PROVIDER')
const name = provider.name
return this.ctx.effect(function* (this: SubagentService) {
if (this.providers.has(name)) {
throw new SubagentError(`a subagent provider named "${name}" is already registered`, 'DUPLICATE_PROVIDER')
}
this.providers.set(snapshot.name, snapshot)
// Yield the rollback before emitting `subagent/provider-added`: a throwing added-listener
// then unregisters the provider (and announces the removal) instead of leaking it into
// the registry.
this.providers.set(name, provider)
yield () => {
this.providers.delete(snapshot.name)
this.emitLifecycle('subagent/provider-removed', snapshot.name)
this.providers.delete(name)
this.emitLifecycle('subagent/provider-removed', name)
}
this.ctx.emit('subagent/provider-added', snapshot)
// A throwing added-listener unwinds the yielded rollback, matching the
// repository's fail-loud registration semantics.
this.ctx.emit('subagent/provider-added', provider)
}.bind(this), 'subagents.registerProvider()')
// Return the exact Cordis disposer so generator effects preserve teardown nesting.
return dispose
}
/**
* Look up the registry's frozen provider snapshot by its accepted name
* (`undefined` if absent).
* @param name - the provider name accepted at registration.
* @returns the frozen acceptance snapshot, or undefined when the name is unknown.
* Look up a provider by name.
* @param name - the provider name.
* @returns the provider, or undefined when absent.
*/
getProvider(name: string): SubagentProvider | undefined {
return this.providers.get(name)
}
/**
* The names of all registered providers (insertion order).
* @returns the registered provider names.
* List registered provider names in insertion order.
* @returns the registered names.
*/
list(): string[] {
return [...this.providers.keys()]
}
/**
* Start a subagent run on the named provider.
*
* @param name - the provider to run on.
* @param request - the child's prompt, capabilities, and options.
* @returns the live run (its `result` resolves when the child settles).
* Establish a ready child on the named provider. Capability and semantic
* checks run before delegation. Provider ownership lasts until its promise
* fulfills; a rejection therefore has no run for the caller to dispose and
* emits no run lifecycle events.
* @param name - the provider to use.
* @param request - child prompt, parent, signal, and optional capabilities.
* @returns the ready holder-owned run.
*/
start(name: string, request: SubagentStartRequest): SubagentRun {
// Parent is the lifecycle scope identity accepted at start. Never reread it
// from the caller-owned request after the provider/result async boundary,
// or start/end could be dispatched into different agent scopes.
const parent = request.parent
async start(name: string, request: SubagentStartRequest): Promise<SubagentRun> {
const provider = this.providers.get(name)
if (!provider) {
if (provider === undefined) {
throw new SubagentError(`no subagent provider registered for "${name}"`, 'NO_PROVIDER')
}
this.assertCapabilities(provider, request)
assertSubagentMaxDepth(request.maxDepth)
if (request.outputSchema !== undefined) assertSupportedOutputSchema(request.outputSchema)
// Detach every data field before crossing into a provider.
const accepted: SubagentStartRequest = {
prompt: structuredClone(request.prompt),
parent,
...request.signal !== undefined ? { signal: request.signal } : {},
...request.agentOptions !== undefined ? { agentOptions: structuredClone(request.agentOptions) } : {},
...request.outputSchema !== undefined ? { outputSchema: structuredClone(request.outputSchema) } : {},
...request.maxDepth !== undefined ? { maxDepth: request.maxDepth } : {},
...request.toolFilter !== undefined ? { toolFilter: structuredClone(request.toolFilter) } : {},
...request.persona !== undefined ? { persona: request.persona } : {},
}
const run = provider.start(accepted)
// Observe result settlement IMMEDIATELY, before waiting on readiness.
let readiness: 'pending' | 'started' | 'failed' = 'pending'
let pendingEnd: SubagentRunEndInfo | undefined
const deliverEnd = (info: SubagentRunEndInfo): void => {
if (readiness === 'started') this.emitLifecycle('subagent/end', info, parent)
else if (readiness === 'pending') pendingEnd = info
// A pre-publication readiness failure has no lifecycle pair; result
// remains observable by the run's consumer, but telemetry must not claim
// that a child started.
}
const parent = request.parent
const run = await provider.start(request)
// Attach the terminal observer before dispatching start. Promise reactions
// still run after this synchronous start emission, preserving start → end.
void run.result.then(
(result) => {
// Snapshot before the caller's own `await run.result` continuation.
let lastAssistantMessage: SubagentResult['output'] | undefined
try {
lastAssistantMessage = structuredClone(result.output)
} catch (error: unknown) {
this.ctx.logger.warn(`subagent: could not clone ${name} output for subagent/end: ${String(error)}`)
}
deliverEnd({
this.emitLifecycle('subagent/end', {
provider: name,
id: run.id,
stopReason: result.stopReason,
...lastAssistantMessage !== undefined ? { lastAssistantMessage } : {},
})
},
() => { deliverEnd({ provider: name, id: run.id, stopReason: 'error' }) },
)
// Readiness is the publication boundary owned by the provider.
void run.started.then(
() => {
readiness = 'started'
this.emitLifecycle('subagent/start', { provider: name, id: run.id }, parent)
if (pendingEnd !== undefined) {
const info = pendingEnd
pendingEnd = undefined
this.emitLifecycle('subagent/end', info, parent)
}
lastAssistantMessage: result.output,
}, parent)
},
() => {
readiness = 'failed'
pendingEnd = undefined
this.emitLifecycle('subagent/end', { provider: name, id: run.id, stopReason: 'error' }, parent)
},
)
this.emitLifecycle('subagent/start', { provider: name, id: run.id }, parent)
return run
}
/**
* 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.
* Emit lifecycle events with per-listener synchronous and asynchronous
* exception containment. Payloads are borrowed immutable values.
*/
private emitLifecycle(name: 'subagent/start', info: SubagentRunInfo, parent: Agent): void
private emitLifecycle(name: 'subagent/end', info: SubagentRunEndInfo, parent: Agent): void
@@ -282,26 +219,22 @@ export class SubagentService extends Service {
info: SubagentRunInfo | SubagentRunEndInfo | string,
parent?: Agent,
): void {
// Run lifecycle events dispatch in the DELEGATING PARENT's scope (a parent-scoped listener
// observes only its own delegations); the provider-removed registry notification stays
// unfiltered.
const dispatchArgs: unknown[] = parent === undefined
? [name, info]
: [scopeTarget(this, parent), name, info]
for (const callback of this.ctx.events.dispatch('emit', dispatchArgs)) {
try {
callback(info)
const returned: unknown = callback(info)
void Promise.resolve(returned).catch((error: unknown) => {
this.ctx.logger.warn(`subagent: ${name} listener rejected: ${renderThrown(error)}`)
})
} catch (error: unknown) {
this.ctx.logger.warn(`subagent: ${name} listener threw: ${String(error)}`)
this.ctx.logger.warn(`subagent: ${name} listener threw: ${renderThrown(error)}`)
}
}
}
/**
* 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`.
*/
/** Reject the first requested capability that the provider lacks. */
private assertCapabilities(provider: SubagentProvider, request: SubagentStartRequest): void {
const needs: { when: boolean; cap: keyof SubagentCapabilities }[] = [
{ when: request.outputSchema !== undefined, cap: 'outputSchema' },
@@ -320,4 +253,13 @@ export class SubagentService extends Service {
}
}
/** Render any listener-thrown value without letting coercion escape containment. */
function renderThrown(value: unknown): string {
try {
return value instanceof Error ? `${value.name}: ${value.message}` : String(value)
} catch {
return '<unrenderable thrown value>'
}
}
export default SubagentService

View File

@@ -8,7 +8,7 @@
import type { Agent, AgentId, AgentOptions } from '@deepseek-ai/dsh-agent'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { StructuredOutputSchema } from '@deepseek-ai/dsh-tools'
import type { StructuredOutputSchema, ToolRestriction } from '@deepseek-ai/dsh-tools'
/**
* Which START-TIME features a provider supports. Checked by the service before delegating to
@@ -18,13 +18,13 @@ import type { StructuredOutputSchema } from '@deepseek-ai/dsh-tools'
*/
export interface SubagentCapabilities {
/** Honor {@link SubagentStartRequest.outputSchema} (structured final output). */
outputSchema: boolean
readonly outputSchema: boolean
/** Enforce {@link SubagentStartRequest.maxDepth} (recursion cap). */
depthLimit: boolean
readonly depthLimit: boolean
/** Enforce {@link SubagentStartRequest.toolFilter} (child tool scoping). */
toolFilter: boolean
readonly toolFilter: boolean
/** Honor {@link SubagentStartRequest.persona} (a per-child persona). */
persona: boolean
readonly persona: boolean
}
/**
@@ -35,22 +35,24 @@ export interface SubagentCapabilities {
*/
export interface SubagentStartRequest {
/** The task/prompt for the child agent (a user message in the child session). */
prompt: ContentBlock[]
readonly 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
readonly 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.
* This is the canonical cancellation channel both before and after startup:
* a provider rejects `start()` after cleaning partial resources when it
* fires before publication, and cancels a published child when it fires
* afterward.
*/
signal?: AbortSignal
readonly signal: AbortSignal
/** Per-child agent options (model, system prompt). */
agentOptions?: AgentOptions
readonly agentOptions?: AgentOptions
/**
* Optional structured-output schema — an object-rooted JSON Schema within the
* enforced subset (see `assertSupportedOutputSchema` in dsh-tools; a schema
@@ -61,12 +63,14 @@ export interface SubagentStartRequest {
* data — a caller holding foreign-realm data materializes it first.
* Requesting it against a provider that lacks the capability is rejected at start.
*/
outputSchema?: StructuredOutputSchema
readonly outputSchema?: StructuredOutputSchema
/**
* Optional recursion cap (max delegation depth below this child). Requires
* {@link SubagentCapabilities.depthLimit}; rejected at start otherwise.
* Optional absolute delegation-depth cap for the child being started: its
* computed depth must be less than or equal to this non-negative safe
* integer. Requires {@link SubagentCapabilities.depthLimit}; rejected at
* start otherwise.
*/
maxDepth?: number
readonly maxDepth?: number
/**
* Optional child tool scoping. Requires {@link SubagentCapabilities.toolFilter};
* rejected at start otherwise. In-process backends apply it as a scoped
@@ -74,7 +78,7 @@ export interface SubagentStartRequest {
* from the child's prompt AND refuse to execute (one visibility), with loud
* unknown-name validation.
*/
toolFilter?: { allow?: string[]; deny?: string[] }
readonly toolFilter?: ToolRestriction
/**
* Optional per-child persona. Requires {@link SubagentCapabilities.persona};
* rejected at start otherwise. In-process backends register it as a scoped
@@ -82,7 +86,7 @@ export interface SubagentStartRequest {
* persona for this child alone — same template semantics as the deployment
* persona (strict `{{…}}` interpolation against the registered variables).
*/
persona?: string
readonly persona?: string
}
/**
@@ -94,7 +98,7 @@ export interface SubagentStartRequest {
export interface SubagentStopReasonMap {
/** The child finished its turn normally. */
completed: 'completed'
/** The run was cancelled (parent signal, explicit `cancel()`, or peer cancel). */
/** The run was cancelled by its request signal or by disposal. */
aborted: 'aborted'
/** The child failed (model error, transport error). */
error: 'error'
@@ -112,7 +116,7 @@ export type SubagentStopReason = SubagentStopReasonMap[keyof SubagentStopReasonM
*/
export interface SubagentResult {
/** The child's final assistant output (the last assistant message's content). */
output: ContentBlock[]
readonly output: ContentBlock[]
/**
* The structured result after a requested `outputSchema` was successfully
* satisfied. Requesting a schema does not guarantee presence: a provider can
@@ -120,32 +124,24 @@ export interface SubagentResult {
* valid capture. Shape is validated against the request schema by the
* provider; `unknown` here because the seam is schema-agnostic.
*/
structured?: unknown
readonly structured?: unknown
/** Why the run ended. A non-`completed` reason means `output` may be partial. */
stopReason: SubagentStopReason
readonly 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).
* Returned by {@link SubagentProvider.start} (via the service) only after the
* child is ready. The consumer awaits {@link result} and MUST {@link dispose}
* on every path to cancel any remaining work and reach child quiescence.
*
* {@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 (local in-process runs publish it in `ctx.agents`; remote transports need not). */
/** The child agent's id (local in-process runs are already published in `ctx.agents`; remote transports need not publish locally). */
readonly id: AgentId
/**
* The provider's publication/readiness boundary. Resolves only after a real
* child is established: an in-process agent is live in `ctx.agents`, or a
* remote transport has created its child session. Rejects when the attempt
* fails or is cancelled before that boundary. The service emits the paired
* `subagent/start`/`subagent/end` lifecycle only after this fulfills.
*/
readonly started: Promise<void>
/**
* Resolves with the child's terminal {@link SubagentResult} when the run
* settles. Does NOT reject on a child-level failure — a model/transport
@@ -154,12 +150,10 @@ export interface SubagentRun {
* cannot represent as a stop reason.
*/
readonly result: Promise<SubagentResult>
/** Request cancellation of the in-flight run; {@link result} settles `aborted`. */
cancel(reason?: string): void
/**
* Reach child quiescence and release the run's resources (in-process: dispose
* the owned agent handle and remove its session; ACP: kill the subprocess).
* Idempotent; awaits the child actually stopping, not merely requesting it.
* Cancel remaining work, reach child quiescence, and release the run's
* resources (in-process: dispose the owned agent and remove its session;
* ACP: kill and reap the subprocess). Idempotent.
*/
dispose(): Promise<void>
/**
@@ -171,7 +165,7 @@ export interface SubagentRun {
* 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
resume?(content: ContentBlock[]): Promise<SubagentRun>
}
/**
@@ -179,8 +173,8 @@ export interface SubagentRun {
* 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). The
* service freezes the public descriptor and callback identity at registration;
* the captured `start` remains bound to the original provider receiver.
* Providers are trusted same-process implementations; callers treat their
* descriptors and returned values as borrowed immutable data.
*/
export interface SubagentProvider {
/** Unique registry name (e.g. `spawn`, `fork`, `acp`). */
@@ -188,22 +182,24 @@ export interface SubagentProvider {
/** The start-time features this provider supports (see {@link SubagentCapabilities}). */
readonly capabilities: SubagentCapabilities
/**
* The provider's context contract: `true` when a child SEES the parent
* The provider's conversation-history descriptor: `true` when a child SEES the parent
* conversation (fork — the child is seeded with the parent's completed-turn
* prefix), `false` when it starts fresh (spawn, ACP). A DESCRIPTIVE fact,
* not a start-time capability: the service validates nothing against it —
* the model-facing consumer (`dsh-tool-subagent`) derives truthful tool
* wording from it, so a tool bound to a fork provider stops telling the
* model the child "does not see this conversation".
* model the child "does not see this conversation". This descriptor concerns
* conversation history only; it says nothing about tool registrations,
* injected services, or authority inheritance.
*/
readonly inheritsParentContext: boolean
/**
* Start preparing a child run and return its handle synchronously. The
* Establish a child and return its handle only after publication. 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. The returned {@link SubagentRun.started} must mark
* the real publication/readiness boundary; the result path must observe that
* promise immediately so a pre-start rejection cannot become unhandled.
* honorable when present. If setup fails or `request.signal` aborts before
* fulfillment, the provider owns and cleans all partial resources before this
* promise rejects. Ownership transfers to the caller only on fulfillment.
*/
start(request: SubagentStartRequest): SubagentRun
start(request: SubagentStartRequest): Promise<SubagentRun>
}

View File

@@ -5,6 +5,7 @@ import { HarnessError } from '@deepseek-ai/dsh-llm'
import { carrierKeyOf } from '@deepseek-ai/dsh-scope'
import SubagentService, {
SubagentError,
assertSubagentMaxDepth,
type SubagentCapabilities,
type SubagentProvider,
type SubagentResult,
@@ -12,570 +13,218 @@ import SubagentService, {
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, persona: false }
const ALL_CAPS: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: true, persona: true }
const NO_CAPS: SubagentCapabilities = { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }
/** A scripted provider whose run settles immediately with a fixed result. */
function baseRequest(overrides: Partial<SubagentStartRequest> = {}): SubagentStartRequest {
return {
prompt: [{ type: 'text', text: 'do a thing' }],
parent: fakeParent(),
signal: new AbortController().signal,
...overrides,
}
}
class StubProvider implements SubagentProvider {
startCount = 0
readonly inheritsParentContext = false
startCount = 0
constructor(
readonly name: string,
readonly capabilities: SubagentCapabilities = ALL_CAPS,
private readonly result: SubagentResult = { output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' },
private readonly outcome: SubagentResult = {
output: [{ type: 'text', text: 'ok' }],
stopReason: 'completed',
},
) {}
start(request: SubagentStartRequest): SubagentRun {
this.startCount++
async start(request: SubagentStartRequest): Promise<SubagentRun> {
this.startCount += 1
return {
id: AgentId(`child:${this.name}:${request.parent.id}`),
started: Promise.resolve(),
result: Promise.resolve(this.result),
cancel() {},
result: Promise.resolve(this.outcome),
async dispose() {},
}
}
}
function baseRequest(overrides: Partial<SubagentStartRequest> = {}): SubagentStartRequest {
return { prompt: [{ type: 'text', text: 'do a thing' }], parent: fakeParent(), ...overrides }
async function service(): Promise<{ ctx: Context; subagents: SubagentService }> {
const ctx = new Context()
await ctx.plugin(SubagentService)
return { ctx, subagents: ctx.subagents }
}
describe('SubagentService', () => {
it('announces provider lifecycle: added on register, removed on dispose', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
it('registers, lists, looks up, starts, and removes providers', async () => {
const { ctx, subagents } = await service()
const added: string[] = []
const removed: string[] = []
ctx.on('subagent/provider-added', provider => void added.push(provider.name))
ctx.on('subagent/provider-removed', name => void removed.push(name))
const dispose = ctx.subagents.registerProvider(new StubProvider('alpha'))
expect(added).toEqual(['alpha'])
expect(removed).toEqual([])
await dispose()
expect(removed).toEqual(['alpha'])
})
it('rolls back the registration when a provider-added listener throws', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
let threw = false
const off = ctx.on('subagent/provider-added', () => {
if (!threw) { threw = true; throw new Error('boom added listener') }
})
expect(() => ctx.subagents.registerProvider(new StubProvider('alpha'))).toThrow('boom added listener')
expect(ctx.subagents.getProvider('alpha')).toBeUndefined() // nothing leaked
off()
ctx.subagents.registerProvider(new StubProvider('alpha'))
expect(ctx.subagents.getProvider('alpha')).toBeDefined()
})
it('contains a throwing provider-removed listener: later mirrors still hear it, teardown completes', async () => {
// provider-removed fires inside the registration's DISPOSER, so a propagating listener
// would disrupt the backend's teardown; and cordis emit halts on the first throw, so an
// uncontained one would starve every mirror registered after it (a stale model-facing
// tool).
const ctx = new Context()
await ctx.plugin(SubagentService)
const warnings: string[] = []
ctx.logger.warn = ((message: unknown) => void warnings.push(String(message))) as typeof ctx.logger.warn
ctx.on('subagent/provider-removed', () => { throw new Error('boom removed listener') })
const heard: string[] = []
ctx.on('subagent/provider-removed', name => void heard.push(name))
const dispose = ctx.subagents.registerProvider(new StubProvider('alpha'))
expect(() => void dispose()).not.toThrow()
expect(heard).toEqual(['alpha']) // the listener AFTER the thrower still ran
expect(ctx.subagents.getProvider('alpha')).toBeUndefined() // teardown reached quiescence
expect(warnings.some(w => w.includes('boom removed listener'))).toBe(true)
})
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')).toMatchObject({ name: 'alpha' })
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('snapshots a provider registration so caller mutation cannot corrupt dispatch or HMR cleanup', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
const capabilities: SubagentCapabilities = {
outputSchema: true,
depthLimit: true,
toolFilter: true,
persona: true,
}
const provider = new StubProvider('stable', capabilities)
const added: SubagentProvider[] = []
const removed: string[] = []
ctx.on('subagent/provider-added', registered => void added.push(registered))
ctx.on('subagent/provider-removed', name => void removed.push(name))
const owner = await ctx.plugin({
name: 'mutable-provider-owner',
inject: ['subagents'],
apply(pluginCtx: Context) {
pluginCtx.subagents.registerProvider(provider)
},
})
const accepted = ctx.subagents.getProvider('stable')
const mutable = provider as unknown as {
name: string
capabilities: SubagentCapabilities
inheritsParentContext: boolean
start: SubagentProvider['start']
}
mutable.name = 'mutated'
capabilities.outputSchema = false
capabilities.depthLimit = false
capabilities.toolFilter = false
capabilities.persona = false
mutable.capabilities = NO_CAPS
mutable.inheritsParentContext = true
const replacementStart = vi.fn((_request: SubagentStartRequest): SubagentRun => {
throw new Error('replacement start must not run')
})
mutable.start = replacementStart
expect(added).toEqual([accepted])
expect(accepted).not.toBe(provider)
expect(Object.isFrozen(accepted)).toBe(true)
expect(Object.isFrozen(accepted?.capabilities)).toBe(true)
expect(accepted).toMatchObject({
name: 'stable',
capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: true },
inheritsParentContext: false,
})
expect(ctx.subagents.list()).toEqual(['stable'])
expect(ctx.subagents.getProvider('mutated')).toBeUndefined()
const run = ctx.subagents.start('stable', baseRequest({
outputSchema: { type: 'object', properties: { answer: { type: 'string' } } },
maxDepth: 2,
toolFilter: { deny: ['bash'] },
persona: 'reviewer',
}))
const dispose = subagents.registerProvider(provider)
expect(subagents.list()).toEqual(['alpha'])
expect(subagents.getProvider('alpha')).toBe(provider)
const run = await subagents.start('alpha', baseRequest())
await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' })
expect(provider.startCount).toBe(1)
expect(replacementStart).not.toHaveBeenCalled()
await owner.dispose()
expect(removed).toEqual(['stable'])
expect(ctx.subagents.list()).toEqual([])
expect(() => ctx.subagents.registerProvider(new StubProvider('stable'))).not.toThrow()
})
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'])
await dispose()
expect(ctx.subagents.list()).toEqual([])
const disposeAgain = ctx.subagents.registerProvider(new StubProvider('reuse'))
expect(ctx.subagents.list()).toEqual(['reuse'])
await disposeAgain()
expect(ctx.subagents.list()).toEqual([])
expect(added).toEqual(['alpha'])
expect(removed).toEqual(['alpha'])
expect(subagents.getProvider('alpha')).toBeUndefined()
})
describe('start-time capability validation (fail loud, before any child)', () => {
it.each([
{ field: 'outputSchema', request: baseRequest({ outputSchema: { type: 'object', properties: { 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: { type: 'object', properties: { x: { type: 'string' } } }, maxDepth: 1 }))
expect(provider.startCount).toBe(1)
})
it('rolls registration back when provider-added throws', async () => {
const { ctx, subagents } = await service()
ctx.on('subagent/provider-added', () => { throw new Error('added boom') })
expect(() => { subagents.registerProvider(new StubProvider('alpha')) }).toThrow('added boom')
expect(subagents.getProvider('alpha')).toBeUndefined()
})
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())
await run.started
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('rejects duplicate and absent provider names with typed errors', async () => {
const { subagents } = await service()
subagents.registerProvider(new StubProvider('dup'))
expect(() => { subagents.registerProvider(new StubProvider('dup')) })
.toThrow(expect.objectContaining({ code: 'DUPLICATE_PROVIDER' }))
await expect(subagents.start('missing', baseRequest()))
.rejects.toMatchObject({ code: 'NO_PROVIDER' })
})
it('waits for provider readiness and observes an early result rejection without reordering lifecycle', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
const readiness = Promise.withResolvers<undefined>()
ctx.subagents.registerProvider({
name: 'delayed-start',
capabilities: NO_CAPS,
inheritsParentContext: false,
start: () => ({
id: AgentId('delayed-child'),
started: readiness.promise,
// Already rejected: SubagentService must attach its result handler in
// the same synchronous start() call, before awaiting readiness.
result: Promise.reject(new Error('early infrastructure fault')),
cancel() {},
async dispose() {},
}),
})
const lifecycle: string[] = []
ctx.on('subagent/start', () => void lifecycle.push('start'))
ctx.on('subagent/end', info => void lifecycle.push(`end:${info.stopReason}`))
const run = ctx.subagents.start('delayed-start', baseRequest())
await expect(run.result).rejects.toThrow('early infrastructure fault')
expect(lifecycle).toEqual([])
readiness.resolve(undefined)
await run.started
expect(lifecycle).toEqual(['start', 'end:error'])
it.each([
['outputSchema', { outputSchema: { type: 'object', properties: {} } }],
['depthLimit', { maxDepth: 1 }],
['toolFilter', { toolFilter: { deny: ['bash'] } }],
['persona', { persona: 'reviewer' }],
] as const)('rejects unsupported %s before provider startup', async (_capability, override) => {
const { subagents } = await service()
const provider = new StubProvider('weak', NO_CAPS)
subagents.registerProvider(provider)
await expect(subagents.start('weak', baseRequest(override)))
.rejects.toMatchObject({ code: 'UNSUPPORTED_CAPABILITY' })
expect(provider.startCount).toBe(0)
})
it('emits no lifecycle pair when readiness rejects before a child exists', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
const readiness = Promise.withResolvers<undefined>()
it('validates depth and schema semantics before provider startup', async () => {
const { subagents } = await service()
const provider = new StubProvider('strong')
subagents.registerProvider(provider)
await expect(subagents.start('strong', baseRequest({ maxDepth: -1 })))
.rejects.toThrow('non-negative safe integer')
await expect(subagents.start('strong', baseRequest({ outputSchema: { type: 'string' } as never })))
.rejects.toThrow()
expect(provider.startCount).toBe(0)
expect(() => { assertSubagentMaxDepth(undefined) }).not.toThrow()
})
it('publishes lifecycle only after async provider start and keeps parent scope', async () => {
const { ctx, subagents } = await service()
const ready = Promise.withResolvers<SubagentRun>()
const result = Promise.withResolvers<SubagentResult>()
ctx.subagents.registerProvider({
name: 'never-started',
subagents.registerProvider({
name: 'deferred',
capabilities: NO_CAPS,
inheritsParentContext: false,
start: () => ({
id: AgentId('never-started-child'),
started: readiness.promise,
result: result.promise,
cancel() {},
async dispose() {},
}),
start: () => ready.promise,
})
const parent = fakeParent('delegator')
const events: string[] = []
const keys: unknown[] = []
ctx.on('subagent/start', function () { events.push('start'); keys.push(carrierKeyOf(this)) })
ctx.on('subagent/end', function () { events.push('end'); keys.push(carrierKeyOf(this)) })
const starting = subagents.start('deferred', baseRequest({ parent }))
await Promise.resolve()
expect(events).toEqual([])
ready.resolve({ id: AgentId('child'), result: result.promise, async dispose() {} })
const run = await starting
expect(events).toEqual(['start'])
result.resolve({ output: [{ type: 'text', text: 'answer' }], stopReason: 'completed' })
await run.result
await Promise.resolve()
expect(events).toEqual(['start', 'end'])
expect(keys).toEqual([parent, parent])
})
it('emits no run lifecycle when provider startup rejects', async () => {
const { ctx, subagents } = await service()
subagents.registerProvider({
name: 'failed',
capabilities: NO_CAPS,
inheritsParentContext: false,
start: async () => { throw new Error('setup rolled back') },
})
const lifecycle = vi.fn()
ctx.on('subagent/start', lifecycle)
ctx.on('subagent/end', lifecycle)
const run = ctx.subagents.start('never-started', baseRequest())
readiness.reject(new Error('publication rolled back'))
await expect(run.started).rejects.toThrow('publication rolled back')
result.resolve({ output: [], stopReason: 'aborted' })
await run.result
await Promise.resolve()
await expect(subagents.start('failed', baseRequest())).rejects.toThrow('setup rolled back')
expect(lifecycle).not.toHaveBeenCalled()
})
it('pins start and end to the parent accepted at start despite caller mutation', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
const gate = Promise.withResolvers<SubagentResult>()
let acceptedRequest: SubagentStartRequest | undefined
ctx.subagents.registerProvider({
name: 'deferred',
capabilities: NO_CAPS,
inheritsParentContext: false,
start: (accepted) => {
acceptedRequest = accepted
return {
id: AgentId('deferred-child'),
started: Promise.resolve(),
result: gate.promise,
cancel() {},
async dispose() {},
}
},
it('emits an enriched end event and maps result rejection to error telemetry', async () => {
const { ctx, subagents } = await service()
const completed = new StubProvider('completed', NO_CAPS, {
output: [{ type: 'text', text: 'answer' }],
stopReason: 'completed',
})
const accepted = fakeParent('accepted-parent')
const replacement = fakeParent('replacement-parent')
const keys: unknown[] = []
ctx.on('subagent/start', function () { keys.push(carrierKeyOf(this)) })
ctx.on('subagent/end', function () { keys.push(carrierKeyOf(this)) })
const request = baseRequest({ parent: accepted })
const run = ctx.subagents.start('deferred', request)
request.parent = replacement
request.prompt[0] = { type: 'text', text: 'mutated prompt' }
expect(acceptedRequest?.parent).toBe(accepted)
expect(acceptedRequest?.prompt).toEqual([{ type: 'text', text: 'do a thing' }])
expect(acceptedRequest?.prompt).not.toBe(request.prompt)
gate.resolve({ output: [], stopReason: 'completed' })
await run.result
await Promise.resolve()
expect(keys).toEqual([accepted, accepted])
})
it('carries lastAssistantMessage (the child output) onto the end event', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
ctx.subagents.registerProvider(new StubProvider(
'enriched',
ALL_CAPS,
{ output: [{ type: 'text', text: 'the child answer' }], stopReason: 'completed' },
))
const started = vi.fn()
subagents.registerProvider(completed)
const ended = vi.fn()
ctx.on('subagent/start', started)
ctx.on('subagent/end', ended)
const run = ctx.subagents.start('enriched', baseRequest())
await run.started
expect(started).toHaveBeenCalledWith(expect.objectContaining({ provider: 'enriched', id: run.id }))
const run = await subagents.start('completed', baseRequest())
await run.result
await Promise.resolve()
expect(ended).toHaveBeenCalledWith(expect.objectContaining({
provider: 'enriched',
id: run.id,
provider: 'completed',
lastAssistantMessage: [{ type: 'text', text: 'answer' }],
stopReason: 'completed',
lastAssistantMessage: [{ type: 'text', text: 'the child answer' }],
}))
})
it('observe-only: a subagent/end listener mutating lastAssistantMessage cannot corrupt the caller\'s result', async () => {
// The subagent/end emit fires from a detached `.then` registered before start() returns —
// i.e.
const ctx = new Context()
await ctx.plugin(SubagentService)
ctx.subagents.registerProvider(new StubProvider(
'clone',
ALL_CAPS,
{ output: [{ type: 'text', text: 'original' }], stopReason: 'completed' },
))
ctx.on('subagent/end', (info) => {
// A hostile/buggy listener reaches in and mutates the event's array.
const blocks = info.lastAssistantMessage
if (blocks?.[0]?.type === 'text') blocks[0].text = 'HIJACKED'
blocks?.push({ type: 'text', text: 'injected' })
})
const run = ctx.subagents.start('clone', baseRequest())
const result = await run.result
await Promise.resolve() // let the detached settle hook (and its listener) run
// The caller's result.output is untouched by the listener's mutation.
expect(result.output).toEqual([{ type: 'text', text: 'original' }])
})
it('omits lastAssistantMessage on the reject path (no SubagentResult was produced)', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
ctx.subagents.registerProvider({
name: 'rej',
const failure = Promise.withResolvers<SubagentResult>()
subagents.registerProvider({
name: 'infra',
capabilities: NO_CAPS,
inheritsParentContext: false,
start: () => ({
id: AgentId('rej-child'),
started: Promise.resolve(),
result: Promise.reject(new Error('infra fault')),
cancel() {},
dispose: async () => {},
}),
async start() {
return { id: AgentId('infra-child'), result: failure.promise, async dispose() {} }
},
})
const ended = vi.fn()
ctx.on('subagent/end', ended)
const run = ctx.subagents.start('rej', baseRequest())
await run.result.catch(() => {})
const failedRun = await subagents.start('infra', baseRequest())
failure.reject(new Error('transport'))
await expect(failedRun.result).rejects.toThrow('transport')
await Promise.resolve()
const endInfo = ended.mock.calls[0]![0] as Record<string, unknown>
expect(endInfo.stopReason).toBe('error')
expect('lastAssistantMessage' in endInfo).toBe(false) // no output exists on reject
expect(ended).toHaveBeenCalledWith(expect.objectContaining({ provider: 'infra', stopReason: 'error' }))
})
it('contains a structuredClone failure: emits subagent/end without lastAssistantMessage (no unhandled rejection)', async () => {
// The clone runs inside onFulfilled, outside emitLifecycle's per-listener containment.
const ctx = new Context()
await ctx.plugin(SubagentService)
const warn = vi.fn(); ctx.logger.warn = warn as never
// An output value structuredClone cannot handle (a function is uncloneable).
const uncloneable = [{ type: 'text', text: 'x', evil: () => 0 }] as unknown as SubagentResult['output']
ctx.subagents.registerProvider({
name: 'unclone',
capabilities: NO_CAPS,
inheritsParentContext: false,
start: () => ({
id: AgentId('unclone-child'),
started: Promise.resolve(),
result: Promise.resolve({ output: uncloneable, stopReason: 'completed' } as SubagentResult),
cancel() {},
dispose: async () => {},
}),
})
it('contains synchronous and asynchronous lifecycle observer failures', async () => {
const { ctx, subagents } = await service()
const warnings: string[] = []
ctx.logger.warn = ((message: unknown) => void warnings.push(String(message))) as typeof ctx.logger.warn
const heard: string[] = []
ctx.on('subagent/provider-removed', () => { throw new Error('sync boom') })
// Runtime listeners may return thenables even though the declaration's observable result is void.
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- exercises rejected-listener containment
ctx.on('subagent/provider-removed', async () => { throw new Error('async boom') })
ctx.on('subagent/provider-removed', () => { throw { toString: () => { throw new Error('coercion') } } })
ctx.on('subagent/provider-removed', name => void heard.push(name))
const dispose = subagents.registerProvider(new StubProvider('contained'))
const ended = vi.fn()
ctx.on('subagent/end', ended)
const run = ctx.subagents.start('unclone', baseRequest())
await run.result
await dispose()
await Promise.resolve()
const endInfo = ended.mock.calls[0]![0] as Record<string, unknown>
expect(endInfo.stopReason).toBe('completed') // the real outcome is preserved
expect('lastAssistantMessage' in endInfo).toBe(false) // clone failed → omitted, not crashed
expect(warn).toHaveBeenCalledWith(expect.stringContaining('could not clone'))
expect(heard).toEqual(['contained'])
expect(warnings.some(message => message.includes('sync boom'))).toBe(true)
expect(warnings.some(message => message.includes('async boom'))).toBe(true)
expect(warnings.some(message => message.includes('<unrenderable thrown value>'))).toBe(true)
})
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,
inheritsParentContext: false,
start: () => ({
id: AgentId('rej-child'),
started: Promise.resolve(),
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 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'))
// Two listeners; the FIRST throws.
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()
await run.started
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 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.
await Promise.resolve()
await Promise.resolve()
expect(second).toHaveBeenCalledWith(expect.objectContaining({ provider: 'contain-end', 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')
it('SubagentError participates in the harness error taxonomy', () => {
const error = new SubagentError('boom', 'NO_PROVIDER')
expect(error).toBeInstanceOf(HarnessError)
expect(error.name).toBe('SubagentError')
expect(error.code).toBe('NO_PROVIDER')
})
})

View File

@@ -1,22 +1,28 @@
# @deepseek-ai/dsh-tool-subagent
Model-facing delegation tool over the [`ctx.subagents`](../subagent/README.md) provider registry. The selected provider may be in-process or out-of-process without changing the model's `{ description, prompt }` request shape.
The `subagent` tool lets the model delegate one self-contained task and collect the child's final output. It is a thin consumer of `ctx.subagents`; changing the configured provider changes the transport without changing the model-facing execution contract.
## Provider binding
## Provider selection
Each plugin load binds one `Config.provider`. To expose multiple providers, load the plugin under distinct `toolName` values. The tool description is derived from `provider.inheritsParentContext`, telling the model whether the child already sees completed parent turns.
Each plugin instance binds to exactly one provider. The model sees `{ description, prompt }`, not a provider selector. To expose multiple transports, load the plugin multiple times with distinct `toolName` values.
The tool follows provider availability through `subagent/provider-added` and `subagent/provider-removed`; it has no Loader-order dependency and disappears while its provider is absent.
The description is derived from `provider.inheritsParentContext`: spawn and ACP tell the model to provide a standalone prompt, while fork says the child already sees completed conversation turns. The plugin follows `subagent/provider-added` and `subagent/provider-removed`, so concurrent Cordis plugin loading does not create a registration-order dependency.
| Config key | Meaning |
## Lifecycle
`execute` passes the tool execution's abort signal directly as the required `SubagentStartRequest.signal`, awaits `ctx.subagents.start(...)`, then awaits `run.result` inside a `try/finally` that always calls `run.dispose()`. The same signal therefore covers startup and live execution, while disposal guarantees quiescence on success, failure, and abort.
A non-`completed` stop reason becomes an `isError` tool result; partial child output is never reported as success. The current tool blocks the parent turn until collection finishes; background and polling modes are deferred.
## Config
| Key | Meaning |
|---|---|
| `provider` (required) | Provider name on `ctx.subagents`. |
| `toolName` | Model-facing name (default `subagent`). |
| `agentOptions` | Default child options (`model?`). |
| `persona` | Child persona; requires provider support. |
| `toolFilter` | Child global-tool restriction; requires provider support. |
| `maxDepth` | Delegation-depth cap; requires provider support. |
| `provider` | Required `ctx.subagents` provider name. |
| `toolName` | Model-facing tool name (default `subagent`). Must be unique per plugin instance. |
| `agentOptions` | Default child agent options, currently including `model`. |
| `persona` | Per-child persona; requires provider `persona` capability. |
| `toolFilter` | Per-child global-tool restriction; requires provider `toolFilter` capability. |
| `maxDepth` | Absolute delegation-depth cap; requires provider `depthLimit` capability. |
## Execution
`execute` starts a run, bridges the tool abort signal to `run.cancel()`, awaits `run.result`, and always disposes the run. Non-completed stop reasons return error tool results rather than successful partial output. Collection is synchronous; background polling remains deferred in the [subagent seam RFC](../../../docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md).
`toolFilter` changes the child's visible global tool layer; it is not a parent-derived authority ceiling. See the [agent-scope security non-goal](../../../docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals).

View File

@@ -1,8 +1,34 @@
/**
* 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.
* 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 }`.
*
* The tool DESCRIPTION is derived from the bound provider's conversation-history
* descriptor ({@link providerWording}): a fresh-conversation provider (spawn,
* ACP) gets the standalone-prompt wording, while a seeded-conversation provider
* (fork) tells the model the child already sees the conversation's completed
* turns. This descriptor says nothing about Cordis scope, services, tools, or
* authority. The tool MIRRORS the
* provider's lifecycle via `subagent/provider-added`/`-removed` — it registers
* when the provider is (or becomes) available and unregisters when the
* provider goes away — so no load-order requirement exists and an HMR reload
* of the backend re-derives the wording from the fresh provider.
*
* 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
*/
@@ -11,6 +37,7 @@ 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 { assertSubagentMaxDepth } from '@deepseek-ai/dsh-subagent'
import type { SubagentProvider, SubagentResult, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
export const name = 'tool-subagent'
@@ -60,8 +87,9 @@ export interface Config {
* Recursion cap applied to every child this tool spawns (see
* `SubagentStartRequest.maxDepth`): a spawn whose child would sit deeper
* than this in the delegation tree is rejected. Requires the provider's
* `depthLimit` capability. Omitted ⇒ unbounded (bound it in deployments
* that expose this tool to children).
* `depthLimit` capability. Must be a non-negative safe integer and is
* validated when the plugin loads. Omitted ⇒ unbounded (bound it in
* deployments that expose this tool to children).
*/
maxDepth?: number
}
@@ -77,13 +105,21 @@ export const Config: z<Config> = z.object({
model: z.string(),
}).default(undefined as unknown as { model: string }),
persona: z.string(),
// A schemastery object materializes {} (with [] for nested arrays) when the key is omitted —
// for toolFilter that would mean an EMPTY ALLOW-LIST, i.e. deny-everything, silently.
// A schemastery object materializes {} (with [] for nested arrays) when the
// key is omitted — for toolFilter that would mean an EMPTY ALLOW-LIST, i.e.
// deny-everything, silently. Force the omitted key to stay absent (the same
// shape discipline as SystemPrompt's toolOrder); the cast is needed because
// .default() expects the object type.
// The NESTED arrays get the same treatment as the object itself: a partial
// filter ({deny: […]}) must not materialize allow: [] beside it — an empty
// allow-list means deny-EVERYTHING, so the materialized default would turn
// a deny-one config into deny-all. An EXPLICIT allow: [] (grant-only
// children) survives, since only the omitted key defaults to undefined.
toolFilter: z.object({
allow: z.array(z.string()).default(undefined as unknown as string[]),
deny: z.array(z.string()).default(undefined as unknown as string[]),
}).default(undefined as unknown as { allow: string[]; deny: string[] }),
maxDepth: z.number(),
maxDepth: z.natural().max(Number.MAX_SAFE_INTEGER),
})
/**
@@ -120,16 +156,19 @@ function stopReasonError(result: SubagentResult): string | undefined {
}
/**
* Model-facing wording per context contract ({@link SubagentProvider.inheritsParentContext}).
* Model-facing wording from the provider's conversation-history descriptor
* ({@link SubagentProvider.inheritsParentContext}).
* A fresh child needs a standalone prompt; a forked child already sees the
* conversation's completed turns — telling the model to restate everything
* (or, worse, that the child "does not see this conversation") would be false
* for a fork. Exported for tests.
* @param inherits - the bound provider's context contract.
* @param inheritsConversation - whether the child's conversation is seeded
* with the parent's completed turns; this says nothing about tool, service,
* scope, or authority inheritance.
* @returns the tool `description` and the `prompt` parameter description.
*/
export function providerWording(inherits: boolean): { description: string; promptDescription: string } {
if (inherits) {
export function providerWording(inheritsConversation: boolean): { description: string; promptDescription: string } {
if (inheritsConversation) {
return {
description:
'Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all '
@@ -156,16 +195,23 @@ export function providerWording(inherits: boolean): { description: string; promp
}
export function apply(ctx: Context, config: Config): void {
// Keep misconfiguration at plugin load even when a caller invokes apply()
// directly and bypasses Schemastery's natural/max metadata.
assertSubagentMaxDepth(config.maxDepth)
// Misconfiguration fails loud AT LOAD (the check is self-contained): an
// explicit `toolFilter: {}` would otherwise pass the capability gate and
// kill every delegation later, in the child-setup `restrict({})` throw.
if (config.toolFilter !== undefined && config.toolFilter.allow === undefined && config.toolFilter.deny === undefined) {
throw new Error('tool-subagent: `toolFilter` is configured but names neither `allow` nor `deny` — remove the key or fill the filter')
}
// The tool MIRRORS its provider's lifecycle instead of assuming load order: the cordis Loader
// starts sibling entries concurrently, so "backend listed first in cordis.yml" does not
// guarantee "provider registered first", and an HMR reload of the backend replaces the
// provider while this fiber stays loaded.
// The tool MIRRORS its provider's lifecycle instead of assuming load order:
// the cordis Loader starts sibling entries concurrently, so "backend listed
// first in cordis.yml" does not guarantee "provider registered first", and
// an HMR reload of the backend replaces the provider while this fiber stays
// loaded. Register the tool when the bound provider is (or becomes)
// available — deriving the wording from THAT provider — and unregister it
// when the provider goes away, so the description can never outlive or
// predate the provider it describes.
let disposeTool: (() => Promise<void> | void) | undefined
const mount = (provider: SubagentProvider): void => {
const wording = providerWording(provider.inheritsParentContext)
@@ -196,22 +242,14 @@ export function apply(ctx: Context, config: Config): void {
const request: SubagentStartRequest = {
prompt: [{ type: 'text', text: args.prompt }],
parent,
...exec.signal ? { signal: exec.signal } : {},
signal: exec.signal ?? new AbortController().signal,
...config.agentOptions !== undefined ? { agentOptions: config.agentOptions } : {},
...config.persona !== undefined ? { persona: config.persona } : {},
...config.toolFilter !== undefined ? { toolFilter: config.toolFilter } : {},
...config.maxDepth !== undefined ? { maxDepth: config.maxDepth } : {},
}
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 })
// `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.
if (exec.signal?.aborted) run.cancel('parent step aborted')
const run: SubagentRun = await ctx.subagents.start(config.provider, request)
try {
const result = await run.result
@@ -223,7 +261,6 @@ export function apply(ctx: Context, config: Config): void {
}
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()
}
@@ -231,8 +268,16 @@ export function apply(ctx: Context, config: Config): void {
}))
}
// Register listeners before the synchronous presence check to avoid an activation gap.
// TODO(subagent-dup-toolname): validate intended tool names before provider activation.
// Listeners first, then the presence check: both run synchronously, so no
// registration can slip between them; the `disposeTool === undefined` guard
// makes a same-tick added-event after a successful mount a no-op.
// TODO(subagent-dup-toolname): two WAITING fibers configured with the same
// toolName collide only when their provider finally arrives — the duplicate
// tool-name throw then propagates through `subagent/provider-added` and
// rolls back the PROVIDER registration, so an invalid config blasts the
// backend's fiber instead of the misconfigured tool's. Config-time detection
// would need a cross-fiber registry of intended tool names; revisit if a
// real deployment ever hits it.
ctx.on('subagent/provider-added', (provider) => {
if (provider.name === config.provider && disposeTool === undefined) mount(provider)
})
@@ -246,6 +291,8 @@ export function apply(ctx: Context, config: Config): void {
mount(present)
} else {
// Not an error: the backend's fiber may simply activate after this one.
// The tool appears the moment the provider registers; a typo'd provider
// name shows up as this note plus a tool that never materializes.
ctx.logger.info(`subagent provider "${config.provider}" not registered yet; the "${config.toolName ?? 'subagent'}" tool will register when it appears`)
}
}

View File

@@ -113,11 +113,9 @@ describe('dsh-tool-subagent', () => {
name: 'weird',
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
inheritsParentContext: false,
start: () => ({
start: async () => ({
id: AgentId('weird-child'),
started: Promise.resolve(),
result: Promise.resolve({ output: [{ type: 'text', text: 'partial' }], stopReason: 'frobnicated' as never }),
cancel() {},
dispose: async () => {},
}),
})
@@ -140,13 +138,11 @@ describe('dsh-tool-subagent', () => {
name: 'capture',
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
inheritsParentContext: false,
start: (request) => {
start: async (request) => {
seen = request
return {
id: AgentId('capture-child'),
started: Promise.resolve(),
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
cancel() {},
dispose: async () => {},
}
},
@@ -171,13 +167,11 @@ describe('dsh-tool-subagent', () => {
name: 'bare',
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
inheritsParentContext: false,
start: (request) => {
start: async (request) => {
seen = request
return {
id: AgentId('bare-child'),
started: Promise.resolve(),
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
cancel() {},
dispose: async () => {},
}
},
@@ -220,7 +214,7 @@ describe('dsh-tool-subagent', () => {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(SubagentService)
const backend = await ctx.plugin(mock, { name: 'mock' }) // spawn-shaped (inherits: false)
const backend = await ctx.plugin(mock, { name: 'mock' }) // fresh conversation (descriptor: false)
await ctx.plugin(tool, { provider: 'mock' })
expect(ctx.tools.schemas().find(s => s.name === 'subagent')!.description).toContain('does not see this conversation')
@@ -228,7 +222,7 @@ describe('dsh-tool-subagent', () => {
await backend.dispose()
expect(ctx.tools.schemas().some(s => s.name === 'subagent')).toBe(false)
// Backend reloads with a DIFFERENT contract: the wording is re-derived
// Backend reloads with a DIFFERENT conversation-history descriptor: the wording is re-derived
// from the fresh provider, not served stale from the first mount.
await ctx.plugin(mock, { name: 'mock', inheritsParentContext: true })
expect(ctx.tools.schemas().find(s => s.name === 'subagent')!.description).toContain('INHERITS this conversation')
@@ -273,7 +267,7 @@ describe('dsh-tool-subagent', () => {
expect(ctx.tools.schemas().some(s => s.name === 'subagent')).toBe(true)
})
it('derives spawn-shaped wording from a fresh-context provider (default mock)', async () => {
it('derives spawn-shaped wording from a fresh-conversation provider (default mock)', async () => {
const ctx = await setup({ provider: 'mock' })
const schema = ctx.tools.schemas().find(s => s.name === 'subagent')!
expect(schema.description).toContain('does not see this conversation')
@@ -281,7 +275,7 @@ describe('dsh-tool-subagent', () => {
expect(props['prompt']!.description).toContain('include everything it needs')
})
it('derives fork-shaped wording from an inheriting provider (the description stops lying)', async () => {
it('derives fork-shaped wording from a seeded-conversation provider (the description stops lying)', async () => {
const ctx = await setup({ provider: 'mock', toolName: 'subagent' }, { inheritsParentContext: true })
const schema = ctx.tools.schemas().find(s => s.name === 'subagent')!
expect(schema.description).toContain('INHERITS this conversation')
@@ -302,11 +296,9 @@ describe('dsh-tool-subagent', () => {
name: 'spy',
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
inheritsParentContext: false,
start: () => ({
start: async () => ({
id: AgentId('spy-child'),
started: Promise.resolve(),
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
cancel() {},
dispose: async () => void disposed(),
}),
})
@@ -326,11 +318,9 @@ describe('dsh-tool-subagent', () => {
name: 'spy',
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
inheritsParentContext: false,
start: () => ({
start: async () => ({
id: AgentId('spy-child'),
started: Promise.resolve(),
result: Promise.resolve({ output: [], stopReason: 'error' as const }),
cancel() {},
dispose: async () => void disposed(),
}),
})
@@ -341,7 +331,7 @@ describe('dsh-tool-subagent', () => {
expect(disposed).toHaveBeenCalledTimes(1)
})
it('bridges the tool abort signal to run.cancel()', async () => {
it('passes the tool abort signal as the provider cancellation channel', async () => {
const cancelled = vi.fn()
const ctx = new Context()
await ctx.plugin(SystemPrompt)
@@ -351,17 +341,17 @@ describe('dsh-tool-subagent', () => {
name: 'spy',
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
inheritsParentContext: false,
start: () => {
start: async (request) => {
if (request.signal.aborted) throw new Error('start aborted')
let resolveResult: (r: { output: never[]; stopReason: 'aborted' }) => void
const result = new Promise<{ output: never[]; stopReason: 'aborted' }>((res) => { resolveResult = res })
request.signal.addEventListener('abort', () => {
cancelled()
resolveResult({ output: [], stopReason: 'aborted' })
}, { once: true })
return {
id: AgentId('spy-child'),
started: Promise.resolve(),
result,
cancel: () => {
cancelled()
resolveResult({ output: [], stopReason: 'aborted' })
},
dispose: async () => {},
}
},
@@ -370,9 +360,7 @@ describe('dsh-tool-subagent', () => {
const controller = new AbortController()
const pending = callSubagent(ctx, { description: 'd', prompt: 'p' }, { signal: controller.signal })
// Abort after the tool body has had a chance to register its abort listener
// (ctx.tools.execute now awaits the tools/pre-execute waterfall before the body runs, so
// the listener is not registered synchronously).
// Let provider.start install its listener before aborting.
await Promise.resolve()
await Promise.resolve()
controller.abort()
@@ -381,11 +369,8 @@ 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`.
const cancelled = vi.fn()
it('passes an already-aborted signal so provider startup rejects', async () => {
const sawAborted = vi.fn()
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
@@ -394,19 +379,9 @@ describe('dsh-tool-subagent', () => {
name: 'spy',
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
inheritsParentContext: 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'),
started: Promise.resolve(),
result,
cancel: () => {
cancelled()
resolveResult({ output: [], stopReason: 'aborted' })
},
dispose: async () => {},
}
start: async (request) => {
if (request.signal.aborted) sawAborted()
throw new Error('start aborted')
},
})
await ctx.plugin(tool, { provider: 'spy' })
@@ -414,7 +389,7 @@ describe('dsh-tool-subagent', () => {
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(sawAborted).toHaveBeenCalledTimes(1)
expect(result.isError).toBe(true)
})
@@ -437,7 +412,10 @@ describe('dsh-tool-subagent', () => {
})
it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/inject/Config/apply', () => {
// Loader must retain this namespace's injection metadata.
// 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'])
@@ -461,13 +439,11 @@ describe('dsh-tool-subagent', () => {
name: 'capture2',
capabilities: { outputSchema: false, depthLimit: true, toolFilter: true, persona: true },
inheritsParentContext: false,
start: (request) => {
start: async (request) => {
seen = request
return {
id: AgentId('capture2-child'),
started: Promise.resolve(),
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
cancel() {},
dispose: async () => {},
}
},
@@ -485,8 +461,33 @@ describe('dsh-tool-subagent', () => {
expect(seen?.maxDepth).toBe(2)
})
it.each([
{ label: 'null', value: null as unknown as number },
{ label: 'a string', value: '1' as unknown as number },
{ label: 'NaN', value: Number.NaN },
{ label: 'positive infinity', value: Number.POSITIVE_INFINITY },
{ label: 'negative infinity', value: Number.NEGATIVE_INFINITY },
{ label: 'a negative integer', value: -1 },
{ label: 'a fractional number', value: 1.5 },
{ label: 'negative zero', value: -0 },
{ label: 'an unsafe integer', value: Number.MAX_SAFE_INTEGER + 1 },
])('rejects maxDepth=$label when the plugin loads', async ({ value }) => {
await expect(setup({ provider: 'mock', maxDepth: value }))
.rejects.toThrow()
})
it('validates maxDepth when apply() is invoked directly without Schemastery', () => {
const ctx = new Context()
expect(() => {
tool.apply(ctx, {
provider: 'unused',
maxDepth: Number.NaN,
})
}).toThrow('subagent maxDepth must be a non-negative safe integer')
})
it('a partial toolFilter (deny only) does not materialize an empty allow-list (deny-all trap)', async () => {
let seen: { toolFilter?: { allow?: string[]; deny?: string[] } } | undefined
let seen: { toolFilter?: { readonly allow?: readonly string[]; readonly deny?: readonly string[] } } | undefined
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
@@ -495,13 +496,11 @@ describe('dsh-tool-subagent', () => {
name: 'capture3',
capabilities: { outputSchema: false, depthLimit: false, toolFilter: true, persona: false },
inheritsParentContext: false,
start: (request) => {
start: async (request) => {
seen = request
return {
id: AgentId('capture3-child'),
started: Promise.resolve(),
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
cancel() {},
dispose: async () => {},
}
},
@@ -526,13 +525,11 @@ describe('dsh-tool-subagent', () => {
name: 'capture4',
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
inheritsParentContext: false,
start: (request) => {
start: async (request) => {
seen = request
return {
id: AgentId('capture4-child'),
started: Promise.resolve(),
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
cancel() {},
dispose: async () => {},
}
},