Merge remote-tracking branch 'origin/master' into parallel-tool-call
# Conflicts: # docs/architecture.md # docs/config-catalog.md # docs/cordis-catalog/services.md # docs/rfc/INDEX.md # packages/cordis/tool-cordis/src/api-catalog.ts # packages/core/agent-loop/README.md # packages/core/agent-loop/src/index.ts # packages/core/agent-loop/src/loop.ts # packages/core/tools/README.md # packages/core/tools/src/index.ts # packages/subagent/subagent/src/types.ts # packages/subagent/tool-subagent/README.md # scripts/gen-cordis-catalog.ts # scripts/type-equiv.manifest.json
This commit is contained in:
@@ -5,14 +5,14 @@ The subagent seam: an agent delegating work to a child agent. Like the [bash](..
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `subagent/` | Abstract subagent seam: named-provider registry + vocabulary | `ctx.subagents` |
|
||||
| `subagent-inprocess/` | Shared in-process run driver (pure lib; registers nothing) | — |
|
||||
| `subagent-inprocess/` | Shared in-process run driver (no provider; one cleanup effect per run) | — |
|
||||
| `subagent-spawn/` | In-process backend: a fresh child agent | (registers on `ctx.subagents`) |
|
||||
| `subagent-fork/` | In-process backend: a child seeded with the parent's completed-turn prefix | (registers on `ctx.subagents`) |
|
||||
| `subagent-subprocess/` | Shared out-of-process machinery: env scrub, dispose ladder, isolated config dirs (pure lib; registers nothing) | — |
|
||||
| `subagent-acp/` | Out-of-process backend: a child agent in a spawned subprocess, driven over ACP | (registers on `ctx.subagents`) |
|
||||
| `tool-subagent/` | Model-facing `subagent` delegation tool over `ctx.subagents` | (registers on `ctx.tools`) |
|
||||
|
||||
The interface lives at `subagent/subagent/`. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a pure library — both depend on it, neither on the other), the out-of-process `subagent-acp` backend builds on the `subagent-subprocess` library (the credential env scrub, the dispose ladder, isolated config dirs) and ships alongside them here; the test-only `dsh-subagent-mock` (in [support](../support/README.md)) is separate. All **product** packages except the mock.
|
||||
The interface lives at `subagent/subagent/`. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a library with no provider of its own — both depend on it, neither on the other), the out-of-process `subagent-acp` backend builds on the `subagent-subprocess` library (the credential env scrub, the dispose ladder, isolated config dirs) and ships alongside them here; the test-only `dsh-subagent-mock` (in [support](../support/README.md)) is separate. All **product** packages except the mock.
|
||||
|
||||
`SubagentProvider.start()` must be safe to call concurrently for independent runs: the `subagent` tool is parallel-safe, so one parent step may issue several subagent calls at once. Each backend reads the parent synchronously at start (a snapshot, never mutated or re-read during the run) — `fork` seeds each child from the parent's completed-turn prefix, which the open in-flight turn cannot change, so concurrent forks inside one open step all see the same stable prefix. A resource-limited provider may queue or cap internally, but must not require the loop to serialize every call.
|
||||
|
||||
|
||||
@@ -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`. 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 | parent 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`.
|
||||
|
||||
@@ -89,7 +89,7 @@ type ResolvedConfig = Required<Omit<Config, 'cwd'>> & Pick<Config, 'cwd'>
|
||||
* a request needing any of them before `start` runs).
|
||||
*/
|
||||
class AcpProvider implements SubagentProvider {
|
||||
readonly capabilities: SubagentCapabilities = { outputSchema: false, depthLimit: false, toolFilter: false }
|
||||
readonly capabilities: SubagentCapabilities = { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }
|
||||
// Context contract: an out-of-process ACP child starts fresh — no parent conversation crosses the process boundary.
|
||||
readonly inheritsParentContext = false
|
||||
|
||||
|
||||
@@ -180,29 +180,19 @@ function toError(value: unknown): Error {
|
||||
* 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 (a spawn/transport/RPC error resolves with `stopReason: 'error'`), per
|
||||
* the seam contract. `cancel()` sends `session/cancel`; `dispose()` kills the
|
||||
* subprocess and awaits its exit (quiescent teardown).
|
||||
* @param request - the start request; the driver consumes `prompt` and `signal`
|
||||
* (an already-aborted signal yields an inert `aborted` run with no spawn).
|
||||
* 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) {
|
||||
return {
|
||||
id,
|
||||
result: Promise.resolve({ output: [], stopReason: 'aborted' }),
|
||||
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
|
||||
@@ -219,9 +209,18 @@ 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
|
||||
// `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
|
||||
@@ -265,7 +264,7 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su
|
||||
// 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 `cancel()` always honors the contract (`result`
|
||||
// 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
|
||||
@@ -273,6 +272,7 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su
|
||||
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. Swallows a
|
||||
@@ -287,26 +287,21 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su
|
||||
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 })
|
||||
|
||||
const result: Promise<SubagentResult> = (async (): Promise<SubagentResult> => {
|
||||
// The accumulated child text as harness ContentBlocks (empty array when the
|
||||
// child streamed nothing). Read at every return so a partial answer survives
|
||||
// a later cancel/error.
|
||||
const collectOutput = (): ContentBlock[] => {
|
||||
const text = output.join('')
|
||||
return text.length > 0 ? [{ type: 'text', text }] : []
|
||||
}
|
||||
try {
|
||||
// Race three outcomes, first to settle wins:
|
||||
// - driveAcp: the normal initialize → newSession → prompt path;
|
||||
// - spawnFailed: a bad command never speaks ACP, so `initialize` would
|
||||
// hang forever — the spawn `error` event is the only signal, and a
|
||||
// rejected race settles the run `error` via the catch;
|
||||
// - 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`).
|
||||
const driveAcp = async (): Promise<SubagentResult> => {
|
||||
// The accumulated child text as harness ContentBlocks (empty array when the
|
||||
// child streamed nothing). Read at every return so a partial answer survives
|
||||
// a later cancel/error.
|
||||
const collectOutput = (): ContentBlock[] => {
|
||||
const text = output.join('')
|
||||
return text.length > 0 ? [{ type: 'text', text }] : []
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -315,28 +310,47 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su
|
||||
})
|
||||
const session = await conn.newSession({ cwd: spec.cwd, mcpServers: [] })
|
||||
sessionId = session.sessionId
|
||||
// A cancel that raced ahead of `newSession` set `cancelled` but could not
|
||||
// send `session/cancel` (no session id yet). Honor it here: settle
|
||||
// `aborted` without ever issuing the prompt, rather than running the child
|
||||
// to completion and ignoring the cancel.
|
||||
if (flags.cancelled) return { output: collectOutput(), stopReason: 'aborted' }
|
||||
const promptResult = await conn.prompt({ sessionId, prompt: toAcpPrompt(request.prompt) })
|
||||
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 {
|
||||
// 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> => {
|
||||
// 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) }
|
||||
}
|
||||
return await Promise.race([
|
||||
driveAcp(),
|
||||
spawnFailed.then((err): SubagentResult => { throw err }),
|
||||
prompt(),
|
||||
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. Cancellation is handled by the `cancelSettled` race arm above
|
||||
// (it settles `aborted` the instant cancel is requested, beating any
|
||||
// rejection), so a rejection that reaches HERE is always a genuine
|
||||
// child-level error — the awaited ACP RPCs or the spawn-failure race
|
||||
// (initialize/newSession/prompt transport/RPC errors, or ENOENT), not a
|
||||
// local bug. Flatten to `error` and surface the original via onError so a
|
||||
// real fault is preserved rather than silently lost.
|
||||
// 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 {
|
||||
@@ -345,17 +359,19 @@ 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,
|
||||
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). For THIS child the EOF tier is the
|
||||
// one that matters: our acp-agent has NO SIGTERM handler in a normal
|
||||
@@ -365,10 +381,8 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su
|
||||
// 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).
|
||||
await disposeChildProcess(child, {
|
||||
disposeEofGraceMs: spec.disposeEofGraceMs,
|
||||
disposeGraceMs: spec.disposeGraceMs,
|
||||
})
|
||||
disposal = disposeProcess()
|
||||
return disposal
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +68,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
|
||||
@@ -105,6 +106,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
|
||||
@@ -225,4 +227,3 @@ if (process.env.MOCK_IGNORE_EOF === '1') {
|
||||
setInterval(() => { /* stay alive past EOF until SIGTERM */ }, 1000)
|
||||
if (READY_FILE !== undefined) writeFileSync(READY_FILE, 'ignore-eof-armed')
|
||||
}
|
||||
|
||||
|
||||
@@ -60,9 +60,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()
|
||||
@@ -93,11 +94,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()
|
||||
|
||||
@@ -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 {
|
||||
@@ -206,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.
|
||||
@@ -253,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)
|
||||
@@ -290,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([
|
||||
@@ -305,7 +306,7 @@ 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
|
||||
@@ -315,14 +316,12 @@ describe('dsh-subagent-acp', () => {
|
||||
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 })
|
||||
}
|
||||
@@ -334,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
|
||||
@@ -347,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')
|
||||
@@ -356,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')
|
||||
@@ -367,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()
|
||||
@@ -377,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.
|
||||
@@ -385,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 () => {
|
||||
@@ -418,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(),
|
||||
@@ -440,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, {
|
||||
@@ -450,26 +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. A nonexistent command triggers the spawn
|
||||
// failure path; the spy records the error + the chosen stop reason.
|
||||
// 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 }) },
|
||||
@@ -483,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') },
|
||||
@@ -514,9 +516,10 @@ describe('dsh-subagent-acp', () => {
|
||||
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()
|
||||
@@ -525,8 +528,8 @@ 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`. A child that hangs
|
||||
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.
|
||||
@@ -534,9 +537,10 @@ describe('dsh-subagent-acp', () => {
|
||||
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([
|
||||
@@ -553,7 +557,7 @@ describe('dsh-subagent-acp', () => {
|
||||
it('advertises no start-time capabilities (out-of-process child)', async () => {
|
||||
const ctx = await setup()
|
||||
const provider = ctx.subagents.getProvider('acp')!
|
||||
expect(provider.capabilities).toEqual({ outputSchema: false, depthLimit: false, toolFilter: false })
|
||||
expect(provider.capabilities).toEqual({ outputSchema: false, depthLimit: false, toolFilter: false, persona: false })
|
||||
})
|
||||
|
||||
it('unregisters the provider when its fiber is disposed (HMR safety)', async () => {
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
# @deepseek-ai/dsh-subagent-fork
|
||||
|
||||
The in-process **fork** subagent backend: a [`SubagentProvider`](../subagent/README.md) that runs each child as a child [`Agent`](../../core/agent) **seeded with a prefix of the parent's session log** — so the child inherits the parent's conversation context instead of starting fresh. Shares the run driver (`startInProcessRun`) with [`dsh-subagent-spawn`](../subagent-spawn/README.md); the only difference is the seed.
|
||||
The 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.
|
||||
|
||||
## The seed boundary (the crux)
|
||||
## Seed boundary
|
||||
|
||||
At the moment a subagent tool's `execute` runs, the parent's CURRENT turn is open and unbalanced: the log holds the `assistant/message` carrying this spawn's tool-call and the dangling `tool/call` with no `tool/result` yet. Seeding that raw prefix would give the child an open turn that the session constructor and the dev-mode [invariants](../../support/invariants) replay **reject**.
|
||||
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.
|
||||
|
||||
So the fork seeds only the **balanced completed-turn prefix** — the parent's log up to and including its last `turn/end`, excluding the in-flight turn entirely (`completedTurnPrefix`). Because the live log keeps `seq === index`, the slice is contiguous-from-0 and a valid seed. A parent on its very first (not-yet-complete) turn forks an *empty* seed — i.e. effectively a fresh child.
|
||||
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.
|
||||
|
||||
The seam this rides on: `CreateAgentOptions.seed` (added on `dsh-agent`, threaded through `AgentLoop.createAgent` → `ctx.sessions.prepare({ seed })`), the same primitive `resume` uses.
|
||||
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.
|
||||
|
||||
## Capabilities
|
||||
## Start and capabilities
|
||||
|
||||
`{ outputSchema: true, depthLimit: true, toolFilter: false }` — identical to spawn (the depth/model/structured-output behavior is the shared driver's).
|
||||
`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
|
||||
|
||||
| Key | Meaning |
|
||||
|---|---|
|
||||
| `providerName` | Registry name on `ctx.subagents` (default `fork`). |
|
||||
|
||||
See [`dsh-subagent-spawn`](../subagent-spawn/README.md) for the run lifecycle, model inheritance, and depth tracking — all shared.
|
||||
|
||||
@@ -32,7 +32,7 @@ export const name = 'subagent-fork'
|
||||
// per-run structured runtime gates its capture-tool registration on `tools`
|
||||
// itself, so this backend's apply timing (and the delegation tool's position
|
||||
// in the model-visible tool list) is unchanged by structured output.
|
||||
export const inject = ['subagents', 'agents']
|
||||
export const inject = ['subagents']
|
||||
|
||||
/** Config: the registry name to register the provider under. */
|
||||
export interface Config {
|
||||
@@ -64,20 +64,19 @@ export function completedTurnPrefix(parent: Agent): SessionEvent[] {
|
||||
|
||||
/**
|
||||
* The fork provider. Supports `depthLimit` and `outputSchema` (via the shared
|
||||
* in-process structured runtime); NOT `toolFilter` this cut (the service
|
||||
* rejects a request needing it before `start` runs).
|
||||
* in-process structured runtime), plus `toolFilter`/`persona` (scoped
|
||||
* restrict() and a scoped shadowing persona section).
|
||||
*/
|
||||
class ForkProvider implements SubagentProvider {
|
||||
readonly capabilities: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: false }
|
||||
readonly capabilities: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: true, persona: true }
|
||||
// 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, {
|
||||
providerName: this.name,
|
||||
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 } : {},
|
||||
@@ -86,5 +85,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))
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
@@ -71,12 +74,29 @@ describe('completedTurnPrefix', () => {
|
||||
})
|
||||
|
||||
describe('dsh-subagent-fork', () => {
|
||||
it('emits subagent/start only after the seeded child is published', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('child answer')])
|
||||
let childAtStart: ReturnType<typeof ctx.agents.get>
|
||||
ctx.on('subagent/start', (info) => {
|
||||
if (info.provider === 'fork') childAtStart = ctx.agents.get(info.id)
|
||||
})
|
||||
|
||||
const starting = start(ctx, 'fork', { prompt: [{ type: 'text', text: 'child q' }], parent })
|
||||
expect(childAtStart).toBeUndefined()
|
||||
const run = await starting
|
||||
expect(childAtStart).toBe(ctx.agents.get(run.id))
|
||||
expect(childAtStart?.id).toBe(run.id)
|
||||
|
||||
await run.result
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('forks an UNSEEDED (fresh) child when the parent has no completed turn', async () => {
|
||||
// The parent has never completed a turn → empty prefix → the provider omits
|
||||
// the seed → the child runs fresh. Exercises the `seed.length > 0` false arm.
|
||||
const { ctx, parent } = await setup([textResponse('fresh child')])
|
||||
expect(completedTurnPrefix(parent)).toEqual([])
|
||||
const run = ctx.subagents.start('fork', { prompt: [{ type: 'text', text: 'child q' }], parent })
|
||||
const 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')
|
||||
@@ -94,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')
|
||||
@@ -127,7 +147,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')
|
||||
@@ -149,7 +169,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'] },
|
||||
@@ -173,7 +193,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.
|
||||
@@ -182,9 +202,9 @@ describe('dsh-subagent-fork', () => {
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('advertises depthLimit and outputSchema but not toolFilter', async () => {
|
||||
it('advertises every start-time capability (depthLimit, outputSchema, toolFilter, persona)', async () => {
|
||||
const { ctx } = await setup([])
|
||||
expect(ctx.subagents.getProvider('fork')!.capabilities).toEqual({ outputSchema: true, depthLimit: true, toolFilter: false })
|
||||
expect(ctx.subagents.getProvider('fork')!.capabilities).toEqual({ outputSchema: true, depthLimit: true, toolFilter: true, persona: true })
|
||||
})
|
||||
|
||||
it('unregisters the provider when its fiber is disposed (HMR safety)', async () => {
|
||||
@@ -200,12 +220,12 @@ describe('dsh-subagent-fork', () => {
|
||||
it('has the namespace-plugin export shape (no stray default)', () => {
|
||||
expect('default' in fork).toBe(false)
|
||||
expect(fork.name).toBe('subagent-fork')
|
||||
expect(fork.inject).toEqual(['subagents', 'agents'])
|
||||
expect(fork.inject).toEqual(['subagents'])
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
const unwrapped = loader.unwrapExports(fork) as Record<string, unknown>
|
||||
expect(unwrapped).toBe(fork)
|
||||
expect(unwrapped.name).toBe('subagent-fork')
|
||||
expect(unwrapped.inject).toEqual(['subagents', 'agents'])
|
||||
expect(unwrapped.inject).toEqual(['subagents'])
|
||||
expect(typeof unwrapped.apply).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,41 +1,41 @@
|
||||
# @deepseek-ai/dsh-subagent-inprocess
|
||||
|
||||
The shared **in-process subagent run driver**. A pure library (no provider, no registration) that the in-process backends — [spawn](../subagent-spawn/README.md) (a fresh child) and [fork](../subagent-fork/README.md) (a child seeded with a prefix of the parent's log) — both build on. The backends are thin shells that differ ONLY in the session seed they pass; everything downstream lives here, so neither backend depends on the other.
|
||||
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.
|
||||
|
||||
## What it exports
|
||||
## Start contract
|
||||
|
||||
### `startInProcessRun(ctx, request, options): SubagentRun`
|
||||
`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.
|
||||
|
||||
Runs a child as a child [`Agent`](../../core/agent) on the same cordis context (`ctx.agents`):
|
||||
The driver follows this sequence:
|
||||
|
||||
1. computes child depth = `depthOf(parent) + 1`; if `request.maxDepth` is set and exceeded, throws `SubagentDepthError` (the `depthLimit` capability); a `request.outputSchema` is asserted against the supported subset (`assertSupportedOutputSchema` from [dsh-tools](../../core/tools/README.md)) and then snapshotted with `structuredClone` before any child exists — assertion first so a hostile value fails as `OutputSchemaError` (never a raw clone error), the snapshot so a post-`start()` caller mutation cannot drift the enforced schema;
|
||||
2. creates a child via `ctx.agents.create` with a fresh `AgentId`/`SessionId`, the parent's `cwd` + `parentSession` lineage, the optional `options.seed` (fork's completed-turn prefix; omitted for a fresh child), and `agentOptions` (the child inherits the **parent's model** by default — a child with no model can't run — overridable via `request.agentOptions.model`; the deployment persona needs no inheritance — it is a context-wide prompt section);
|
||||
3. drives the one-shot: `child.send(prompt)` then `await child.whenIdle()` (ordering matters — `send` enqueues synchronously, so `whenIdle` observes the queued work and resolves on the child's `running → idle` transition, never before the turn starts); there is deliberately NO re-prompt for a structured child that finished cleanly without calling `structured_output` — the shortfall maps to an `error` result for the parent;
|
||||
4. reads the result, scoped to the child's OWN events (everything at or after `seedLength`, so a seeded child that produced no message of its own never returns the seeded parent's last message): the last `assistant/message` content (deep-cloned — the log is frozen) and the last `turn/end.reason` mapped to a `SubagentStopReason`. A structured run surfaces the captured value as `result.structured`; a structured child that finished cleanly WITHOUT ever capturing settles `error` (a clean finish without the demanded result is a failure, not a success with a missing field).
|
||||
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.
|
||||
|
||||
`dispose()` delegates to `AgentHandle.dispose()` (stop loop → await quiescence → remove session); `cancel()` cancels the child's in-flight turn. A cancel landing before any `turn/end` (the pre-turn window) still settles `aborted`, honoring the cancel contract rather than the generic no-turn `error`.
|
||||
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.
|
||||
|
||||
### `InProcessRunOptions`
|
||||
## Cancellation and ownership
|
||||
|
||||
`{ providerName: string; seed?: SessionEvent[] }` — the per-backend inputs: the provider name (for error context) and the optional child-session seed.
|
||||
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.
|
||||
|
||||
### Structured output (package-internal runtime)
|
||||
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.
|
||||
|
||||
The mechanism behind `outputSchema` for in-process children — acquired per structured RUN inside `startInProcessRun` (nothing is registered on a context that never runs a structured child; only the model-facing constants `STRUCTURED_OUTPUT_TOOL`/`STRUCTURED_OUTPUT_INSTRUCTION` are exported). One globally registered `structured_output` capture tool (its registered parameters are a placeholder) plus four listeners:
|
||||
## Spawn and fork inputs
|
||||
|
||||
- a `system-prompt/assemble` waterfall listener registered `prepend: true` that post-processes `await next()` — **final-assembly enforcement**: the assembly the loop renders never carries `structured_output` for an agent without a structured run, and for one that has it always carries the run's OWN schema (as the tool's `parameters`) plus the calling instruction as a trailing prompt section (the demand travels with the tool — `AgentOptions` has no per-agent prompt field to carry it). The loop logs the rendered assembly as the step's `request/header`, so the injection is reconstructable log state, never a wire-only mutation. Per-agent shaping lives here because the tool registry and prompt assembly are context-global while schemas differ per concurrent child (FIXME in the module doc: per-agent/per-session scoping would dissolve this); cooperative mutate-then-`next()` would not survive a downstream listener returning a replacement assembly.
|
||||
- a `tools/post-execute` listener (`prepend: true` = outermost, so `await next()` yields the composed final decision) that COMMITS the capture: the tool body only stages the validated value, and it becomes the run's result only when the final decision accepts the call — a downstream block (a PostToolUse hook) turns the logged result into `isError`, and the run must not report `structured` success for a call the model and session log saw fail.
|
||||
- a `tools/pre-execute` deny for any call arriving after the agent's capture — terminal means terminal WITHIN the step: a response listing `structured_output` before further tool calls cannot run side effects after the final answer was accepted.
|
||||
- an `agent/turn-continuation` listener (also `prepend: true` — an earlier-registered force-continue listener returning without `next()` must not decide the turn before the veto runs) that stops a child's turn once its output is captured, so a successful capture doesn't buy a wasted extra model step.
|
||||
`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.
|
||||
|
||||
The capture tool validates each call against the run's schema (`validateStructuredValue`) — violations become an `INVALID_ARGS` isError result the model retries in-turn; a valid call stages the value for the post-execute commit.
|
||||
`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`.
|
||||
|
||||
Lifetime is refcounted by live structured runs: each acquires at start and releases at settle, so the registrations exist exactly while at least one structured child is live, a backend hot-reload mid-run cannot unregister the capture tool under a live child, and the last settle disposes everything. `release()` is idempotent per acquisition.
|
||||
## Structured output
|
||||
|
||||
### `depthOf(agent): number`
|
||||
`attachStructuredRuntime(childCtx, schema)` installs the whole contract in the child's scope:
|
||||
|
||||
Delegation depth rides on a merge-extensible `AgentOptions.subagentDepth` field (0 for a top-level agent, parent + 1 for a child), so a nested spawn reads its parent's depth from `parent.options.subagentDepth`. `depthOf` reads it (absent ⇒ 0).
|
||||
- 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 are ordinary child-scoped registrations. An expert `system-prompt/assemble` listener may replace them and therefore owns preserving the structured-output protocol for that child.
|
||||
- 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.
|
||||
|
||||
### `SubagentDepthError`
|
||||
|
||||
Thrown by `startInProcessRun` when a spawn would exceed the request's `maxDepth` cap; carries `attemptedDepth` and `maxDepth`.
|
||||
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.
|
||||
|
||||
@@ -1,33 +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. The concrete in-process backends are thin shells over this driver,
|
||||
* differing ONLY in the `seed` they pass (a fresh child vs. a child seeded with
|
||||
* a prefix of the parent's log); everything downstream — drive the child, read
|
||||
* its final output, map the stop reason, dispose — is identical and lives here.
|
||||
*
|
||||
* This package owns no provider and registers nothing; it is a pure library the
|
||||
* backend packages depend on, so neither backend needs to know about the other.
|
||||
* 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 } from 'cordis'
|
||||
import { AgentId, type Agent, type AgentHandle, type AgentOptions } from '@deepseek-ai/dsh-agent'
|
||||
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 {
|
||||
acquireStructuredRuntime,
|
||||
type StructuredAcquisition,
|
||||
attachStructuredRuntime,
|
||||
type StructuredAttachment,
|
||||
} from './structured.ts'
|
||||
|
||||
// The runtime itself (acquire/attach/release) is package-internal: runs
|
||||
// acquire it inside startInProcessRun, and no other package drives it. Only
|
||||
// the model-facing vocabulary is public.
|
||||
export {
|
||||
STRUCTURED_OUTPUT_TOOL,
|
||||
STRUCTURED_OUTPUT_INSTRUCTION,
|
||||
@@ -35,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}`)
|
||||
@@ -64,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':
|
||||
@@ -73,9 +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. A missing reason (no turn ran) is also an error.
|
||||
case 'error':
|
||||
case 'disposed':
|
||||
case 'interrupted':
|
||||
@@ -84,168 +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 provider name (`spawn`/`fork`), for error context only. */
|
||||
readonly providerName: string
|
||||
/**
|
||||
* The child session's seed: a balanced, contiguous-from-0 prefix of the
|
||||
* parent's log (FORK), or `undefined` for a fresh child (SPAWN).
|
||||
*/
|
||||
/** Completed-turn seed for fork, or undefined for a fresh spawn. */
|
||||
readonly seed?: SessionEvent[]
|
||||
}
|
||||
|
||||
/** 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}.
|
||||
*
|
||||
* Drives the child as a one-shot: `send(prompt)` then `whenIdle()` (the ordering
|
||||
* matters — `send` enqueues synchronously, so `whenIdle` observes the queued
|
||||
* work and resolves only on the child's `running → idle` transition, never
|
||||
* before the turn starts). The final `assistant/message` is the result output,
|
||||
* the matching `turn/end.reason` the stop reason. `dispose()` delegates to the
|
||||
* factory's {@link AgentHandle.dispose} (stop loop → await quiescence → remove
|
||||
* session); `cancel()` cancels the child's in-flight turn.
|
||||
*
|
||||
* Throws {@link SubagentDepthError} before creating anything when the child's
|
||||
* depth (parent depth + 1) would exceed `request.maxDepth`.
|
||||
* @param ctx - the context whose `agents` factory creates and owns the child.
|
||||
* @param request - the start request (prompt, parent, signal, per-child options).
|
||||
* @param options - the backend's inputs: provider name plus the optional 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 {
|
||||
const childDepth = depthOf(request.parent) + 1
|
||||
): Promise<SubagentRun> {
|
||||
assertSubagentMaxDepth(request.maxDepth)
|
||||
if (request.signal.aborted) throw prePublicationAbort()
|
||||
const parent = request.parent
|
||||
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). Assertion comes FIRST so a hostile value fails as
|
||||
// OutputSchemaError, never as structuredClone's raw DataCloneError — the
|
||||
// asserted subset is plain JSON data, which always clones. The snapshot is
|
||||
// load-bearing: the caller keeps its reference, so attaching the ORIGINAL
|
||||
// would let a post-start() mutation drift the enforced schema away from the
|
||||
// asserted one — the clone (taken synchronously with the assertion, no
|
||||
// interleaving possible) pins assertion, the model-visible parameters, and
|
||||
// validateStructuredValue to one isolation-immutable value.
|
||||
if (request.outputSchema !== undefined) assertSupportedOutputSchema(request.outputSchema)
|
||||
const schema = request.outputSchema === undefined ? undefined : structuredClone(request.outputSchema)
|
||||
|
||||
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 = request.parent.session.header
|
||||
// Inherit the parent's model by default (a child with no model cannot run);
|
||||
// an explicit `request.agentOptions.model` overrides it. The persona needs
|
||||
// no inheritance: the deployment persona is a context-wide prompt section,
|
||||
// so parent and child render the same one. A structured run's
|
||||
// structured_output instruction is NOT prompt state either — the structured
|
||||
// runtime's final-request listener appends it per request (see structured.ts).
|
||||
const parentHeader = parent.session.header
|
||||
const parentModel = parent.options.model
|
||||
const agentOptions: AgentOptions = {
|
||||
...request.parent.options.model !== undefined ? { model: request.parent.options.model } : {},
|
||||
...parentModel !== undefined ? { model: parentModel } : {},
|
||||
...request.agentOptions,
|
||||
subagentDepth: childDepth,
|
||||
}
|
||||
|
||||
// The structured runtime is held for the WHOLE run (acquired before the child
|
||||
// exists, released when the result settles), so a backend hot-reload mid-run
|
||||
// cannot unregister the capture tool out from under this live child.
|
||||
const structured: StructuredAcquisition | undefined = schema !== undefined ? acquireStructuredRuntime(ctx) : undefined
|
||||
let structured: StructuredAttachment | undefined
|
||||
const setup = (childCtx: Context): void => {
|
||||
if (request.persona !== undefined) {
|
||||
childCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: request.persona })
|
||||
}
|
||||
if (request.toolFilter !== undefined) childCtx.tools.restrict(request.toolFilter)
|
||||
if (request.outputSchema !== undefined) {
|
||||
structured = attachStructuredRuntime(childCtx, request.outputSchema)
|
||||
}
|
||||
}
|
||||
|
||||
const handle: AgentHandle = ctx.agents.create({
|
||||
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,
|
||||
// Record the seed boundary so a reload (and a replay harness) can tell the
|
||||
// inherited prefix from the child's OWN events. 0 for a fresh spawn.
|
||||
...seedLength > 0 ? { seedLength } : {},
|
||||
},
|
||||
...options.seed !== undefined ? { seed: options.seed } : {},
|
||||
agentOptions,
|
||||
signal: request.signal,
|
||||
setup,
|
||||
})
|
||||
const child = handle.agent
|
||||
if (structured && schema !== undefined) structured.attach(child, schema)
|
||||
|
||||
// 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).
|
||||
// `cancelled` records that a cancel was requested at all, so the pre-turn
|
||||
// cancel window — where the child clears the queued prompt before any
|
||||
// `turn/end` is logged — settles as `aborted` (honoring the cancel contract)
|
||||
// rather than falling through to the no-turn `error` mapping.
|
||||
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
|
||||
const requestCancel = (reason: string): void => {
|
||||
cancelled = true
|
||||
child.cancel(reason)
|
||||
// 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 onAbort = (): void => { requestCancel('subagent cancelled') }
|
||||
request.signal?.addEventListener('abort', onAbort, { once: true })
|
||||
|
||||
const onAbort = (): void => {
|
||||
flags.cancelled = true
|
||||
child.cancel('subagent request aborted')
|
||||
}
|
||||
request.signal.addEventListener('abort', onAbort, { once: true })
|
||||
|
||||
const result: Promise<SubagentResult> = (async () => {
|
||||
try {
|
||||
// A signal already aborted BEFORE the run starts never fires an `abort`
|
||||
// event (`addEventListener` only fires on the transition), so the listener
|
||||
// above won't catch it — settle `aborted` without running the child rather
|
||||
// than completing an already-cancelled request.
|
||||
if (request.signal?.aborted) return { output: [], stopReason: 'aborted' }
|
||||
child.send(request.prompt)
|
||||
await child.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(child, seedLength, isCancelled(), structured ? { captured: structured.captured(child) } : undefined)
|
||||
return readResult(
|
||||
child,
|
||||
seedLength,
|
||||
flags.cancelled,
|
||||
structured ? { captured: structured.captured() } : undefined,
|
||||
)
|
||||
} finally {
|
||||
request.signal?.removeEventListener('abort', onAbort)
|
||||
if (structured) {
|
||||
structured.detach(child)
|
||||
structured.release()
|
||||
}
|
||||
request.signal.removeEventListener('abort', onAbort)
|
||||
}
|
||||
})()
|
||||
|
||||
return {
|
||||
id: childId,
|
||||
result,
|
||||
cancel(reason?: string): void {
|
||||
requestCancel(reason ?? 'subagent cancelled')
|
||||
},
|
||||
async dispose(): Promise<void> {
|
||||
request.signal?.removeEventListener('abort', onAbort)
|
||||
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). The output
|
||||
* is the child's last `assistant/message` content (deep-cloned — the log is
|
||||
* frozen); the stop reason is the child's last `turn/end` reason mapped to a
|
||||
* {@link SubagentStopReason}. When `cancelled` is set but no `turn/end` was
|
||||
* logged (a cancel landed in the pre-turn window, before any turn ran), the
|
||||
* run settles `aborted` per the {@link SubagentRun.cancel} contract rather than
|
||||
* the generic no-turn `error`.
|
||||
*
|
||||
* A structured run (`structured` present) additionally reports the captured
|
||||
* value on {@link SubagentResult.structured}. A structured child that finished
|
||||
* CLEANLY without ever capturing (the nudges ran out) settles `error` — a clean
|
||||
* finish without the demanded structured result is a failure, not a success
|
||||
* with a missing field; a non-`completed` reason keeps its own honest mapping.
|
||||
*/
|
||||
/** Read one settled child's result from events after its optional fork seed. */
|
||||
function readResult(
|
||||
child: Agent,
|
||||
seedLength: number,
|
||||
@@ -253,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 }
|
||||
|
||||
@@ -1,312 +1,173 @@
|
||||
/**
|
||||
* Structured-output support for the in-process subagent backends: the mechanism
|
||||
* behind `SubagentStartRequest.outputSchema` for children that run as agents on
|
||||
* the same context.
|
||||
* Structured-output support for the in-process subagent backends: the
|
||||
* mechanism behind `SubagentStartRequest.outputSchema` for children that run
|
||||
* as agents on the same context.
|
||||
*
|
||||
* The model-facing surface is one globally registered `structured_output` tool
|
||||
* whose REGISTERED parameters are a placeholder — the real schema is per run.
|
||||
* Because the tool registry and prompt assembly are context-global while
|
||||
* schemas differ per child (two concurrent structured runs may carry different
|
||||
* schemas), per-agent shaping happens on the `system-prompt/assemble`
|
||||
* waterfall with a `prepend: true` listener that post-processes `await next()`
|
||||
* — FINAL-ASSEMBLY enforcement: whatever downstream listeners mutated or
|
||||
* replaced, the assembly the loop renders never carries `structured_output`
|
||||
* for an agent without a structured run, and for one that has it always
|
||||
* carries the run's OWN schema plus a trailing
|
||||
* {@link STRUCTURED_OUTPUT_INSTRUCTION} section (the demand travels with the
|
||||
* tool). The loop logs what the assembly produced as the request header, so
|
||||
* the injection is a reconstructable fact of the session log, never a
|
||||
* wire-only mutation (the reconstructability RFC).
|
||||
* (Cooperative mutate-then-`next()` would not survive a downstream listener
|
||||
* returning a replacement assembly — see the waterfall composition caveat in
|
||||
* docs/architecture.md.)
|
||||
* 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.
|
||||
*
|
||||
* FIXME: the whole enforcement dance above exists because the tool registry
|
||||
* and prompt assembly are context-global. If they become per-agent or
|
||||
* per-session scoped, a structured run just registers its own schema'd tool on
|
||||
* the child's scope and this module reduces to the capture tool plus the
|
||||
* turn-stop — no placeholder, no final-assembly swap, no strip-for-everyone-
|
||||
* else, no global-registration lifetime dance.
|
||||
* The child scope's registrations enforce the contract:
|
||||
*
|
||||
* A companion `agent/turn-continuation` listener stops a child's turn once its
|
||||
* output is captured — without it, the loop's default "had tool calls ⇒
|
||||
* continue" buys a wasted extra model step per structured child. It is also
|
||||
* `prepend: true`: the veto must run before any earlier-registered listener
|
||||
* that could short-circuit the chain into a forced continue. A third listener
|
||||
* closes the within-step window the continuation veto cannot: a
|
||||
* `tools/pre-execute` deny for any call arriving after the agent's capture, so
|
||||
* a response that lists `structured_output` before further tool calls cannot
|
||||
* run side effects after the final answer was accepted. A fourth,
|
||||
* `tools/post-execute`, is the capture COMMIT: the tool body only stages the
|
||||
* validated value, and it becomes the run's captured result only when the
|
||||
* final post-execute decision accepts the call — a blocking hook downstream
|
||||
* yields `isError` in the log, and the run must not report success for it.
|
||||
*
|
||||
* Lifetime is refcounted by structured RUNS: each acquires from start to
|
||||
* settle, so the registrations exist exactly while at least one structured
|
||||
* child is live — a plain deployment that never passes `outputSchema` carries
|
||||
* no always-on global state, and a backend hot-reload mid-run cannot
|
||||
* unregister the capture tool out from under a live child (the run holds its
|
||||
* own acquisition). Registrations land on the ROOT context and the refcount
|
||||
* disposes them when the last run settles; the next structured run
|
||||
* re-registers them.
|
||||
* - The scoped capture tool and instruction are ordinary assembly inputs. The
|
||||
* loop logs the assembled request header, so the demand is reconstructable
|
||||
* log state rather than a wire-only mutation. As with every other assembly
|
||||
* contribution, an expert `system-prompt/assemble` listener that deliberately
|
||||
* removes or replaces either input owns the resulting composition.
|
||||
* - `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
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { ContinuationStop } from '@deepseek-ai/dsh-agent'
|
||||
import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContinuationDecision } from '@deepseek-ai/dsh-agent'
|
||||
import type { AssembleContext, PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
|
||||
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
import { ToolArgsError, validateStructuredValue, type StructuredOutputSchema } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
/** The model-facing tool name a structured child must call to finish. */
|
||||
export const STRUCTURED_OUTPUT_TOOL = 'structured_output'
|
||||
|
||||
/**
|
||||
* The instruction the assembly listener appends to a structured child's
|
||||
* system prompt as a trailing section on every assembly. Per-assembly state,
|
||||
* NOT agent prompt state: `AgentOptions` has no prompt field (the persona is
|
||||
* deployment config on the system-prompt plugin), so the same final-assembly
|
||||
* enforcement that injects the schema'd tool carries the instruction that
|
||||
* demands calling it.
|
||||
* 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. `
|
||||
+ 'Do not finish with a plain text answer: only the tool call counts as your result.'
|
||||
|
||||
/** One structured run's state: the schema to enforce and the captured value, once recorded. */
|
||||
interface RunState {
|
||||
readonly schema: StructuredOutputSchema
|
||||
/** One structured run's live handle: read the captured value once the child settles. */
|
||||
export interface StructuredAttachment {
|
||||
/**
|
||||
* A validated value awaiting the post-execute verdict on ITS OWN call. Set
|
||||
* by the capture tool's body, promoted to {@link RunState.captured} only
|
||||
* when the final `tools/post-execute` decision accepts the call — a
|
||||
* downstream block turns the logged result into `isError`, and a value
|
||||
* committed at body time would let the run report success for a call the
|
||||
* model saw fail.
|
||||
* 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.
|
||||
*/
|
||||
pending?: { value: unknown }
|
||||
captured?: { value: unknown }
|
||||
}
|
||||
|
||||
/** The per-root-context runtime: run states plus the shared registrations. */
|
||||
interface StructuredRuntime {
|
||||
refs: number
|
||||
readonly states: WeakMap<Agent, RunState>
|
||||
readonly disposers: (() => void)[]
|
||||
}
|
||||
|
||||
/** One root context ⇒ one runtime (multi-app test isolation). */
|
||||
const runtimes = new WeakMap<Context, StructuredRuntime>()
|
||||
|
||||
/**
|
||||
* One holder's handle on the shared structured runtime. `release()` is
|
||||
* idempotent per acquisition; the runtime's registrations are disposed when the
|
||||
* LAST holder (backend plugin or live run) releases.
|
||||
*/
|
||||
export interface StructuredAcquisition {
|
||||
/** Enforce `schema` on `agent`'s requests and start capturing its `structured_output` call. */
|
||||
attach(agent: Agent, schema: StructuredOutputSchema): void
|
||||
/** The captured value, once the child called the tool with valid arguments. */
|
||||
captured(agent: Agent): { value: unknown } | undefined
|
||||
/** Stop enforcing/capturing for `agent` (WeakMap-backed; safe to call twice). */
|
||||
detach(agent: Agent): void
|
||||
/** Drop this holder's reference (idempotent); the last release unregisters everything. */
|
||||
release(): void
|
||||
captured(): { value: unknown } | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire the per-root-context structured runtime, registering the capture tool
|
||||
* and the runtime's listeners on the FIRST acquisition. See the module doc
|
||||
* for the enforcement and lifetime design.
|
||||
* @param ctx - any context of the app; the runtime keys off `ctx.root`.
|
||||
* @returns this holder's handle (attach/captured/detach + idempotent release).
|
||||
* 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 acquireStructuredRuntime(ctx: Context): StructuredAcquisition {
|
||||
const root: Context = ctx.root
|
||||
let runtime = runtimes.get(root)
|
||||
if (!runtime) {
|
||||
runtime = { refs: 0, states: new WeakMap(), disposers: [] }
|
||||
runtimes.set(root, runtime)
|
||||
registerRuntime(root, runtime)
|
||||
}
|
||||
runtime.refs += 1
|
||||
export function attachStructuredRuntime(childCtx: Context, schema: StructuredOutputSchema): StructuredAttachment {
|
||||
/**
|
||||
* 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
|
||||
let captured: { value: unknown } | undefined
|
||||
|
||||
let released = false
|
||||
return {
|
||||
attach(agent: Agent, schema: StructuredOutputSchema): void {
|
||||
runtime.states.set(agent, { schema })
|
||||
},
|
||||
captured(agent: Agent): { value: unknown } | undefined {
|
||||
return runtime.states.get(agent)?.captured
|
||||
},
|
||||
detach(agent: Agent): void {
|
||||
runtime.states.delete(agent)
|
||||
},
|
||||
release(): void {
|
||||
if (released) return
|
||||
released = true
|
||||
runtime.refs -= 1
|
||||
if (runtime.refs > 0) return
|
||||
runtimes.delete(root)
|
||||
for (const dispose of runtime.disposers.splice(0)) dispose()
|
||||
},
|
||||
const schemaEntry: ToolSchema = {
|
||||
name: STRUCTURED_OUTPUT_TOOL,
|
||||
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.',
|
||||
// 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>,
|
||||
}
|
||||
}
|
||||
|
||||
/** Register the capture tool + the two listeners on the root context (first acquire). */
|
||||
function registerRuntime(root: Context, runtime: StructuredRuntime): void {
|
||||
// The registered parameters are a PLACEHOLDER: the request listener below
|
||||
// swaps in the run's real schema per child, and strips the tool entirely for
|
||||
// every agent without a structured run — so this shape is never model-visible.
|
||||
//
|
||||
// Registration does NOT ride on the acquiring backend's plugin-level
|
||||
// `inject`: a backend that waited on `tools` would apply later than it did
|
||||
// before this module existed, shifting when its PROVIDER registers — and the
|
||||
// delegation tool mirrors provider lifecycle, so that shift would reorder
|
||||
// the model-visible tool list of every existing prompt. Instead the capture
|
||||
// tool registers synchronously when `tools` is already live (the common
|
||||
// case), and through a scoped inject fiber when the Loader happens to start
|
||||
// the backend first. Either way the registration lands on root and is
|
||||
// disposed by the runtime's refcount; disposing the fiber also covers the
|
||||
// never-activated case.
|
||||
let disposeTool: (() => void) | undefined
|
||||
const registerCapture = (tools: Context['tools']): void => {
|
||||
disposeTool = tools.register({
|
||||
name: STRUCTURED_OUTPUT_TOOL,
|
||||
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.',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
execute(args: unknown, exec: ToolExecution): Promise<ContentBlock[]> {
|
||||
const state = exec.agent ? runtime.states.get(exec.agent) : undefined
|
||||
if (!state) {
|
||||
// Reachable only if a non-structured agent somehow calls the tool (the
|
||||
// request listener strips it, so the model never sees it) — fail loud
|
||||
// rather than capture into nowhere.
|
||||
throw new Error(`${STRUCTURED_OUTPUT_TOOL} is only available to subagents started with an output schema`)
|
||||
childCtx.tools.register({
|
||||
...schemaEntry,
|
||||
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)
|
||||
// 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.' }])
|
||||
},
|
||||
})
|
||||
|
||||
childCtx.systemPrompt.section({
|
||||
name: `tool:${STRUCTURED_OUTPUT_TOOL}`,
|
||||
order: 190,
|
||||
text: STRUCTURED_OUTPUT_INSTRUCTION,
|
||||
})
|
||||
|
||||
// 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' }
|
||||
})
|
||||
|
||||
// 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) {
|
||||
if (exec.name === STRUCTURED_OUTPUT_TOOL) {
|
||||
const entry = staged.get(exec)
|
||||
if (entry === undefined) return
|
||||
staged.delete(exec)
|
||||
if (result.isError) return
|
||||
if (exec.parent === undefined) {
|
||||
/* v8 ignore else -- sequential agent-loop dispatch lets the guard block every later supported call */
|
||||
if (captured === undefined) captured = { value: entry.value }
|
||||
} else {
|
||||
/* v8 ignore else -- Code Mode serializes sub-dispatches, so the guard blocks every later supported call */
|
||||
if (captured === undefined && pending === undefined) {
|
||||
pending = { parent: exec.parent, value: entry.value }
|
||||
}
|
||||
const violations = validateStructuredValue(state.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)
|
||||
// Two-phase commit: the body only STAGES the value; the post-execute
|
||||
// listener below promotes it once the final decision accepts the call.
|
||||
state.pending = { value: args }
|
||||
return Promise.resolve([{ type: 'text', text: 'Structured output recorded.' }])
|
||||
},
|
||||
})
|
||||
}
|
||||
const liveTools = root.get('tools')
|
||||
const toolsFiber = liveTools ? undefined : root.inject(['tools'], (childCtx: Context) => {
|
||||
registerCapture(childCtx.root.tools)
|
||||
})
|
||||
if (liveTools) registerCapture(liveTools)
|
||||
runtime.disposers.push(() => {
|
||||
disposeTool?.()
|
||||
void toolsFiber?.dispose()
|
||||
})
|
||||
|
||||
// FINAL-ASSEMBLY enforcement (prepend: true = first registered = OUTERMOST
|
||||
// wrapper): post-process whatever the downstream listeners and the registry
|
||||
// produced, so a downstream listener returning a replacement assembly cannot
|
||||
// leak the tool to other agents or erase the child's schema. The loop logs
|
||||
// the rendered assembly as the step's request header, so the swap is
|
||||
// reconstructable log state, never a wire-only mutation.
|
||||
runtime.disposers.push(root.on('system-prompt/assemble', async function (
|
||||
this: unknown, _assembly: PromptAssembly, context: AssembleContext, next: () => Promise<PromptAssembly>,
|
||||
): Promise<PromptAssembly> {
|
||||
const final = await next()
|
||||
const state = context.agent ? runtime.states.get(context.agent) : undefined
|
||||
if (state) {
|
||||
const schemaEntry: ToolSchema = {
|
||||
name: STRUCTURED_OUTPUT_TOOL,
|
||||
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.',
|
||||
// ToolSchema.parameters is the wire-level JSON Schema object; the
|
||||
// asserted subset type is structurally exactly that.
|
||||
parameters: state.schema as unknown as Record<string, unknown>,
|
||||
}
|
||||
final.tools = [...final.tools.filter(tool => tool.name !== STRUCTURED_OUTPUT_TOOL), schemaEntry]
|
||||
// The demand travels WITH the tool: a trailing section in the
|
||||
// tool-guidance order band, appended after next() so it renders last
|
||||
// (renderPrompt joins in array order).
|
||||
final.sections = [...final.sections, { name: `tool:${STRUCTURED_OUTPUT_TOOL}`, order: 190, text: STRUCTURED_OUTPUT_INSTRUCTION }]
|
||||
return final
|
||||
return
|
||||
}
|
||||
// No structured run: strip the placeholder so it is never model-visible.
|
||||
// An empty tools array canonicalizes to an absent header/wire field
|
||||
// (canonicalHeader pins empty ≡ absent), so no re-shaping is needed here.
|
||||
final.tools = final.tools.filter(tool => tool.name !== STRUCTURED_OUTPUT_TOOL)
|
||||
return final
|
||||
}, { prepend: true }))
|
||||
if (pending?.parent !== exec.token) return
|
||||
const entry = pending
|
||||
pending = undefined
|
||||
if (result.isError) return
|
||||
/* v8 ignore else -- Code Mode serializes outer executions, so the guard blocks every later supported call */
|
||||
if (captured === undefined) captured = { value: entry.value }
|
||||
})
|
||||
|
||||
// Stop a structured child's turn once its output is captured: the default
|
||||
// "had tool calls ⇒ continue" would otherwise buy a wasted extra model step
|
||||
// after every successful capture. `prepend: true` puts the veto OUTERMOST —
|
||||
// an earlier-registered listener that short-circuits the chain (a goal-style
|
||||
// force-continue returning without `next()`) would otherwise decide the turn
|
||||
// before this listener ever ran, and no downstream decision may resurrect a
|
||||
// structured turn that is already finished.
|
||||
runtime.disposers.push(root.on('agent/turn-continuation', function (
|
||||
this: unknown, agent: Agent, _turn: number, _decision: ContinuationDecision, next: () => Promise<ContinuationDecision>,
|
||||
): Promise<ContinuationDecision> {
|
||||
if (runtime.states.get(agent)?.captured) return Promise.resolve({ action: 'stop' })
|
||||
return next()
|
||||
}, { prepend: true }))
|
||||
|
||||
// The capture COMMIT: promote the staged value only when the final
|
||||
// post-execute decision accepts the call. The capture tool's body cannot
|
||||
// decide — `tools/post-execute` runs after it, and a blocking listener (a
|
||||
// PostToolUse hook) turns the logged result into `isError` feedback; a value
|
||||
// committed at body time would make readResult report `structured` success
|
||||
// for a call whose result the model and session log saw fail. `prepend:
|
||||
// true` = outermost at registration time, so `await next()` returns the
|
||||
// COMPOSED downstream decision — the same final verdict the registry maps
|
||||
// onto the result. (A later-registered outer listener that blocks without
|
||||
// delegating skips this commit entirely: the staged value is dropped and the
|
||||
// run errors — failure-safe in the same direction.) The staging slot clears
|
||||
// on every path, including a rejecting downstream listener.
|
||||
runtime.disposers.push(root.on('tools/post-execute', async function (
|
||||
this: unknown, exec: ToolExecution, _result: ToolExecutionResult, next: () => Promise<PostToolDecision>,
|
||||
): Promise<PostToolDecision> {
|
||||
const state = exec.agent ? runtime.states.get(exec.agent) : undefined
|
||||
if (!state || exec.name !== STRUCTURED_OUTPUT_TOOL || state.pending === undefined) return next()
|
||||
const pending = state.pending
|
||||
try {
|
||||
const decision = await next()
|
||||
if (decision.kind === 'accept') state.captured = pending
|
||||
return decision
|
||||
} finally {
|
||||
delete state.pending
|
||||
}
|
||||
}, { prepend: true }))
|
||||
|
||||
// Terminal means terminal WITHIN the step, not only at its end: the
|
||||
// turn-continuation veto above runs after every call in the current model
|
||||
// response has executed, so a response that puts `structured_output` before
|
||||
// further tool calls would still perform those side effects after the final
|
||||
// answer was accepted. Deny every later call for a captured agent at the
|
||||
// allow/deny gate — dispatch is skipped and the model sees an `isError`
|
||||
// result naming the contract. Calls that PRECEDE the capture in the same
|
||||
// response ran before `captured` was set and are untouched; a second
|
||||
// `structured_output` is denied like any other call. `prepend: true` for the
|
||||
// same reason as the continuation veto: no earlier-registered allow may
|
||||
// short-circuit past the terminal contract.
|
||||
runtime.disposers.push(root.on('tools/pre-execute', function (
|
||||
this: unknown, exec: ToolExecution, next: () => Promise<PreToolDecision>,
|
||||
): Promise<PreToolDecision> {
|
||||
if (exec.agent && runtime.states.get(exec.agent)?.captured) {
|
||||
return Promise.resolve({
|
||||
kind: 'deny',
|
||||
reason: `structured output already recorded: the run is complete, so \`${exec.name}\` is not executed`,
|
||||
})
|
||||
}
|
||||
return next()
|
||||
}, { prepend: true }))
|
||||
return { captured: () => captured }
|
||||
}
|
||||
|
||||
@@ -5,21 +5,30 @@ import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, ContinuationDecision } from '@deepseek-ai/dsh-agent'
|
||||
import type { ContinuationDecision } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
||||
import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
|
||||
import type { StructuredOutputSchema } from '@deepseek-ai/dsh-tools'
|
||||
import type { Config as ToolConfig, StructuredOutputSchema } from '@deepseek-ai/dsh-tools'
|
||||
import { RUN_CODE_NAME } from '@deepseek-ai/dsh-tools'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import { startInProcessRun } from '../src/index.ts'
|
||||
import {
|
||||
acquireStructuredRuntime,
|
||||
STRUCTURED_OUTPUT_INSTRUCTION,
|
||||
STRUCTURED_OUTPUT_TOOL,
|
||||
} from '../src/structured.ts'
|
||||
|
||||
type Script = ConstructorParameters<typeof MockAdapter>[0]
|
||||
|
||||
interface CodeRunRequestLike {
|
||||
bindings: { global: string; functions: Record<string, (args: unknown) => Promise<unknown>> }[]
|
||||
}
|
||||
|
||||
interface SetupOptions {
|
||||
toolMode?: ToolConfig['mode']
|
||||
codeRun?: (request: CodeRunRequestLike) => Promise<{ logs: never[]; value?: unknown }>
|
||||
}
|
||||
|
||||
const SCHEMA: StructuredOutputSchema = {
|
||||
type: 'object',
|
||||
properties: { answer: { type: 'number' }, note: { type: 'string' } },
|
||||
@@ -27,29 +36,36 @@ 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
|
||||
* coverage lives in the spawn/fork specs. The mock model script drives the
|
||||
* child's structured_output calls.
|
||||
*/
|
||||
async function setup(script: Script) {
|
||||
async function setup(script: Script, options: SetupOptions = {}) {
|
||||
const ctx = new Context()
|
||||
const adapter = new MockAdapter(script)
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(ToolRegistry, { mode: options.toolMode ?? 'native' })
|
||||
if (options.toolMode === 'code' || options.toolMode === 'both') {
|
||||
ctx.provide('codeRuntime', {
|
||||
language: 'typescript',
|
||||
isolation: 'test',
|
||||
run: options.codeRun ?? (() => Promise.resolve({ logs: [] })),
|
||||
} as never)
|
||||
}
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(Invariants)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SubagentService)
|
||||
const disposeProvider = ctx.subagents.registerProvider({
|
||||
name: 'spawn',
|
||||
capabilities: { outputSchema: true, depthLimit: true, toolFilter: false },
|
||||
capabilities: { outputSchema: true, depthLimit: true, toolFilter: false, persona: false },
|
||||
inheritsParentContext: false,
|
||||
start: (request: SubagentStartRequest) => startInProcessRun(ctx, request, { providerName: 'spawn' }),
|
||||
start: (request: SubagentStartRequest) => startInProcessRun(request, {}),
|
||||
})
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' })
|
||||
@@ -57,7 +73,13 @@ async function setup(script: Script) {
|
||||
}
|
||||
|
||||
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. */
|
||||
@@ -70,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' })
|
||||
@@ -82,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.
|
||||
@@ -113,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 })
|
||||
@@ -122,6 +144,44 @@ describe('in-process structured output', () => {
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('a later prepended pre-execute listener cannot resurrect dispatch after capture', async () => {
|
||||
const response = [
|
||||
...toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 5 }).slice(0, -2),
|
||||
{ type: 'block-start', index: 1, blockType: 'tool-call' },
|
||||
{ type: 'block-end', index: 1, block: { type: 'tool-call', id: CallId('c2'), name: 'side_effect', arguments: '{}' } },
|
||||
{ type: 'usage', usage: { inputTokens: 10, outputTokens: 5 } },
|
||||
{ type: 'finish', reason: { kind: 'tool-calls' } },
|
||||
] as Script[number]
|
||||
const { ctx, parent } = await setup([response])
|
||||
let sideEffectRan = false
|
||||
ctx.tools.register({
|
||||
name: 'side_effect',
|
||||
description: 'probe',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
execute(): Promise<ContentBlock[]> {
|
||||
sideEffectRan = true
|
||||
return Promise.resolve([{ type: 'text', text: 'ran' }])
|
||||
},
|
||||
})
|
||||
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.
|
||||
ctx.on('tools/pre-execute', async (_exec, next) => {
|
||||
await next()
|
||||
return { kind: 'allow' as const }
|
||||
}, { prepend: true })
|
||||
|
||||
const result = await run.result
|
||||
expect(result.structured).toEqual({ answer: 5 })
|
||||
expect(sideEffectRan).toBe(false)
|
||||
const child = ctx.agents.get(run.id)
|
||||
const sideEffectResult = child?.session.events.find(event =>
|
||||
event.type === 'tool/result' && event.data.callId === 'c2')
|
||||
expect(sideEffectResult?.type === 'tool/result' && sideEffectResult.data.isError).toBe(true)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('leaves tool calls that PRECEDE the capture in the same response untouched', async () => {
|
||||
const response = [
|
||||
{ type: 'block-start', index: 0, blockType: 'tool-call' },
|
||||
@@ -140,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.
|
||||
@@ -149,57 +209,65 @@ 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)
|
||||
it('a later-prepended continuation wrapper cannot resurrect a captured turn', async () => {
|
||||
const { ctx, parent, adapter } = await setup([
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 3 }),
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 7 }),
|
||||
textResponse('MUST NOT BE CONSUMED'),
|
||||
])
|
||||
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' }
|
||||
ctx.on('agent/turn-continuation', () => Promise.resolve<ContinuationDecision>({ action: 'stop' }))
|
||||
let wrapperInstalled = false
|
||||
// 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 === parent) return
|
||||
wrapperInstalled = true
|
||||
child.ctx.on('agent/turn-continuation', async (_subject, _turn, _decision, next): Promise<ContinuationDecision> => {
|
||||
const downstream = await next()
|
||||
expect(downstream).toEqual({ action: 'stop' })
|
||||
return { action: 'continue' }
|
||||
}, { prepend: true })
|
||||
})
|
||||
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
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)
|
||||
expect(wrapperInstalled).toBe(true)
|
||||
expect(result.structured).toEqual({ answer: 7 })
|
||||
expect(result.stopReason).toBe('completed')
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('the captured-turn veto is prepend: an EARLIER force-continue listener cannot short-circuit it', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
// Registered BEFORE the structured runtime exists — without prepend, this
|
||||
// goal-style listener would decide the turn first (returning WITHOUT
|
||||
// calling next()) and the veto would never run.
|
||||
ctx.on('agent/turn-continuation', () => Promise.resolve<ContinuationDecision>({ action: 'continue' }))
|
||||
const acquisition = acquireStructuredRuntime(ctx)
|
||||
const agent = { id: AgentId('structured-child') } as unknown as Agent
|
||||
acquisition.attach(agent, SCHEMA)
|
||||
const captured = await ctx.tools.execute({
|
||||
callId: 'call-1' as never,
|
||||
name: STRUCTURED_OUTPUT_TOOL,
|
||||
arguments: { answer: 1 },
|
||||
agent,
|
||||
it('a continuation wrapper cannot carry steering past a captured terminal stop', async () => {
|
||||
const { ctx, parent, adapter } = await setup([
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 9 }),
|
||||
textResponse('MUST NOT BE CONSUMED'),
|
||||
])
|
||||
// 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 = 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> => {
|
||||
const downstream = await next()
|
||||
expect(downstream).toEqual({ action: 'stop' })
|
||||
subject.steer([{ type: 'text', text: 'late steering after downstream stop' }])
|
||||
return downstream
|
||||
}, { prepend: true })
|
||||
})
|
||||
expect(captured.isError).toBeFalsy()
|
||||
const decision = await ctx.waterfall(
|
||||
'agent/turn-continuation', agent, 1,
|
||||
{ action: 'continue' },
|
||||
() => Promise.resolve<ContinuationDecision>({ action: 'continue' }),
|
||||
)
|
||||
expect(decision).toEqual({ action: 'stop' })
|
||||
acquisition.detach(agent)
|
||||
acquisition.release()
|
||||
|
||||
const result = await run.result
|
||||
const child = ctx.agents.get(run.id)
|
||||
|
||||
expect(result.structured).toEqual({ answer: 9 })
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(child?.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
|
||||
expect(child?.session.events.filter(event => event.type === 'steering/message')).toHaveLength(0)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('an invalid call gets an INVALID_ARGS isError result and the model retries in-turn', async () => {
|
||||
@@ -207,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')
|
||||
@@ -224,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()
|
||||
@@ -238,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)
|
||||
@@ -247,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 child = ctx.agents.get(run.id)!
|
||||
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) => {
|
||||
if (session === child.session && event.type === 'turn/end') run.cancel('cancelled at turn end')
|
||||
const child = ctx.agents.get(run.id)
|
||||
if (session === child?.session && event.type === 'turn/end') controller.abort('cancelled at turn end')
|
||||
})
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('aborted')
|
||||
@@ -261,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 () => {
|
||||
@@ -282,15 +349,15 @@ describe('in-process structured output', () => {
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 7 }),
|
||||
textResponse('continues after the blocked capture'),
|
||||
])
|
||||
// A PostToolUse-style hook, registered AFTER the runtime (so the runtime's
|
||||
// prepend commit listener stays outermost and composes this verdict).
|
||||
// A PostToolUse-style hook turns the tool body's provisional success into
|
||||
// the authoritative final error observed by the commit notification.
|
||||
ctx.on('tools/post-execute', (exec, _result, next) => {
|
||||
if (exec.name === STRUCTURED_OUTPUT_TOOL) {
|
||||
return Promise.resolve({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'capture rejected by hook' }] })
|
||||
}
|
||||
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()
|
||||
@@ -316,21 +383,46 @@ 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 })
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('commits only after a later prepended post-execute wrapper returns the authoritative result', async () => {
|
||||
const { ctx, parent } = await setup([
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 8 }),
|
||||
textResponse('capture was rejected'),
|
||||
])
|
||||
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.
|
||||
ctx.on('tools/post-execute', async (exec, _result, next) => {
|
||||
const downstream = await next()
|
||||
if (exec.name !== STRUCTURED_OUTPUT_TOOL) return downstream
|
||||
return { kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'rejected after downstream' }] }
|
||||
}, { prepend: true })
|
||||
|
||||
const result = await run.result
|
||||
expect(result.structured).toBeUndefined()
|
||||
expect(result.stopReason).toBe('error')
|
||||
const child = ctx.agents.get(run.id)
|
||||
const captureResult = child?.session.events.find(event =>
|
||||
event.type === 'tool/result' && event.data.callId === 'c1')
|
||||
expect(captureResult?.type === 'tool/result' && captureResult.data.isError).toBe(true)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('appends the structured instruction to the child REQUEST\'s system text (base prompt preserved)', async () => {
|
||||
const { ctx, parent, adapter } = await setup([toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 })])
|
||||
// A context-wide section stands in for the deployment persona: the
|
||||
// instruction must APPEND to whatever the prompt pipeline assembled, not
|
||||
// replace it (AgentOptions has no prompt field — the instruction is
|
||||
// per-request wire state added by the final-request listener).
|
||||
// instruction must APPEND to the other scoped and global sections, not
|
||||
// replace them (AgentOptions has no prompt field — the instruction is an
|
||||
// ordinary child-scoped prompt registration).
|
||||
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.')
|
||||
@@ -339,6 +431,84 @@ describe('in-process structured output', () => {
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('keeps pure Code Mode at one wire tool and exposes structured capture through the SDK only', async () => {
|
||||
const { ctx, parent, adapter } = await setup([
|
||||
toolCallResponse('c1', RUN_CODE_NAME, { code: 'return await tools.structured_output({ answer: 12 })' }),
|
||||
], {
|
||||
toolMode: 'code',
|
||||
codeRun: async (request) => {
|
||||
const capture = request.bindings.at(0)?.functions[STRUCTURED_OUTPUT_TOOL]
|
||||
if (!capture) throw new Error('structured_output binding missing')
|
||||
await capture({ answer: 12 })
|
||||
return { logs: [], value: 'captured' }
|
||||
},
|
||||
})
|
||||
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
|
||||
const result = await run.result
|
||||
expect(result.structured).toEqual({ answer: 12 })
|
||||
const request = adapter.requests[0]!
|
||||
expect(toolNames(request)).toEqual([RUN_CODE_NAME])
|
||||
expect(request.system).toContain('declare const tools:')
|
||||
expect(request.system).toContain('structured_output(args:')
|
||||
expect(request.system).toContain(STRUCTURED_OUTPUT_INSTRUCTION)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('discards a nested capture when the enclosing run_code execution fails', async () => {
|
||||
const { ctx, parent, adapter } = await setup([
|
||||
toolCallResponse('c1', RUN_CODE_NAME, { code: 'await tools.structured_output({ answer: 12 }); throw new Error("boom")' }),
|
||||
textResponse('outer code failed'),
|
||||
], {
|
||||
toolMode: 'code',
|
||||
codeRun: async (request) => {
|
||||
const capture = request.bindings.at(0)?.functions[STRUCTURED_OUTPUT_TOOL]
|
||||
if (!capture) throw new Error('structured_output binding missing')
|
||||
await capture({ answer: 12 })
|
||||
return {
|
||||
logs: [],
|
||||
error: { kind: 'runtime', message: 'boom after capture' },
|
||||
} as never
|
||||
},
|
||||
})
|
||||
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
|
||||
const result = await run.result
|
||||
expect(result.structured).toBeUndefined()
|
||||
expect(result.stopReason).toBe('error')
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
const child = ctx.agents.get(run.id)!
|
||||
const outer = child.session.events.find(event =>
|
||||
event.type === 'tool/result' && event.data.callId === CallId('c1'))
|
||||
expect(outer?.type === 'tool/result' && outer.data.isError).toBe(true)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('discards a nested capture when post-policy blocks the enclosing run_code result', async () => {
|
||||
const { ctx, parent, adapter } = await setup([
|
||||
toolCallResponse('c1', RUN_CODE_NAME, { code: 'return await tools.structured_output({ answer: 12 })' }),
|
||||
textResponse('outer code was blocked'),
|
||||
], {
|
||||
toolMode: 'code',
|
||||
codeRun: async (request) => {
|
||||
const capture = request.bindings.at(0)?.functions[STRUCTURED_OUTPUT_TOOL]
|
||||
if (!capture) throw new Error('structured_output binding missing')
|
||||
await capture({ answer: 12 })
|
||||
return { logs: [], value: 'captured' }
|
||||
},
|
||||
})
|
||||
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 = await ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
|
||||
const result = await run.result
|
||||
expect(result.structured).toBeUndefined()
|
||||
expect(result.stopReason).toBe('error')
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('the instruction rides ONLY structured requests: appended for the child, absent for a plain agent', async () => {
|
||||
const { ctx, parent, adapter } = await setup([
|
||||
textResponse('parent answer'),
|
||||
@@ -347,7 +517,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.
|
||||
@@ -357,20 +527,14 @@ describe('in-process structured output', () => {
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
describe('final-request enforcement (the prepend agent/request listener)', () => {
|
||||
it('a plain agent assembling while the runtime is LIVE gets the placeholder stripped', async () => {
|
||||
// Run-scoped acquisition means a plain deployment never registers the
|
||||
// tool at all; the strip branch exists for the CONCURRENT case — a plain
|
||||
// agent taking a turn while some structured child holds the runtime open.
|
||||
describe('scoped registration (each child owns its capture tool)', () => {
|
||||
it('a plain agent never sees the tool: nothing is registered globally at all', async () => {
|
||||
const { ctx, parent, adapter } = await setup([textResponse('parent answer')])
|
||||
const hold = acquireStructuredRuntime(ctx)
|
||||
parent.send([{ type: 'text', text: 'hello' }])
|
||||
await parent.whenIdle()
|
||||
// The placeholder IS in the registry during this turn; the assembly the
|
||||
// loop rendered must not carry it for an agent without a structured run.
|
||||
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeDefined()
|
||||
// Scoped registration: the global view has no capture tool, ever.
|
||||
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
|
||||
expect(toolNames(adapter.requests[0]!)).not.toContain(STRUCTURED_OUTPUT_TOOL)
|
||||
hold.release()
|
||||
})
|
||||
|
||||
it('a structured child sees structured_output with ITS schema; a plain agent never sees the tool', async () => {
|
||||
@@ -384,7 +548,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)
|
||||
@@ -417,8 +581,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' })
|
||||
@@ -430,157 +594,62 @@ describe('in-process structured output', () => {
|
||||
await runB.dispose()
|
||||
})
|
||||
|
||||
it('wins against a downstream listener that REPLACES the assembly object', async () => {
|
||||
it('places the capture tool and instruction in their canonical orders', async () => {
|
||||
const { ctx, parent, adapter } = await setup([
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 5 }),
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 7 }),
|
||||
])
|
||||
// A downstream (non-prepend) listener that returns a brand-new assembly —
|
||||
// the composition caveat that erases cooperative mutations. Registered
|
||||
// AFTER the runtime's prepend listener, so it runs INSIDE it.
|
||||
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
|
||||
const replaced = await next()
|
||||
return { sections: [...replaced.sections], tools: [...replaced.tools], variables: { ...replaced.variables } }
|
||||
// A global tool sorts lexicographically after structured_output, while a
|
||||
// global section above the 190 band follows the capture instruction.
|
||||
ctx.tools.register({
|
||||
name: 'zz_probe',
|
||||
description: 'probe',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
execute: () => Promise.resolve([{ type: 'text', text: 'x' }]),
|
||||
})
|
||||
const run = 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)
|
||||
expect(entry).toBeDefined()
|
||||
expect(entry!.parameters).toEqual(SCHEMA)
|
||||
ctx.systemPrompt.section({ name: 'after-band', order: 200, text: 'AFTER-BAND' })
|
||||
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
await run.result
|
||||
const request = adapter.requests[0]!
|
||||
const names = toolNames(request)
|
||||
expect(names.indexOf(STRUCTURED_OUTPUT_TOOL)).toBeGreaterThanOrEqual(0)
|
||||
expect(names.indexOf(STRUCTURED_OUTPUT_TOOL)).toBeLessThan(names.indexOf('zz_probe'))
|
||||
const system = request.system ?? ''
|
||||
const instructionAt = system.indexOf(STRUCTURED_OUTPUT_INSTRUCTION)
|
||||
expect(instructionAt).toBeGreaterThanOrEqual(0)
|
||||
expect(system.indexOf('AFTER-BAND')).toBeGreaterThan(instructionAt)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('a non-structured agent request keeps tools ABSENT when it had none (no tools: [] materialized)', async () => {
|
||||
const { parent, adapter } = await setup([
|
||||
// The registry contributes the placeholder via prompt assembly, so
|
||||
// tools is an array in the raw request — but after stripping the
|
||||
// placeholder (its ONLY entry), the field must not be re-added as a
|
||||
// different shape.
|
||||
textResponse('plain'),
|
||||
])
|
||||
const { parent, adapter } = await setup([textResponse('plain')])
|
||||
parent.send([{ type: 'text', text: 'q' }])
|
||||
await parent.whenIdle()
|
||||
const request = adapter.requests[0]!
|
||||
expect(toolNames(request)).not.toContain(STRUCTURED_OUTPUT_TOOL)
|
||||
expect(request.tools).toBeUndefined()
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
it('shapes a bare assembly on the waterfall: no-agent context strips the placeholder; a structured agent gains schema + trailing instruction section', async () => {
|
||||
// Drive ctx.systemPrompt.assemble directly — the enforcement listener
|
||||
// must tolerate a context with NO agent (a bare diagnostic assemble)
|
||||
// and shape a structured agent's assembly on the same path the loop
|
||||
// renders and logs as the request header.
|
||||
const { ctx, parent } = await setup([])
|
||||
const acquisition = acquireStructuredRuntime(ctx)
|
||||
// Bare assemble WHILE the runtime is live: the no-agent branch must
|
||||
// strip the registered placeholder (before the acquisition there is
|
||||
// nothing to strip — run-scoped registration).
|
||||
const bare = await ctx.systemPrompt.assemble({})
|
||||
expect(bare.tools.map(tool => tool.name)).not.toContain(STRUCTURED_OUTPUT_TOOL)
|
||||
|
||||
acquisition.attach(parent, SCHEMA)
|
||||
const shaped = await ctx.systemPrompt.assemble({ agent: parent })
|
||||
expect(shaped.tools.map(tool => tool.name)).toContain(STRUCTURED_OUTPUT_TOOL)
|
||||
expect(shaped.tools.find(tool => tool.name === STRUCTURED_OUTPUT_TOOL)!.parameters).toEqual(SCHEMA)
|
||||
// The demand travels with the tool: the instruction renders LAST
|
||||
// (appended post-next(); renderPrompt joins in array order).
|
||||
expect(shaped.sections.at(-1)).toMatchObject({ name: `tool:${STRUCTURED_OUTPUT_TOOL}`, text: STRUCTURED_OUTPUT_INSTRUCTION })
|
||||
acquisition.detach(parent)
|
||||
acquisition.release()
|
||||
})
|
||||
})
|
||||
|
||||
describe('runtime lifetime (refcount: live structured runs)', () => {
|
||||
it('the runtime exists exactly while structured runs are live: nothing before, nothing after', async () => {
|
||||
const { ctx, parent } = await setup([
|
||||
it('registrations ride the child fiber: disposing the run removes them; a provider reload mid-run cannot', async () => {
|
||||
const { ctx, parent, disposeProvider } = await setup([
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 4 }),
|
||||
])
|
||||
// No always-on global state: a context that has run no structured child
|
||||
// carries no capture tool.
|
||||
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.
|
||||
disposeProvider()
|
||||
const result = await run.result
|
||||
// The capture succeeded — the registrations existed while the run lived.
|
||||
expect(result.structured).toEqual({ answer: 4 })
|
||||
// The run's settle released the last acquisition.
|
||||
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
|
||||
const child = ctx.agents.get(run.id)!
|
||||
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL, child)).toBeDefined()
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('concurrent structured runs share one runtime; the last settle disposes it', async () => {
|
||||
const { ctx, parent } = await setup([
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
|
||||
toolCallResponse('c2', STRUCTURED_OUTPUT_TOOL, { answer: 2 }),
|
||||
])
|
||||
const first = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const second = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const [a, b] = await Promise.all([first.result, second.result])
|
||||
expect([a.structured, b.structured].sort()).toEqual([{ answer: 1 }, { answer: 2 }].sort())
|
||||
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
|
||||
await first.dispose()
|
||||
await second.dispose()
|
||||
})
|
||||
|
||||
it('acquisition release is idempotent (double release cannot underflow the refcount)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
const first = acquireStructuredRuntime(ctx)
|
||||
const second = acquireStructuredRuntime(ctx)
|
||||
first.release()
|
||||
first.release()
|
||||
// The second holder still keeps the tool registered.
|
||||
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeDefined()
|
||||
second.release()
|
||||
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('registers the capture tool through the scoped fiber when tools loads after the acquisition', async () => {
|
||||
// The Loader starts sibling plugins concurrently, so a backend can
|
||||
// acquire the runtime before dsh-tools has applied. The capture tool
|
||||
// must then register as soon as `tools` exists — via the inject fiber,
|
||||
// not by deferring the backend (which would reorder the prompt's tools).
|
||||
const ctx = new Context()
|
||||
const acquisition = acquireStructuredRuntime(ctx)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
// Fiber activation completes asynchronously after the service appears.
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeDefined()
|
||||
acquisition.release()
|
||||
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('releasing before tools ever loads disposes the pending fiber without registering', async () => {
|
||||
const ctx = new Context()
|
||||
const acquisition = acquireStructuredRuntime(ctx)
|
||||
acquisition.release()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
// The disposed fiber never fires: nothing registers after the fact.
|
||||
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('attach/captured/detach manage per-agent state through the acquisition surface', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
const acquisition = acquireStructuredRuntime(ctx)
|
||||
expect(acquisition.captured(parent)).toBeUndefined()
|
||||
acquisition.attach(parent, SCHEMA)
|
||||
expect(acquisition.captured(parent)).toBeUndefined()
|
||||
acquisition.detach(parent)
|
||||
acquisition.detach(parent)
|
||||
acquisition.release()
|
||||
// That manual acquisition was the ONLY holder - release disposes.
|
||||
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
|
||||
// Child disposed ⇒ its scoped registrations are gone.
|
||||
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL, child)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
it('a direct structured_output call from an agent WITHOUT a structured run is an isError', async () => {
|
||||
it('a structured_output call from an agent WITHOUT a structured run is UNKNOWN_TOOL (the tool does not exist for it)', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
// Hold the runtime open (run-scoped: nothing is registered otherwise) so
|
||||
// the call reaches the capture tool's own fail-loud guard, not UNKNOWN_TOOL.
|
||||
const hold = acquireStructuredRuntime(ctx)
|
||||
const result = await ctx.tools.execute({
|
||||
callId: 'x' as never,
|
||||
name: STRUCTURED_OUTPUT_TOOL,
|
||||
@@ -588,19 +657,140 @@ describe('in-process structured output', () => {
|
||||
agent: parent,
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(JSON.stringify(result.content)).toContain('only available to subagents')
|
||||
hold.release()
|
||||
expect(result.error?.code).toBe('UNKNOWN_TOOL')
|
||||
})
|
||||
|
||||
it('a structured_output call with NO calling agent at all is an isError', async () => {
|
||||
it('a structured_output call with NO calling agent at all is UNKNOWN_TOOL', async () => {
|
||||
const { ctx } = await setup([])
|
||||
const hold = acquireStructuredRuntime(ctx)
|
||||
const result = await ctx.tools.execute({
|
||||
callId: 'x' as never,
|
||||
name: STRUCTURED_OUTPUT_TOOL,
|
||||
arguments: { answer: 1 },
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
hold.release()
|
||||
expect(result.error?.code).toBe('UNKNOWN_TOOL')
|
||||
})
|
||||
|
||||
it('a failed execution stage is discarded and never promoted by a later call', async () => {
|
||||
const { ctx, parent } = await setup([
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
|
||||
])
|
||||
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.
|
||||
let blocks = 1
|
||||
ctx.on('tools/post-execute', (exec, _result, next) => {
|
||||
if (exec.name === STRUCTURED_OUTPUT_TOOL && blocks > 0) {
|
||||
blocks -= 1
|
||||
return Promise.resolve({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'rejected' }] })
|
||||
}
|
||||
return next()
|
||||
}, { prepend: true })
|
||||
const result = await run.result
|
||||
const child = ctx.agents.get(run.id)!
|
||||
// The blocked capture must NOT surface as structured success…
|
||||
expect(result.stopReason).toBe('error')
|
||||
expect(result.structured).toBeUndefined()
|
||||
// …and a LATER invalid call (its own body staged nothing) must not
|
||||
// resurrect c1's discarded value: drive the pipeline directly.
|
||||
const invalid = await ctx.tools.execute({
|
||||
callId: 'c2' as never,
|
||||
name: STRUCTURED_OUTPUT_TOOL,
|
||||
arguments: { answer: 'not-a-number' },
|
||||
agent: child,
|
||||
})
|
||||
expect(invalid.isError).toBe(true)
|
||||
// A fresh valid call still captures ITS OWN value.
|
||||
const valid = await ctx.tools.execute({
|
||||
callId: 'c3' as never,
|
||||
name: STRUCTURED_OUTPUT_TOOL,
|
||||
arguments: { answer: 9 },
|
||||
agent: child,
|
||||
})
|
||||
expect(valid.isError).toBeFalsy()
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('reusing a failed execution\'s call id never promotes its discarded stage', async () => {
|
||||
const { ctx, parent } = await setup([
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
|
||||
])
|
||||
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
|
||||
ctx.on('tools/post-execute', (exec, _result, next) => {
|
||||
if (exec.name === STRUCTURED_OUTPUT_TOOL && blocks > 0) {
|
||||
blocks -= 1
|
||||
return Promise.resolve({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'rejected' }] })
|
||||
}
|
||||
return next()
|
||||
}, { prepend: true })
|
||||
await run.result
|
||||
const child = ctx.agents.get(run.id)!
|
||||
// A SECOND capture call with the SAME call id whose body never stages
|
||||
// (invalid args throw before the stage): the discarded value must not ride
|
||||
// its acceptance.
|
||||
const reused = await ctx.tools.execute({
|
||||
callId: 'c1' as never,
|
||||
name: STRUCTURED_OUTPUT_TOOL,
|
||||
arguments: { answer: 'not-a-number' },
|
||||
agent: child,
|
||||
})
|
||||
expect(reused.isError).toBe(true)
|
||||
// Nothing was ever committed: a fresh valid call is still required.
|
||||
const valid = await ctx.tools.execute({
|
||||
callId: 'c1' as never,
|
||||
name: STRUCTURED_OUTPUT_TOOL,
|
||||
arguments: { answer: 5 },
|
||||
agent: child,
|
||||
})
|
||||
expect(valid.isError).toBeFalsy()
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('a pre-execute deny with call-id reuse cannot promote another execution\'s stage', async () => {
|
||||
const { ctx, parent } = await setup([
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
|
||||
])
|
||||
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) => {
|
||||
if (exec.name === STRUCTURED_OUTPUT_TOOL && blocks > 0) {
|
||||
blocks -= 1
|
||||
return Promise.resolve({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'rejected' }] })
|
||||
}
|
||||
return next()
|
||||
}, { prepend: true })
|
||||
await run.result
|
||||
const child = ctx.agents.get(run.id)!
|
||||
// A prepended pre-execute deny skips the body, while the denied call still
|
||||
// reaches the final notification with the same adapter-minted call id.
|
||||
const offDeny = ctx.on('tools/pre-execute', (exec) => {
|
||||
if (exec.name === STRUCTURED_OUTPUT_TOOL) {
|
||||
return Promise.resolve({ kind: 'deny' as const, reason: 'outer veto' })
|
||||
}
|
||||
return undefined as never
|
||||
}, { prepend: true })
|
||||
const denied = await ctx.tools.execute({
|
||||
callId: 'c1' as never,
|
||||
name: STRUCTURED_OUTPUT_TOOL,
|
||||
arguments: { answer: 2 },
|
||||
agent: child,
|
||||
})
|
||||
expect(denied.isError).toBe(true)
|
||||
offDeny()
|
||||
// The discarded value was never promoted: a fresh valid call is required
|
||||
// (and succeeds, proving the runtime is not wedged).
|
||||
const valid = await ctx.tools.execute({
|
||||
callId: 'c1' as never,
|
||||
name: STRUCTURED_OUTPUT_TOOL,
|
||||
arguments: { answer: 5 },
|
||||
agent: child,
|
||||
})
|
||||
expect(valid.isError).toBeFalsy()
|
||||
await run.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,51 +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('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 }, { providerName: 'spawn' })
|
||||
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()
|
||||
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 }, { providerName: 'spawn' }))
|
||||
.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 }, { providerName: 'fork', 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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
# @deepseek-ai/dsh-subagent-spawn
|
||||
|
||||
The in-process **spawn** subagent backend: a [`SubagentProvider`](../subagent/README.md) that runs each child as a **fresh** child [`Agent`](../../core/agent) on the same cordis context (`ctx.agents`) — its own session, its own (or the parent's) model, zero inherited conversation. The cheapest transport, reusing the agent factory's quiescent [`AgentHandle`](../../core/agent) teardown.
|
||||
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 run mechanics live in the shared [`@deepseek-ai/dsh-subagent-inprocess`](../subagent-inprocess/README.md) driver (`startInProcessRun`); this backend just passes **no seed** (a fresh child). The [fork](../subagent-fork/README.md) backend is an independent peer over the same driver — neither knows about the other.
|
||||
## Behavior
|
||||
|
||||
## What it does
|
||||
`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.
|
||||
|
||||
`start(request)` delegates to `startInProcessRun(ctx, request, { providerName })` with no seed: a fresh child agent with the parent's `cwd`/`parentSession` lineage and (by default) the parent's model. See the [driver README](../subagent-inprocess/README.md) for the full lifecycle (depth check, one-shot drive, result read, dispose).
|
||||
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: false }`. It constructs the child, so it enforces a recursion cap, and it supports structured output via the driver's [structured runtime](../subagent-inprocess/README.md) (acquired per structured run inside the driver — this backend registers nothing at apply). Tool-scoping is deferred (the service rejects a request needing it before `start` runs).
|
||||
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
|
||||
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
* ({@link startInProcessRun}); this backend just passes NO seed (a fresh
|
||||
* child). The fork backend is an independent peer over the same driver.
|
||||
*
|
||||
* Structured output (`outputSchema`) is supported via the driver's shared
|
||||
* structured runtime: the backend acquires it for its plugin lifetime (so the
|
||||
* capture tool and request-shaping listeners exist before any run), and each
|
||||
* structured run holds its own acquisition until it settles.
|
||||
* Structured output (`outputSchema`) is supported through the driver's
|
||||
* per-child scoped runtime: the child registers its real-schema capture tool,
|
||||
* prompt instruction, and enforcement listeners inside the creation setup
|
||||
* window, and its scope owns their lifetime.
|
||||
*
|
||||
* Plugin export shape: named `name`/`inject`/`Config`/`apply`, NO default.
|
||||
*
|
||||
@@ -25,12 +25,11 @@ import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } fro
|
||||
import { startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess'
|
||||
|
||||
export const name = 'subagent-spawn'
|
||||
// `tools` is deliberately NOT injected: the shared driver's structured runtime
|
||||
// (acquired per structured RUN, not at apply) gates its own capture-tool
|
||||
// registration on `tools` availability, so this backend's apply timing — and
|
||||
// with it the provider-mirroring delegation tool's position in the
|
||||
// model-visible tool list — stays what it was before structured output existed.
|
||||
export const inject = ['subagents', 'agents']
|
||||
// `tools` is deliberately NOT injected: the shared driver registers structured
|
||||
// output through the child's creation context, whose factory already requires
|
||||
// the tool service. Keeping it out of this backend's inject list preserves the
|
||||
// provider's independent apply timing.
|
||||
export const inject = ['subagents']
|
||||
|
||||
/** Config: the registry name to register the provider under. */
|
||||
export interface Config {
|
||||
@@ -43,26 +42,27 @@ export const Config: z<Config> = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* The spawn provider. Supports `depthLimit` (it constructs the child, so it can
|
||||
* enforce a recursion cap) and `outputSchema` (via the shared in-process
|
||||
* structured runtime); NOT `toolFilter` in this cut — a request that needs it
|
||||
* is rejected by the service before `start` runs.
|
||||
* The spawn provider. Supports every start-time capability: `depthLimit` (it
|
||||
* constructs the child, so it can enforce a recursion cap), `outputSchema`
|
||||
* (the scoped structured runtime), and `toolFilter`/`persona` (scoped
|
||||
* `restrict()` and a scoped shadowing persona section, applied in the child's
|
||||
* creation window).
|
||||
*/
|
||||
class SpawnProvider implements SubagentProvider {
|
||||
readonly capabilities: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: false }
|
||||
readonly capabilities: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: true, persona: true }
|
||||
// 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, { providerName: this.name })
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -23,9 +23,10 @@ export async function spawnHarness(workdir: string): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
// The deployment persona is context-wide (parent AND spawned children
|
||||
// render it), so it stays neutral for both roles; the delegation nudge
|
||||
// lives in the e2e's user prompt and the subagent tool's own description.
|
||||
// This harness installs only the global default persona, so both parent and
|
||||
// spawned children render it. It stays neutral for both roles; the
|
||||
// delegation nudge lives in the e2e's user prompt and the subagent tool's
|
||||
// own description.
|
||||
await ctx.plugin(SystemPrompt, { persona: 'You are a coding agent. Report only when the requested work is done.' })
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
|
||||
@@ -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,20 +44,43 @@ 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')
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('emits subagent/start only after the fresh child is published', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('child answer')])
|
||||
let childAtStart: ReturnType<typeof ctx.agents.get>
|
||||
ctx.on('subagent/start', (info) => {
|
||||
if (info.provider === 'spawn') childAtStart = ctx.agents.get(info.id)
|
||||
})
|
||||
|
||||
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()
|
||||
const run = await starting
|
||||
expect(childAtStart).toBe(ctx.agents.get(run.id))
|
||||
expect(childAtStart?.id).toBe(run.id)
|
||||
|
||||
await run.result
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('gives the child its OWN session (not the parent\'s), with parentSession lineage', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('hi')])
|
||||
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
|
||||
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)
|
||||
@@ -73,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.
|
||||
@@ -84,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()
|
||||
@@ -95,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)
|
||||
@@ -105,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()
|
||||
@@ -121,14 +144,14 @@ 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 () => {
|
||||
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
|
||||
@@ -137,26 +160,44 @@ describe('dsh-subagent-spawn', () => {
|
||||
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. Deriving the stop reason from
|
||||
// `turn/end` alone then mis-maps the no-turn case to `error`; the run must
|
||||
// honor the cancel contract and settle `aborted`. The cancel is synchronous
|
||||
// (same tick as start, before the loop's queued-wait continuation runs), so
|
||||
// the turn is dropped and the empty script is never consumed.
|
||||
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 run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
|
||||
run.cancel('early')
|
||||
const beforeAgents = ctx.agents.list().length
|
||||
const beforeSessions = ctx.sessions.list().length
|
||||
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'))
|
||||
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')
|
||||
|
||||
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.stopReason).toBe('aborted')
|
||||
expect(result.output).toEqual([])
|
||||
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()
|
||||
})
|
||||
|
||||
@@ -164,7 +205,7 @@ describe('dsh-subagent-spawn', () => {
|
||||
// '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))
|
||||
@@ -174,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
|
||||
@@ -206,13 +236,13 @@ describe('dsh-subagent-spawn', () => {
|
||||
it('inherits the parent cwd into the child session', async () => {
|
||||
const { ctx } = await setup([textResponse('x')])
|
||||
// A parent WITH a cwd (config agents have none, so create one explicitly).
|
||||
const parentHandle = ctx.agents.create({
|
||||
const parentHandle = await ctx.agents.create({
|
||||
agentId: AgentId('cwd-parent'),
|
||||
sessionId: SessionId('cwd-parent-session'),
|
||||
meta: { cwd: '/tmp/parent-workspace' },
|
||||
agentOptions: { model: 'mock' },
|
||||
})
|
||||
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent: parentHandle.agent })
|
||||
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')
|
||||
@@ -223,13 +253,13 @@ describe('dsh-subagent-spawn', () => {
|
||||
it('uses request.agentOptions.model when the parent has no model of its own', async () => {
|
||||
const { ctx } = await setup([textResponse('explicit model child')])
|
||||
// A parent with NO model (its own turns would need one supplied per-request).
|
||||
const parentHandle = ctx.agents.create({
|
||||
const parentHandle = await ctx.agents.create({
|
||||
agentId: AgentId('modelless-parent'),
|
||||
sessionId: SessionId('modelless-parent-session'),
|
||||
agentOptions: {},
|
||||
})
|
||||
// The request supplies the child's model explicitly.
|
||||
const run = ctx.subagents.start('spawn', {
|
||||
const run = await start(ctx, 'spawn', {
|
||||
prompt: [{ type: 'text', text: 'p' }],
|
||||
parent: parentHandle.agent,
|
||||
agentOptions: { model: 'mock' },
|
||||
@@ -241,10 +271,10 @@ describe('dsh-subagent-spawn', () => {
|
||||
await parentHandle.dispose()
|
||||
})
|
||||
|
||||
it('advertises depthLimit and outputSchema but not toolFilter', async () => {
|
||||
it('advertises every start-time capability (depthLimit, outputSchema, toolFilter, persona)', async () => {
|
||||
const { ctx } = await setup([])
|
||||
const provider = ctx.subagents.getProvider('spawn')!
|
||||
expect(provider.capabilities).toEqual({ outputSchema: true, depthLimit: true, toolFilter: false })
|
||||
expect(provider.capabilities).toEqual({ outputSchema: true, depthLimit: true, toolFilter: true, persona: true })
|
||||
})
|
||||
|
||||
it('unregisters the provider when its fiber is disposed (HMR safety)', async () => {
|
||||
@@ -261,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'] },
|
||||
@@ -274,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'])
|
||||
@@ -289,31 +319,171 @@ 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. The
|
||||
// backend owns the child agent, so the unload tears the child down and
|
||||
// the run settles — releasing its own runtime acquisition on the way out.
|
||||
// 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 start racing an already-unloading backend cannot begin child creation', 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(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SubagentService)
|
||||
const fiber = await ctx.plugin(spawn, { providerName: 'spawn' })
|
||||
const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' })
|
||||
const parentEffects = parent.ctx.fiber.getEffects().length
|
||||
const published: string[] = []
|
||||
ctx.on('session/created', () => void published.push('session/created'))
|
||||
ctx.on('agent/created', () => void published.push('agent/created'))
|
||||
|
||||
const unloading = fiber.dispose()
|
||||
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([])
|
||||
})
|
||||
|
||||
it('has the namespace-plugin export shape (no stray default)', () => {
|
||||
expect('default' in spawn).toBe(false)
|
||||
expect(spawn.name).toBe('subagent-spawn')
|
||||
expect(spawn.inject).toEqual(['subagents', 'agents'])
|
||||
expect(spawn.inject).toEqual(['subagents'])
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
const unwrapped = loader.unwrapExports(spawn) as Record<string, unknown>
|
||||
expect(unwrapped).toBe(spawn)
|
||||
expect(unwrapped.name).toBe('subagent-spawn')
|
||||
expect(unwrapped.inject).toEqual(['subagents', 'agents'])
|
||||
expect(unwrapped.inject).toEqual(['subagents'])
|
||||
expect(typeof unwrapped.apply).toBe('function')
|
||||
})
|
||||
|
||||
describe('persona and toolFilter (the scoped child world)', () => {
|
||||
it('a per-child persona shadows the deployment persona in the child request only', async () => {
|
||||
const { ctx, parent, adapter } = await setup([
|
||||
textResponse('parent answer'),
|
||||
textResponse('child answer'),
|
||||
])
|
||||
parent.send([{ type: 'text', text: 'hi' }])
|
||||
await parent.whenIdle()
|
||||
|
||||
const run = await start(ctx, 'spawn', {
|
||||
prompt: [{ type: 'text', text: 'do X' }],
|
||||
parent,
|
||||
persona: 'You are the tersest test runner.',
|
||||
})
|
||||
await run.result
|
||||
const childRequest = adapter.requests.at(-1)!
|
||||
expect(childRequest.system).toContain('You are the tersest test runner.')
|
||||
// The parent's earlier request carried no such persona.
|
||||
expect(adapter.requests[0]!.system ?? '').not.toContain('tersest test runner')
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('toolFilter hides denied tools from the child prompt AND refuses their execution', async () => {
|
||||
const { ctx, parent, adapter } = await setup([
|
||||
// The child tries the denied tool anyway, then answers.
|
||||
toolCallResponse('c1', 'forbidden_tool', {}),
|
||||
textResponse('done'),
|
||||
])
|
||||
ctx.tools.register({
|
||||
name: 'forbidden_tool', description: 'global', parameters: {},
|
||||
execute: () => Promise.resolve([{ type: 'text', text: 'ran' }]),
|
||||
})
|
||||
const run = await start(ctx, 'spawn', {
|
||||
prompt: [{ type: 'text', text: 'do X' }],
|
||||
parent,
|
||||
toolFilter: { deny: ['forbidden_tool'] },
|
||||
})
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('completed')
|
||||
// Not advertised…
|
||||
const childRequest = adapter.requests[0]!
|
||||
expect((childRequest.tools ?? []).map(t => t.name)).not.toContain('forbidden_tool')
|
||||
// …and the attempted call executed as UNKNOWN_TOOL (visible in the log).
|
||||
const child = ctx.agents.get(run.id)!
|
||||
const toolResult = child.session.events.find(e => e.type === 'tool/result')!
|
||||
expect(JSON.stringify(toolResult.data)).toContain('unknown tool')
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
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
|
||||
await expect(start(ctx, 'spawn', {
|
||||
prompt: [{ type: 'text', text: 'do X' }],
|
||||
parent,
|
||||
toolFilter: { deny: ['no_such_tool'] },
|
||||
})).rejects.toThrow(/unknown global tool "no_such_tool"/)
|
||||
expect(ctx.agents.list().length).toBe(before)
|
||||
})
|
||||
})
|
||||
|
||||
it('spawning from a DISPOSING parent fails loud with no orphaned child (INACTIVE_EFFECT teaching error)', async () => {
|
||||
const { ctx } = await setup([])
|
||||
// A handle-owned parent we can dispose (config agents dispose with the loop fiber).
|
||||
const parentHandle = await ctx.agents.create({
|
||||
agentId: AgentId('doomed-parent'),
|
||||
sessionId: SessionId('doomed-s'),
|
||||
agentOptions: { model: 'mock' },
|
||||
})
|
||||
await parentHandle.dispose()
|
||||
const before = ctx.agents.list().length
|
||||
const sessionsBefore = ctx.sessions.list().length
|
||||
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'))
|
||||
await expect(start(ctx, 'spawn', {
|
||||
prompt: [{ type: 'text', text: 'do X' }],
|
||||
parent: parentHandle.agent,
|
||||
})).rejects.toThrow(/inactive context/)
|
||||
expect(ctx.agents.list().length).toBe(before)
|
||||
expect(ctx.sessions.list()).toHaveLength(sessionsBefore)
|
||||
expect(published).toEqual([])
|
||||
})
|
||||
|
||||
it('parent disposal during the child setup transaction prevents every publication notification', async () => {
|
||||
const { ctx } = await setup([])
|
||||
const parentHandle = await ctx.agents.create({
|
||||
agentId: AgentId('setup-race-parent'),
|
||||
sessionId: SessionId('setup-race-parent-session'),
|
||||
agentOptions: { 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 starting = start(ctx, 'spawn', {
|
||||
prompt: [{ type: 'text', text: 'must never run' }],
|
||||
parent: parentHandle.agent,
|
||||
})
|
||||
// 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(starting).rejects.toThrow(/owner disposed during setup|inactive context/)
|
||||
|
||||
expect(published).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 under `provider.name`. Throws `SubagentError('DUPLICATE_PROVIDER')` on a name clash. Effect-scoped (HMR-safe); returns the disposer. |
|
||||
| `getProvider(name)` | Look up a provider (`undefined` if absent). |
|
||||
| `list()` | Registered provider names (insertion order). |
|
||||
| `start(name, request)` | Resolve the provider (`NO_PROVIDER` if absent), validate every requested START-TIME capability (`UNSUPPORTED_CAPABILITY` for the first unmet one — before any child is created), then delegate to `provider.start` and emit `subagent/start` / `subagent/end` around the run. |
|
||||
| `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`) 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 a `result` promise, `cancel()`, `dispose()`, and the optional runtime methods. `result` resolves with a `SubagentResult` (`output`, optional `structured`, `stopReason`) — it does **not** reject on a child-level failure (a model/transport failure resolves with `stopReason: 'error'`), so the consumer maps a non-`completed` reason to an `isError` tool result. The consumer MUST `dispose()` on every path (success, error, abort) to reach child quiescence and avoid leaking an idle child / session.
|
||||
- `outputSchema` — enforce a structured final result.
|
||||
- `depthLimit` — enforce `maxDepth`.
|
||||
- `toolFilter` — apply the requested child tool restriction.
|
||||
- `persona` — apply a per-child persona.
|
||||
|
||||
The service also announces provider lifecycle: `subagent/provider-added` (the live provider) fires after a registration and `subagent/provider-removed` (the name) after an unregistration, so a consumer deriving state from a named provider (the model-facing tool wording) mirrors registry membership instead of assuming load order — the cordis Loader starts sibling plugins concurrently, so "listed earlier" does not mean "registered earlier". The service emits `subagent/start` (payload `SubagentRunInfo`) and `subagent/end` (payload `SubagentRunEndInfo`) around the run — both **observe-only** (plain `emit`s; `subagent/end` fires from a detached `.then` and awaits no listener). `subagent/end` carries `lastAssistantMessage` (a deep clone of the child's final `output`) on the settle path, absent when the run rejected at the infrastructure level. The clone keeps the surface observe-only: the end emit fires from a detached `.then` before the caller's `await run.result` resumes, so a shared reference would let a mutating listener corrupt the caller's result. A `subagent/start` listener can still reach the live child via `ctx.agents.get(info.id)`; a `subagent/end` listener can only observe (the run has settled). Any run-affecting decision (continuation, injection that changes the run) is out of scope for this observe-only surface.
|
||||
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.
|
||||
|
||||
@@ -24,12 +24,14 @@
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-scope": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
|
||||
@@ -1,41 +1,24 @@
|
||||
/**
|
||||
* 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).
|
||||
* capability-validating asynchronous start surface. Providers establish a
|
||||
* child before returning its run, so fulfillment is the single publication and
|
||||
* ownership-transfer boundary.
|
||||
*
|
||||
* Unlike the bash seam (one executor per context, second load throws), MULTIPLE
|
||||
* providers coexist here: each registers under a unique name and a caller picks
|
||||
* one by name. The shape mirrors the LLM adapter registry
|
||||
* (`LlmService.registerAdapter`), not the single-service bash executor.
|
||||
*
|
||||
* This package is the INTERFACE third of the capability seam. Implementations
|
||||
* (`@deepseek-ai/dsh-subagent-spawn`, `-fork`, `-acp`) and the model-facing
|
||||
* consumer (`@deepseek-ai/dsh-tool-subagent`) are separate packages.
|
||||
*
|
||||
* Scope (first cut): the consumer collects synchronously — it starts a run and
|
||||
* awaits {@link SubagentRun.result}. Steering ({@link SubagentRun.sendMessage})
|
||||
* is part of the contract but intentionally unused; background / poll / spill
|
||||
* semantics are deferred to a future redesign that unifies long-running-tool
|
||||
* handling across subagents and bash.
|
||||
*
|
||||
* The `subagent/start` / `subagent/end` lifecycle events carry an OBSERVE-ONLY
|
||||
* payload; `subagent/end` additionally carries the child's `lastAssistantMessage`
|
||||
* — see `docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.md`.
|
||||
* FIXME(subagent-continuation): a control-flow `subagent/end` (an awaited
|
||||
* waterfall returning a stop/continue decision, like the other interception
|
||||
* seams) would require reshaping this emit into a waterfall, awaiting listeners
|
||||
* before settling, and a `resume` capability on the in-process provider — part
|
||||
* of the deferred background/steering redesign, NOT this observe-only cut.
|
||||
* 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 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 { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import type {
|
||||
SubagentCapabilities,
|
||||
SubagentProvider,
|
||||
@@ -54,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
|
||||
@@ -61,75 +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 provider that just registered, live in the registry.
|
||||
* 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 after the provider is resolved and its
|
||||
* capabilities validated, as the child run begins. Paired with
|
||||
* {@link Events['subagent/end']}.
|
||||
* @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'(info: SubagentRunInfo): void
|
||||
'subagent/start'(this: Scoped<SubagentService>, info: SubagentRunInfo): void
|
||||
/**
|
||||
* A subagent run settled — emitted when {@link SubagentRun.result}
|
||||
* resolves (any stop reason). Paired with {@link Events['subagent/start']}.
|
||||
* @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'(info: SubagentRunEndInfo): void
|
||||
'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)
|
||||
@@ -137,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>()
|
||||
|
||||
@@ -149,163 +128,120 @@ export class SubagentService extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a provider under its `provider.name`. Throws {@link SubagentError}
|
||||
* (`DUPLICATE_PROVIDER`) if the name is already taken. Effect-scoped: disposed
|
||||
* with the calling fiber (HMR-safe). Emits `subagent/provider-added` after
|
||||
* the registration and `subagent/provider-removed` on unregistration, so
|
||||
* consumers can mirror provider lifecycle instead of assuming load order.
|
||||
* @param provider - the provider; its `name` is the registry key.
|
||||
* @returns the disposer that unregisters the provider.
|
||||
* 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): () => void {
|
||||
const dispose = this.ctx.effect(function* (this: SubagentService) {
|
||||
if (this.providers.has(provider.name)) {
|
||||
throw new SubagentError(`a subagent provider named "${provider.name}" is already registered`, 'DUPLICATE_PROVIDER')
|
||||
const name = provider.name
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||
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(provider.name, provider)
|
||||
// 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. The removal
|
||||
// announcement itself is contained PER LISTENER ({@link emitLifecycle}):
|
||||
// it runs inside this disposer, where a propagating subscriber would
|
||||
// disrupt the backend fiber's teardown and starve later mirrors.
|
||||
this.providers.set(name, provider)
|
||||
yield () => {
|
||||
this.providers.delete(provider.name)
|
||||
this.emitLifecycle('subagent/provider-removed', provider.name)
|
||||
this.providers.delete(name)
|
||||
this.emitLifecycle('subagent/provider-removed', name)
|
||||
}
|
||||
// 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()')
|
||||
// ctx.effect's disposer returns Promise<void>; our disposer API is
|
||||
// synchronous fire-and-forget — discard the (always-resolved) promise.
|
||||
return () => void dispose()
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a registered provider by name (`undefined` if absent).
|
||||
* @param name - the provider name as registered.
|
||||
* @returns the provider, 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. Resolves the provider (throws
|
||||
* `NO_PROVIDER` if absent), validates every requested START-TIME capability
|
||||
* against {@link SubagentProvider.capabilities} (throws `UNSUPPORTED_CAPABILITY`
|
||||
* for the first unmet one — fail loud, before any child is created), then
|
||||
* delegates to {@link SubagentProvider.start} and emits `subagent/start` /
|
||||
* `subagent/end` around the run.
|
||||
* @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 {
|
||||
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)
|
||||
|
||||
const run = provider.start(request)
|
||||
// Emit `subagent/start` with PER-LISTENER containment (see {@link emitLifecycle}):
|
||||
// the run is already live, so neither a throwing subscriber escaping
|
||||
// `start()` (the caller would never receive the run to dispose it — a leaked
|
||||
// child) NOR one bad subscriber starving the listeners after it is
|
||||
// acceptable. `ctx.emit` halts the dispatch on the first throw, so a single
|
||||
// surrounding try/catch is not enough — each listener is invoked and
|
||||
// contained individually.
|
||||
this.emitLifecycle('subagent/start', { provider: name, id: run.id })
|
||||
// Emit `subagent/end` when the run settles. The result promise does not
|
||||
// reject on a child-level failure (it resolves with stopReason 'error'),
|
||||
// so a rejection here is an infrastructure fault — surface its stop reason
|
||||
// as 'error' for the telemetry event without swallowing the rejection
|
||||
// (the consumer still observes it via `run.result`). On the resolve path the
|
||||
// child's final output rides on the event (lastAssistantMessage); on the
|
||||
// reject path there is no SubagentResult, so only the stop reason is known.
|
||||
// Per-listener containment also keeps a thrown `subagent/end` listener from
|
||||
// becoming an unhandled rejection on this detached `.then`.
|
||||
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) => {
|
||||
// Deep-clone the output onto the event: this detached `.then` runs BEFORE
|
||||
// the caller's own `await run.result` continuation, so handing listeners
|
||||
// the SAME array reference the caller consumes would let a mutating
|
||||
// `subagent/end` listener corrupt the caller's SubagentResult.output —
|
||||
// breaking the observe-only contract. A snapshot makes the event a
|
||||
// read-only view, not a shared handle. The clone is wrapped: it runs
|
||||
// inside `onFulfilled`, OUTSIDE emitLifecycle's per-listener containment,
|
||||
// so an uncloneable value (a future non-serializable content-block type,
|
||||
// or a contract-violating result with no `output`) would otherwise become
|
||||
// an unhandled rejection on this detached `.then`. On clone failure, log
|
||||
// and emit the event WITHOUT lastAssistantMessage rather than dropping the
|
||||
// whole `subagent/end`.
|
||||
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)}`)
|
||||
}
|
||||
this.emitLifecycle('subagent/end', { provider: name, id: run.id, stopReason: result.stopReason, ...lastAssistantMessage !== undefined ? { lastAssistantMessage } : {} })
|
||||
this.emitLifecycle('subagent/end', {
|
||||
provider: name,
|
||||
id: run.id,
|
||||
stopReason: result.stopReason,
|
||||
lastAssistantMessage: result.output,
|
||||
}, parent)
|
||||
},
|
||||
() => {
|
||||
this.emitLifecycle('subagent/end', { provider: name, id: run.id, stopReason: 'error' }, parent)
|
||||
},
|
||||
() => { this.emitLifecycle('subagent/end', { provider: name, id: run.id, stopReason: 'error' }) },
|
||||
)
|
||||
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. A single try/catch around `ctx.emit` would not do the
|
||||
* last part — cordis `emit` runs listeners in a `.map(cb => cb())` that halts
|
||||
* on the first throw — so this resolves the listener callbacks via
|
||||
* `ctx.events.dispatch` and contains each call, the same guarantee
|
||||
* `BashExecutor.notifyTaskDone` gives its own listener set.
|
||||
*
|
||||
* `subagent/provider-removed` routes through here too: it fires inside the
|
||||
* provider registration's DISPOSER, where a propagating listener would
|
||||
* disrupt the backend fiber's teardown (dispose must reach quiescence) and a
|
||||
* starved later listener would leave a mirror consumer (`dsh-tool-subagent`)
|
||||
* holding a tool for a provider that no longer exists. `subagent/provider-added`
|
||||
* deliberately does NOT: it fires at registration time, where a throwing
|
||||
* listener unwinds the yielded rollback — the same fail-loud register-time
|
||||
* semantics as the system-prompt registries.
|
||||
* Emit lifecycle events with per-listener synchronous and asynchronous
|
||||
* exception containment. Payloads are borrowed immutable values.
|
||||
*/
|
||||
private emitLifecycle(name: 'subagent/start', info: SubagentRunInfo): void
|
||||
private emitLifecycle(name: 'subagent/end', info: SubagentRunEndInfo): void
|
||||
private emitLifecycle(name: 'subagent/start', info: SubagentRunInfo, parent: Agent): void
|
||||
private emitLifecycle(name: 'subagent/end', info: SubagentRunEndInfo, parent: Agent): void
|
||||
private emitLifecycle(name: 'subagent/provider-removed', info: string): void
|
||||
private emitLifecycle(
|
||||
name: 'subagent/start' | 'subagent/end' | 'subagent/provider-removed',
|
||||
info: SubagentRunInfo | SubagentRunEndInfo | string,
|
||||
parent?: Agent,
|
||||
): void {
|
||||
for (const callback of this.ctx.events.dispatch('emit', [name, info])) {
|
||||
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' },
|
||||
{ when: request.maxDepth !== undefined, cap: 'depthLimit' },
|
||||
{ when: request.toolFilter !== undefined, cap: 'toolFilter' },
|
||||
{ when: request.persona !== undefined, cap: 'persona' },
|
||||
]
|
||||
for (const { when, cap } of needs) {
|
||||
if (when && !provider.capabilities[cap]) {
|
||||
@@ -318,4 +254,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
|
||||
|
||||
@@ -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
|
||||
@@ -24,11 +24,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). */
|
||||
readonly persona: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,22 +41,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
|
||||
/** Per-child agent options (model, system prompt). */
|
||||
agentOptions?: AgentOptions
|
||||
readonly signal: AbortSignal
|
||||
/** Per-child agent options (model and plugin-defined extension fields). */
|
||||
readonly agentOptions?: AgentOptions
|
||||
/**
|
||||
* Optional structured-output schema — an object-rooted JSON Schema within the
|
||||
* enforced subset (see `assertSupportedOutputSchema` in dsh-tools; a schema
|
||||
@@ -65,17 +69,30 @@ 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.
|
||||
* rejected at start otherwise. In-process backends apply it as a scoped
|
||||
* `tools.restrict()` in the child's creation window: the named tools vanish
|
||||
* 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
|
||||
* `deployment:persona` section on the child, SHADOWING the deployment's
|
||||
* persona for this child alone — same template semantics as the deployment
|
||||
* persona (strict `{{…}}` interpolation against the registered variables).
|
||||
*/
|
||||
readonly persona?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,7 +104,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'
|
||||
@@ -105,29 +122,31 @@ 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, present IFF the request carried an `outputSchema`
|
||||
* AND the provider honored it. Shape is validated against the request schema
|
||||
* by the provider; `unknown` here because the seam is schema-agnostic.
|
||||
* The structured result after a requested `outputSchema` was successfully
|
||||
* satisfied. Requesting a schema does not guarantee presence: a provider can
|
||||
* end with `stopReason: 'error'` when the child fails or finishes without a
|
||||
* 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 (use `ctx.agents.get(id)` to reach the live child). */
|
||||
/** The child agent's id (local in-process runs are already published in `ctx.agents`; remote transports need not publish locally). */
|
||||
readonly id: AgentId
|
||||
/**
|
||||
* Resolves with the child's terminal {@link SubagentResult} when the run
|
||||
@@ -137,12 +156,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>
|
||||
/**
|
||||
@@ -154,14 +171,16 @@ 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>
|
||||
}
|
||||
|
||||
/**
|
||||
* A subagent backend: one transport for running a child agent (in-process
|
||||
* spawn/fork, ACP to another process, …). Implementations register under a
|
||||
* unique name via {@link SubagentService.registerProvider}; multiple providers
|
||||
* coexist in one context (unlike the single-implementation bash seam).
|
||||
* coexist in one context (unlike the single-implementation bash seam). The
|
||||
* 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`). */
|
||||
@@ -169,32 +188,31 @@ 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 a child run. The service has already validated that every requested
|
||||
* start-time capability is supported, so an implementation may assume e.g.
|
||||
* `request.maxDepth` is honorable when present.
|
||||
* 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. 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.
|
||||
*
|
||||
* MUST be safe to call concurrently for independent runs: the `subagent` tool
|
||||
* is parallel-safe, so a parent step may issue several subagent calls at once,
|
||||
* each invoking `start()` before an earlier run settles. An implementation
|
||||
* reads the parent SYNCHRONOUSLY at start (a snapshot — never mutating or
|
||||
* re-reading it during the run) so concurrent starts inside the parent's one
|
||||
* open step all observe the same stable state; the fork backend seeds each
|
||||
* child from the parent's completed-turn prefix, which the open in-flight turn
|
||||
* cannot change. A provider backed by a limited resource may queue internally,
|
||||
* apply its own capacity cap, or return a typed failure for the affected run —
|
||||
* but it must NOT require the parent loop to serialize every `subagent` call.
|
||||
* @param request - the start request (prompt, parent, and any start-time options).
|
||||
* @returns the started {@link SubagentRun}.
|
||||
* snapshots the parent at start and must not require the parent loop to
|
||||
* serialize every `subagent` call; a resource-limited provider queues or
|
||||
* rejects internally.
|
||||
*/
|
||||
start(request: SubagentStartRequest): SubagentRun
|
||||
start(request: SubagentStartRequest): Promise<SubagentRun>
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { AgentId, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import { carrierKeyOf } from '@deepseek-ai/dsh-scope'
|
||||
import SubagentService, {
|
||||
SubagentError,
|
||||
assertSubagentMaxDepth,
|
||||
type SubagentCapabilities,
|
||||
type SubagentProvider,
|
||||
type SubagentResult,
|
||||
@@ -11,403 +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 }
|
||||
const NO_CAPS: SubagentCapabilities = { outputSchema: false, depthLimit: false, toolFilter: 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 }
|
||||
|
||||
function baseRequest(overrides: Partial<SubagentStartRequest> = {}): SubagentStartRequest {
|
||||
return {
|
||||
prompt: [{ type: 'text', text: 'do a thing' }],
|
||||
parent: fakeParent(),
|
||||
signal: new AbortController().signal,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** A scripted provider whose run settles immediately with a fixed result. */
|
||||
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}`),
|
||||
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([])
|
||||
|
||||
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). Both are
|
||||
// prevented by per-listener containment.
|
||||
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(() => { 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')).toBe(provider)
|
||||
|
||||
const run = ctx.subagents.start('alpha', baseRequest())
|
||||
expect(provider.startCount).toBe(1)
|
||||
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)
|
||||
|
||||
it('lets multiple providers coexist (the defining requirement)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
ctx.subagents.registerProvider(new StubProvider('spawn'))
|
||||
ctx.subagents.registerProvider(new StubProvider('acp'))
|
||||
|
||||
expect(ctx.subagents.list()).toEqual(['spawn', 'acp'])
|
||||
expect(ctx.subagents.getProvider('spawn')).toBeDefined()
|
||||
expect(ctx.subagents.getProvider('acp')).toBeDefined()
|
||||
})
|
||||
|
||||
it('throws NO_PROVIDER when starting on an unregistered name', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
try {
|
||||
ctx.subagents.start('missing', baseRequest())
|
||||
expect.fail('expected NO_PROVIDER')
|
||||
} catch (error: unknown) {
|
||||
expect(error).toBeInstanceOf(SubagentError)
|
||||
expect((error as SubagentError).code).toBe('NO_PROVIDER')
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects duplicate provider names with DUPLICATE_PROVIDER', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
ctx.subagents.registerProvider(new StubProvider('dup'))
|
||||
try {
|
||||
ctx.subagents.registerProvider(new StubProvider('dup'))
|
||||
expect.fail('expected DUPLICATE_PROVIDER')
|
||||
} catch (error: unknown) {
|
||||
expect(error).toBeInstanceOf(SubagentError)
|
||||
expect((error as SubagentError).code).toBe('DUPLICATE_PROVIDER')
|
||||
}
|
||||
})
|
||||
|
||||
it('unregisters a provider when its owning fiber is disposed (HMR safety)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
inner.subagents.registerProvider(new StubProvider('scoped'))
|
||||
}, { inject: ['subagents'] }))
|
||||
expect(ctx.subagents.list()).toEqual(['scoped'])
|
||||
|
||||
await fiber.dispose()
|
||||
expect(ctx.subagents.list()).toEqual([])
|
||||
})
|
||||
|
||||
it('re-registers a name after its prior registration is disposed (not wedged)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
|
||||
const dispose = ctx.subagents.registerProvider(new StubProvider('reuse'))
|
||||
expect(ctx.subagents.list()).toEqual(['reuse'])
|
||||
dispose()
|
||||
expect(ctx.subagents.list()).toEqual([])
|
||||
|
||||
const disposeAgain = ctx.subagents.registerProvider(new StubProvider('reuse'))
|
||||
expect(ctx.subagents.list()).toEqual(['reuse'])
|
||||
disposeAgain()
|
||||
expect(ctx.subagents.list()).toEqual([])
|
||||
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'))
|
||||
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' })
|
||||
})
|
||||
|
||||
const started = vi.fn()
|
||||
const ended = vi.fn()
|
||||
ctx.on('subagent/start', started)
|
||||
ctx.on('subagent/end', ended)
|
||||
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)
|
||||
})
|
||||
|
||||
const run = ctx.subagents.start('events', baseRequest())
|
||||
expect(started).toHaveBeenCalledWith(expect.objectContaining({ provider: 'events', id: run.id }))
|
||||
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()
|
||||
})
|
||||
|
||||
await run.result
|
||||
// `subagent/end` fires from a `.then` on the result — let the microtask run.
|
||||
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>()
|
||||
subagents.registerProvider({
|
||||
name: 'deferred',
|
||||
capabilities: NO_CAPS,
|
||||
inheritsParentContext: false,
|
||||
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(ended).toHaveBeenCalledWith(expect.objectContaining({ provider: 'events', id: run.id, stopReason: 'completed' }))
|
||||
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('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' },
|
||||
))
|
||||
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)
|
||||
await expect(subagents.start('failed', baseRequest())).rejects.toThrow('setup rolled back')
|
||||
expect(lifecycle).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
const started = vi.fn()
|
||||
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',
|
||||
})
|
||||
subagents.registerProvider(completed)
|
||||
const ended = vi.fn()
|
||||
ctx.on('subagent/start', started)
|
||||
ctx.on('subagent/end', ended)
|
||||
|
||||
const run = ctx.subagents.start('enriched', baseRequest())
|
||||
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. BEFORE the caller's own `await run.result`
|
||||
// continuation. If the event shared the result.output reference, a mutating
|
||||
// listener would change the SubagentResult the caller consumes. The service
|
||||
// deep-clones output onto the event, so the listener mutates only its copy.
|
||||
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'),
|
||||
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. An uncloneable output (here a content block carrying a
|
||||
// function) would otherwise throw and become an unhandled rejection on the
|
||||
// detached `.then`. The handler must instead log and emit the event WITHOUT
|
||||
// lastAssistantMessage, still carrying the real stopReason.
|
||||
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'),
|
||||
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
|
||||
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'),
|
||||
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. Per-listener containment means the second
|
||||
// must STILL run (a single try/catch around ctx.emit would let the first
|
||||
// throw halt the dispatch and starve the second — the round-2 regression).
|
||||
const second = vi.fn()
|
||||
ctx.on('subagent/start', () => { throw new Error('bad start listener') })
|
||||
ctx.on('subagent/start', second)
|
||||
|
||||
const run = ctx.subagents.start('contain', baseRequest())
|
||||
expect(run.id).toBeDefined()
|
||||
expect(second).toHaveBeenCalledWith(expect.objectContaining({ provider: 'contain', id: run.id }))
|
||||
await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' })
|
||||
})
|
||||
|
||||
it('contains a throwing subagent/end listener 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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../../core/scope"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,27 +1,32 @@
|
||||
# @deepseek-ai/dsh-tool-subagent
|
||||
|
||||
The model-facing `subagent` tool: delegate a self-contained task to a child agent and return its final output. Pure schema + lifecycle shaping over the [`ctx.subagents`](../subagent/README.md) provider registry — an in-process, ACP, or future A2A backend swaps in without changing what the model sees.
|
||||
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 selection is config, not model-facing
|
||||
## Provider selection
|
||||
|
||||
This plugin binds to **exactly one** provider (`Config.provider`). The model sees only `{ description, prompt }` — there is no provider/type parameter in the schema. To expose more than one transport, load the plugin more than once, each bound to a different provider **and a distinct `toolName`** (the tool registry rejects a duplicate name, so a second load that kept the default `subagent` name would throw). Keeping selection in config (not the schema) is the deliberate split: the *service* holds a multi-provider registry; the *tool* picks one.
|
||||
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 description states the provider's context contract
|
||||
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.
|
||||
|
||||
The tool description and the `prompt` parameter description are DERIVED from the bound provider's `inheritsParentContext` (`providerWording`): a fresh-context provider (spawn, ACP) gets the standalone-prompt wording ("it does not see this conversation"), an inheriting provider (fork) tells the model the child already sees the conversation's completed turns and its prompt should state only what is new. Because the description is fixed at tool registration, the tool **mirrors the provider's lifecycle** (`subagent/provider-added`/`-removed`): it registers when the bound provider is (or becomes) available and unregisters when the provider goes away — no load-order requirement (the cordis Loader starts sibling entries concurrently, so "listed first" never guaranteed "registered first"), and an HMR reload of the backend re-derives the wording from the fresh provider. While the provider is absent the tool simply does not exist (a `ctx.logger` note records the wait; a typo'd provider name shows up as a tool that never materializes).
|
||||
## Lifecycle
|
||||
|
||||
| Config key | Meaning |
|
||||
`execute` passes the tool execution's abort signal when present, otherwise supplies an inert signal to satisfy the required `SubagentStartRequest.signal`. It awaits `ctx.subagents.start(...)`, then awaits `run.result` inside a `try/finally` that always calls `run.dispose()`. The selected 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) | The `ctx.subagents` provider name to start runs on (`spawn`, `fork`, `acp`, …). |
|
||||
| `toolName` | The model-facing tool name to register (default `subagent`). Set a distinct value per load when exposing multiple providers, e.g. `subagent` + `subagent_acp`. |
|
||||
| `agentOptions` | Default per-child `{ model? }` applied to every spawned child. (No per-child persona: the deployment persona is a context-wide section every agent shares.) |
|
||||
|
||||
## Lifecycle (synchronous collect)
|
||||
|
||||
`execute` starts a run on the configured provider and **awaits `run.result` inside a `try/finally` that always `dispose()`s the run** — the owned child agent/session is torn down on every path (success, error, abort), never leaked. The tool's abort signal (`exec.signal`) is bridged to `run.cancel()`. A non-`completed` stop reason (aborted/error/max-tokens/refusal) maps to an `isError` tool result rather than returning partial output as success.
|
||||
|
||||
Background / poll collection is deferred (see the [RFC](../../../docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md)); this cut blocks the parent turn until the child finishes.
|
||||
| `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. |
|
||||
|
||||
## Concurrency
|
||||
|
||||
The tool declares `isConcurrencySafe: () => true`: each call starts an independent child run and returns only its final answer, touching no parent-agent state, and `SubagentProvider.start()` is contractually concurrent-safe for independent runs (see [subagent/](../README.md)). So the agent loop may run several `subagent` calls from one assistant step in parallel, and the tool description tells the model it may issue independent tasks together when their work scopes do not overlap. The subagent tool stays synchronous (one result = the child's final answer); background spawning + later collection is separate future work.
|
||||
|
||||
`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).
|
||||
|
||||
@@ -11,10 +11,12 @@
|
||||
* — 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 context contract
|
||||
* ({@link providerWording}): a fresh-context provider (spawn, ACP) gets the
|
||||
* standalone-prompt wording, an inheriting provider (fork) tells the model the
|
||||
* child already sees the conversation's completed turns. The tool MIRRORS the
|
||||
* 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
|
||||
@@ -35,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'
|
||||
@@ -54,19 +57,69 @@ export interface Config {
|
||||
toolName?: string
|
||||
/**
|
||||
* Default per-child agent options (model) applied to every spawned child.
|
||||
* Omitted fields fall back to the child loop's own defaults. There is no
|
||||
* per-child persona: the deployment persona (the system-prompt plugin's
|
||||
* `persona` config) is a context-wide section every agent shares.
|
||||
* Omitted fields fall back to the child loop's own defaults.
|
||||
*/
|
||||
agentOptions?: AgentOptions
|
||||
/**
|
||||
* Per-child persona applied to every child this tool spawns: a scoped
|
||||
* `deployment:persona` section shadowing the deployment's persona for the
|
||||
* child alone. Requires the bound provider's `persona` capability
|
||||
* (in-process backends support it; a request against one that doesn't is
|
||||
* rejected at start). Omitted ⇒ the child renders the deployment persona.
|
||||
*/
|
||||
persona?: string
|
||||
/**
|
||||
* Tool scoping applied to every child this tool spawns (see
|
||||
* `SubagentStartRequest.toolFilter`): the named global tools vanish from
|
||||
* the child's prompt AND refuse to execute. Requires the provider's
|
||||
* `toolFilter` capability. Unknown names fail the spawn loudly. Note the
|
||||
* child otherwise sees every global tool — including this delegation tool
|
||||
* itself; `deny`-listing it (or setting `maxDepth`) is how a deployment
|
||||
* bounds recursion.
|
||||
*/
|
||||
toolFilter?: {
|
||||
/** Global tool names the child keeps; everything else is removed. */
|
||||
allow?: string[]
|
||||
/** Global tool names removed from the child. */
|
||||
deny?: string[]
|
||||
}
|
||||
/**
|
||||
* 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. 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
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
provider: z.string().required(),
|
||||
toolName: z.string().default('subagent'),
|
||||
// Omitted-object discipline (see the toolFilter note below): without the
|
||||
// forced default an omitted `agentOptions` materializes `{}`, which reads as
|
||||
// present — the request would carry `agentOptions: {}` and the presence
|
||||
// check in execute() could never be false through config.
|
||||
agentOptions: 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. 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.natural().max(Number.MAX_SAFE_INTEGER),
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -103,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 '
|
||||
@@ -141,6 +197,15 @@ 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
|
||||
@@ -185,21 +250,14 @@ export function apply(ctx: Context, config: Config): void {
|
||||
const request: SubagentStartRequest = {
|
||||
prompt: [{ type: 'text', text: args.prompt }],
|
||||
parent,
|
||||
...exec.signal ? { signal: exec.signal } : {},
|
||||
...config.agentOptions ? { agentOptions: config.agentOptions } : {},
|
||||
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. Cancel explicitly in that case — the bridge must honor an
|
||||
// already-aborted signal, not lean on each provider re-checking it.
|
||||
if (exec.signal?.aborted) run.cancel('parent step aborted')
|
||||
const run: SubagentRun = await ctx.subagents.start(config.provider, request)
|
||||
|
||||
try {
|
||||
const result = await run.result
|
||||
@@ -211,7 +269,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()
|
||||
}
|
||||
|
||||
@@ -120,12 +120,11 @@ describe('dsh-tool-subagent', () => {
|
||||
await ctx.plugin(SubagentService)
|
||||
ctx.subagents.registerProvider({
|
||||
name: 'weird',
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
|
||||
inheritsParentContext: false,
|
||||
start: () => ({
|
||||
start: async () => ({
|
||||
id: AgentId('weird-child'),
|
||||
result: Promise.resolve({ output: [{ type: 'text', text: 'partial' }], stopReason: 'frobnicated' as never }),
|
||||
cancel() {},
|
||||
dispose: async () => {},
|
||||
}),
|
||||
})
|
||||
@@ -146,14 +145,13 @@ describe('dsh-tool-subagent', () => {
|
||||
await ctx.plugin(SubagentService)
|
||||
ctx.subagents.registerProvider({
|
||||
name: 'capture',
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
|
||||
inheritsParentContext: false,
|
||||
start: (request) => {
|
||||
start: async (request) => {
|
||||
seen = request
|
||||
return {
|
||||
id: AgentId('capture-child'),
|
||||
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
||||
cancel() {},
|
||||
dispose: async () => {},
|
||||
}
|
||||
},
|
||||
@@ -176,14 +174,13 @@ describe('dsh-tool-subagent', () => {
|
||||
await ctx.plugin(SubagentService)
|
||||
ctx.subagents.registerProvider({
|
||||
name: 'bare',
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
|
||||
inheritsParentContext: false,
|
||||
start: (request) => {
|
||||
start: async (request) => {
|
||||
seen = request
|
||||
return {
|
||||
id: AgentId('bare-child'),
|
||||
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
||||
cancel() {},
|
||||
dispose: async () => {},
|
||||
}
|
||||
},
|
||||
@@ -226,7 +223,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')
|
||||
|
||||
@@ -234,7 +231,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')
|
||||
@@ -279,7 +276,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')
|
||||
@@ -287,7 +284,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')
|
||||
@@ -306,12 +303,11 @@ describe('dsh-tool-subagent', () => {
|
||||
await ctx.plugin(SubagentService)
|
||||
ctx.subagents.registerProvider({
|
||||
name: 'spy',
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
|
||||
inheritsParentContext: false,
|
||||
start: () => ({
|
||||
start: async () => ({
|
||||
id: AgentId('spy-child'),
|
||||
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
||||
cancel() {},
|
||||
dispose: async () => void disposed(),
|
||||
}),
|
||||
})
|
||||
@@ -329,12 +325,11 @@ describe('dsh-tool-subagent', () => {
|
||||
await ctx.plugin(SubagentService)
|
||||
ctx.subagents.registerProvider({
|
||||
name: 'spy',
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
|
||||
inheritsParentContext: false,
|
||||
start: () => ({
|
||||
start: async () => ({
|
||||
id: AgentId('spy-child'),
|
||||
result: Promise.resolve({ output: [], stopReason: 'error' as const }),
|
||||
cancel() {},
|
||||
dispose: async () => void disposed(),
|
||||
}),
|
||||
})
|
||||
@@ -345,7 +340,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)
|
||||
@@ -353,18 +348,19 @@ describe('dsh-tool-subagent', () => {
|
||||
await ctx.plugin(SubagentService)
|
||||
ctx.subagents.registerProvider({
|
||||
name: 'spy',
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
|
||||
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'),
|
||||
result,
|
||||
cancel: () => {
|
||||
cancelled()
|
||||
resolveResult({ output: [], stopReason: 'aborted' })
|
||||
},
|
||||
dispose: async () => {},
|
||||
}
|
||||
},
|
||||
@@ -373,12 +369,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). A few
|
||||
// microtask turns let execute() reach `addEventListener('abort')`, so this
|
||||
// exercises the LIVE onAbort bridge — distinct from the already-aborted
|
||||
// sync path the next test covers.
|
||||
// Let provider.start install its listener before aborting.
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
controller.abort()
|
||||
@@ -387,33 +378,19 @@ describe('dsh-tool-subagent', () => {
|
||||
expect(result.isError).toBe(true)
|
||||
})
|
||||
|
||||
it('cancels the run when the tool signal is ALREADY aborted before execute (no missed abort)', async () => {
|
||||
// `addEventListener('abort')` does not fire for a signal already aborted
|
||||
// before the listener is added, so a step cancelled before the tool ran
|
||||
// would never reach the child unless the bridge re-checks `signal.aborted`.
|
||||
// A provider that leans only on the abort EVENT (this spy never inspects
|
||||
// request.signal) proves the bridge itself must cancel.
|
||||
const cancelled = vi.fn()
|
||||
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)
|
||||
await ctx.plugin(SubagentService)
|
||||
ctx.subagents.registerProvider({
|
||||
name: 'spy',
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
|
||||
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'),
|
||||
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' })
|
||||
@@ -421,7 +398,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)
|
||||
})
|
||||
|
||||
@@ -460,4 +437,130 @@ describe('dsh-tool-subagent', () => {
|
||||
expect(typeof unwrapped.apply).toBe('function')
|
||||
expect(unwrapped.Config).toBeDefined()
|
||||
})
|
||||
|
||||
it('passes persona/toolFilter/maxDepth config through to the start request', async () => {
|
||||
let seen: { persona?: string; toolFilter?: unknown; maxDepth?: number } | undefined
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(SubagentService)
|
||||
ctx.subagents.registerProvider({
|
||||
name: 'capture2',
|
||||
capabilities: { outputSchema: false, depthLimit: true, toolFilter: true, persona: true },
|
||||
inheritsParentContext: false,
|
||||
start: async (request) => {
|
||||
seen = request
|
||||
return {
|
||||
id: AgentId('capture2-child'),
|
||||
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
||||
dispose: async () => {},
|
||||
}
|
||||
},
|
||||
})
|
||||
await ctx.plugin(tool, {
|
||||
provider: 'capture2',
|
||||
persona: 'You are the child.',
|
||||
toolFilter: { deny: ['subagent'] },
|
||||
maxDepth: 2,
|
||||
})
|
||||
|
||||
await callSubagent(ctx, { description: 'd', prompt: 'p' })
|
||||
expect(seen?.persona).toBe('You are the child.')
|
||||
expect(seen?.toolFilter).toMatchObject({ deny: ['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?: { readonly allow?: readonly string[]; readonly deny?: readonly string[] } } | undefined
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(SubagentService)
|
||||
ctx.subagents.registerProvider({
|
||||
name: 'capture3',
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: true, persona: false },
|
||||
inheritsParentContext: false,
|
||||
start: async (request) => {
|
||||
seen = request
|
||||
return {
|
||||
id: AgentId('capture3-child'),
|
||||
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
||||
dispose: async () => {},
|
||||
}
|
||||
},
|
||||
})
|
||||
await ctx.plugin(tool, { provider: 'capture3', toolFilter: { deny: ['subagent'] } })
|
||||
await callSubagent(ctx, { description: 'd', prompt: 'p' })
|
||||
expect(seen?.toolFilter).toEqual({ deny: ['subagent'] })
|
||||
expect(seen?.toolFilter).not.toHaveProperty('allow')
|
||||
})
|
||||
|
||||
it('an omitted agentOptions does not materialize an empty object onto the request', async () => {
|
||||
// Same schemastery trap as toolFilter, adjacent field: an omitted
|
||||
// `agentOptions` config key materializes `{}` without the forced default,
|
||||
// which reads as present and puts a dishonest `agentOptions: {}` on every
|
||||
// start request.
|
||||
let seen: { agentOptions?: unknown } | undefined
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(SubagentService)
|
||||
ctx.subagents.registerProvider({
|
||||
name: 'capture4',
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
|
||||
inheritsParentContext: false,
|
||||
start: async (request) => {
|
||||
seen = request
|
||||
return {
|
||||
id: AgentId('capture4-child'),
|
||||
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
||||
dispose: async () => {},
|
||||
}
|
||||
},
|
||||
})
|
||||
await ctx.plugin(tool, { provider: 'capture4' })
|
||||
await callSubagent(ctx, { description: 'd', prompt: 'p' })
|
||||
expect(seen).toBeDefined()
|
||||
expect(seen).not.toHaveProperty('agentOptions')
|
||||
})
|
||||
|
||||
it('an explicit empty toolFilter fails at plugin load, not at first delegation', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(SubagentService)
|
||||
ctx.subagents.registerProvider({
|
||||
name: 'p',
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: true, persona: false },
|
||||
inheritsParentContext: false,
|
||||
start: () => { throw new Error('unreachable') },
|
||||
})
|
||||
const fiber = ctx.plugin(tool, { provider: 'p', toolFilter: {} })
|
||||
await expect(fiber).rejects.toThrow(/names neither `allow` nor `deny`/)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user