From bb3f6bd7367c2b197e8d1f1740951efe86314860 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 12 Jul 2026 22:41:59 +0800 Subject: [PATCH] refactor(subagent): unify async readiness and cancellation --- ...-subagent-persona-tool-filter-and-depth.md | 92 ++ packages/subagent/subagent-acp/README.md | 65 +- packages/subagent/subagent-acp/src/run.ts | 132 +- .../subagent-acp/tests/mock-acp-server.ts | 3 +- .../subagent-acp/tests/subagent-acp.e2e.ts | 6 +- .../subagent-acp/tests/subagent-acp.spec.ts | 121 +- packages/subagent/subagent-fork/README.md | 18 +- packages/subagent/subagent-fork/src/index.ts | 6 +- .../tests/multi-subagent.spec.ts | 10 +- .../subagent-fork/tests/subagent-fork.spec.ts | 20 +- .../subagent/subagent-inprocess/README.md | 48 +- .../subagent/subagent-inprocess/src/index.ts | 418 ++--- .../subagent-inprocess/src/structured.ts | 28 +- .../tests/structured.spec.ts | 120 +- .../tests/subagent-inprocess.spec.ts | 481 ++---- packages/subagent/subagent-spawn/README.md | 10 +- packages/subagent/subagent-spawn/src/index.ts | 6 +- .../tests/subagent-spawn.spec.ts | 183 +-- packages/subagent/subagent/README.md | 67 +- packages/subagent/subagent/package.json | 2 - packages/subagent/subagent/src/index.ts | 614 +------ packages/subagent/subagent/src/types.ts | 80 +- .../subagent/subagent/tests/service.spec.ts | 1427 ++--------------- packages/subagent/subagent/tsconfig.json | 3 - packages/subagent/tool-subagent/README.md | 38 +- packages/subagent/tool-subagent/src/index.ts | 15 +- .../tool-subagent/tests/tool-subagent.spec.ts | 82 +- packages/support/subagent-mock/README.md | 6 +- packages/support/subagent-mock/src/index.ts | 46 +- .../subagent-mock/tests/subagent-mock.spec.ts | 19 +- packages/ui/acp/README.md | 4 +- packages/ui/acp/src/index.ts | 116 +- packages/ui/acp/tests/edges.spec.ts | 2 +- packages/ui/acp/tests/turns.spec.ts | 33 +- .../workflow/workflow-workerthread/README.md | 85 +- .../workflow-workerthread/src/host.ts | 305 ++-- .../workflow-workerthread/src/index.ts | 6 +- .../workflow-workerthread/src/protocol.ts | 8 +- .../workflow-workerthread/src/runtime.ts | 47 +- .../workflow-workerthread/src/session.ts | 26 +- .../workflow-workerthread/src/types.ts | 4 +- .../tests/integration.spec.ts | 2 +- .../tests/session.spec.ts | 36 +- .../tests/workflow-workerthread.spec.ts | 531 +----- packages/workflow/workflow/README.md | 46 +- packages/workflow/workflow/src/index.ts | 41 +- packages/workflow/workflow/src/types.ts | 2 +- .../workflow/workflow/tests/workflow.spec.ts | 25 +- pnpm-lock.yaml | 12 - 49 files changed, 1350 insertions(+), 4147 deletions(-) create mode 100644 docs/rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md diff --git a/docs/rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md b/docs/rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md new file mode 100644 index 0000000000..9d657f7bd4 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md @@ -0,0 +1,92 @@ +# RFC: Configure subagent persona, tool visibility, and depth + +Status: implemented + +## Problem + +A reusable subagent provider answers how to run a child, but different delegation tools need different child behavior. One deployment may want a reviewer persona, a research-only tool set, or a hard recursion bound without creating a new provider for every combination. + +These controls affect the child's first model request and therefore cannot be installed after the child is visible. They also need honest provider support: an ACP backend cannot silently accept an in-process-only tool filter, and a filter must not be described as a security boundary when every plugin runs in the same trusted process. + +## Decision + +Subagent starts have three independent composition controls: `persona`, `toolFilter`, and `maxDepth`. A provider advertises support for each control, the service rejects unsupported requests before starting a run, and an in-process provider installs the requested composition while the child is still unpublished. + +The controls answer different questions: + +| Control | Question | Result | +|---|---|---| +| `persona` | What role instructions replace the deployment persona for this child? | A child-local prompt section shadows `deployment:persona` | +| `toolFilter` | Which deployment-global tools enter this child's visible tool view? | A scoped restriction filters globals before child-local tools are added | +| `maxDepth` | How deep may this delegation tree grow? | A start whose child depth exceeds the absolute cap is rejected | + +`dsh-tool-subagent` exposes the controls as plugin configuration and copies them into each request it creates. Direct `SubagentService` callers may choose them per request. The provider capability descriptor remains the source of truth for whether a backend can honor each field. + +### Persona is a scoped shadow + +The persona control changes one child without changing deployment-wide prompt assembly. During unpublished setup, an in-process provider registers a child-scoped section named `deployment:persona`; ordinary most-specific-wins resolution replaces the global section only in that child's assemblies. + +The value has the same strict template semantics as the deployment persona. Omitting it inherits the deployment section through the global layer; an explicit empty string shadows the global persona with an empty section. Parent and sibling personas never enter the child's flat scope. + +This uses the normal system-prompt registration mechanism rather than a second persona channel. The first prompt therefore sees the same named contribution that later prompts and prompt-inspection tools see. + +### Tool filtering is one live global-view rule + +The tool filter controls visibility and executable lookup together. An in-process provider installs `ToolRegistry.restrict()` in the child's scope before publication, and the registry's single resolver applies the same result to prompt schemas, lookup, execution, and Code Mode SDK generation. + +Resolution follows these rules: + +1. Each restriction applies `allow` before `deny` to the live deployment-global tool registry. +2. Multiple restrictions intersect, so every installed restriction must admit a global tool. +3. Child-scoped tools are added after global filtering and may shadow an admitted global tool. +4. Reserved `run_code` presentation and other scope-local protocol contributions are outside the global filter. + +Configuration fails loudly when a filter is empty or names a tool that is unknown, scope-local, or reserved at setup time. This catches misspellings and prevents configuration from appearing effective when it cannot affect the named entry. + +The global registry remains live. A deny-only filter admits a later global name unless it explicitly denies that name; an allow-list excludes a later global name unless it explicitly allows that name. Removing a global tool removes it from every resolved view. These semantics preserve hot registration while making the difference between allow and deny explicit. + +### Depth is an absolute tree cap + +The depth limit bounds recursive delegation independently of tool visibility. A top-level agent has depth zero; an in-process child has its parent's validated depth plus one. `maxDepth` is an absolute non-negative safe integer, and a start rejects before child ownership begins when the derived child depth is greater than the cap. + +Every public entry validates the domain rather than relying on one model-facing configuration path. Negative values, fractions, negative zero, non-finite values, unsafe integers, malformed stored parent depth, and derived overflow all reject. Omitting the cap leaves depth unbounded by this mechanism. + +A deployment can combine depth and filtering. For example, it may keep the delegation tool visible at depth one but set `maxDepth: 1`, or deny the delegation tool entirely in children. Neither choice changes the provider's conversation-history behavior. + +### Capability gating keeps providers honest + +Capabilities separate a requested feature from a provider implementation. `SubagentCapabilities` advertises `persona`, `toolFilter`, and `depthLimit`; `SubagentService.start()` checks every present request field against those flags before calling the provider. + +This lets spawn and fork providers share the in-process implementation while external providers advertise only what they can enforce. A request never degrades silently: selecting an unsupported control produces `UNSUPPORTED_CAPABILITY`, and no run or lifecycle event exists. + +### Unpublished setup makes the first request correct + +All child-local composition is complete before the child becomes observable. The in-process provider supplies one setup callback to agent creation; that callback installs persona, tool restriction, and structured-output contributions in the child's scope. Only after setup succeeds does creation publish the session and agent and allow the driver to start. + +A setup failure rolls back the private child. No observer can acquire a child whose first prompt used the deployment persona or unfiltered tool set and whose later prompts use the requested configuration. + +## Visibility is not authority + +These controls compose trusted same-process behavior; they do not authorize it. `toolFilter` changes the child view resolved by the tool registry, but it does not create a parent-to-child grant lattice, require a child to be a subset of its parent, sandbox plugins, or prevent code with another Cordis context from calling services directly. + +In particular, a child-local tool is added after the global filter and may be absent from the parent's view. A deny-only child also sees later global tools not named by the deny-list. Those are deliberate live-composition semantics, not non-escalation guarantees. + +A security design would need a separate authority representation, propagation rule, and execution-time enforcement point. Creation-time grant snapshots, parent-subset grants, explicit future-grant APIs, and generic capability/output/termination tags are outside this feature. + +## Alternatives considered + +**Create one provider per persona or tool set.** This multiplies providers that share the same transport and lifecycle implementation, makes dynamic deployment configuration awkward, and still needs a recursion mechanism. Providers remain about execution transport; requests carry per-child composition. + +**Copy the parent's complete tool view.** Registration scope is flat by design, and lifetime ownership does not imply visibility inheritance. Copying a resolved view would also freeze dynamic global registrations and conflate composition with authority without defining either contract fully. + +**Snapshot allowed global tools at child creation.** A frozen allow-set makes future registration uniformly unavailable, but it changes hot-registration semantics and starts an authorization design. The implemented filter stays a live registry predicate and documents allow-versus-deny behavior directly. + +**Hide only tool schemas.** Presentation-only filtering lets the model execute a tool that the prompt says does not exist through Code Mode or a forged call. One resolver governs both presentation and execution instead. + +**Use only tool filtering to stop recursion.** Removing the delegation tool is useful but provider-specific and does not protect direct service callers or alternate delegation tools. Absolute depth is an independent structural bound. + +## Consequences + +Contributors can configure child role, visible global tools, and recursion without defining new providers. Capability checks fail before ownership starts, unpublished setup makes the first request consistent, and one tool resolver prevents presentation/execution drift. + +The cost is that deployments must understand live allow/deny behavior and the distinction between visibility and authority. Provider authors must advertise each supported control accurately, and in-process providers must install every requested contribution before publication. The controls deliberately do not solve security confinement or parent-to-child non-escalation. diff --git a/packages/subagent/subagent-acp/README.md b/packages/subagent/subagent-acp/README.md index cb51986915..c949685fcc 100644 --- a/packages/subagent/subagent-acp/README.md +++ b/packages/subagent/subagent-acp/README.md @@ -1,32 +1,31 @@ # @deepseek-ai/dsh-subagent-acp -The out-of-process **ACP subagent backend**: runs each child agent in a spawned subprocess, driven over the [Agent Client Protocol](https://github.com/zed-industries/agent-client-protocol) (ACP) as the *client*. Registers a `SubagentProvider` on `ctx.subagents` (the [subagent seam](../subagent)), alongside the in-process [`-spawn`](../subagent-spawn)/[`-fork`](../subagent-fork) backends — multiple backends coexist by name. +The ACP provider runs each subagent in a fresh subprocess and drives it as an Agent Client Protocol client. It is the out-of-process alternative to spawn and fork: the child has its own runtime, session, model configuration, and tools. -It is the direction-inverted twin of the server-side bridge in [`@deepseek-ai/dsh-acp`](../../ui/acp): that package is the ACP *agent* (it answers `initialize`/`newSession`/`prompt`); this one is the ACP *client* (it *calls* them and implements the `sessionUpdate`/`requestPermission` callbacks). Point the configured command at the `acp-agent` example to "talk to our own process". +## Start and ownership -## What it does +`start(request)` performs `spawn` → ACP `initialize` → `newSession` before it fulfills. Fulfillment therefore means a remote session is ready and ownership has transferred to the caller. A spawn, initialization, new-session, or pre-publication cancellation failure rejects only after the subprocess has been reaped. -`start(request)` spawns the configured command, wraps its stdio in an ACP `ClientSideConnection`, and drives one session: `initialize` → `newSession` → `prompt`. `run.started` resolves after `newSession` publishes the remote session and rejects when initialization fails or cancellation wins first; the service emits no start/end pair for a child that never became live. The child's streamed `agent_message_chunk` text becomes the `SubagentResult.output`; the prompt's terminal `StopReason` maps to the stop reason. `dispose()` kills the subprocess and awaits its exit. +After publication, the provider sends the prompt and collects streamed `agent_message_chunk` text into `SubagentResult.output`. A prompt/transport failure resolves with `stopReason: 'error'`, or `aborted` when the required request signal or disposal requested cancellation. -**Fresh process per run.** Each `start` spawns a new child, runs exactly one ACP session, and disposes it. Persistent-process pooling is a future optimization (see the RFC). +`dispose()` is idempotent. It removes the signal listener, requests ACP cancellation when possible, closes stdin, waits `disposeEofGraceMs`, escalates to SIGTERM, waits `disposeGraceMs`, and finally uses SIGKILL if necessary. Every run uses a fresh process; process pooling is not implemented. -Unlike the in-process backends, the child does NOT share this cordis context — it is a separate process with its own session, model client, and tools. So this backend: -- injects only `subagents` (no `ctx.agents`); -- advertises NO start-time capabilities (an out-of-process child can't enforce the parent's depth/tool-filter); -- ignores `request.parent`. +## Capabilities and context -## Config +ACP advertises no start-time capabilities because this process cannot enforce the remote child's depth, tool filter, persona, or structured-output runtime. It also reports `inheritsParentContext: false`: the remote session starts fresh and ignores `request.parent` beyond the seam's required attribution field. -| Key | Type | Default | Notes | -|---|---|---|---| -| `providerName` | string | `acp` | Registry name on `ctx.subagents`. | -| `command` | string | — (required) | The executable to spawn for each run (the child ACP agent). | -| `args` | string[] | `[]` | Arguments passed to `command`. | -| `cwd` | string | process cwd | Working directory for the child process and its ACP session. | -| `permission` | `'allow' \| 'reject'` | `reject` | How to auto-answer the child's `session/request_permission` prompts. `reject` declines every prompt (answer `cancelled`); `allow` approves via the first allow-shaped option. The first cut surfaces no prompt to a human. | -| `env` | Record | `{}` | 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`. diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index 410f7ad7bf..54a5641d96 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -180,35 +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 { const id = AgentId(randomUUID()) - // A request already aborted before it starts never spawns the child at all — - // return an inert run that settled `aborted`, rather than launching the - // configured binary just to tear it down. `dispose`/`cancel` are no-ops. - if (request.signal?.aborted) { - const started = Promise.reject(new Error('subagent request was aborted before the ACP child started')) - // The result is derived from the same boundary so the readiness rejection - // is observed even when this provider is driven directly rather than - // through SubagentService. - const result: Promise = started.catch(() => ({ output: [], stopReason: 'aborted' })) - return { - id, - started, - result, - cancel(_reason?: string): void { /* nothing was started */ }, - dispose(): Promise { 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 @@ -225,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 | undefined + const disposeProcess = (): Promise => (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 @@ -271,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 @@ -279,6 +272,7 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su let signalCancelSettled!: () => void const cancelSettled = new Promise((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 @@ -293,7 +287,7 @@ 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 }) // The accumulated child text as harness ContentBlocks (empty array when the // child streamed nothing). Read at every return so a partial answer survives @@ -303,47 +297,41 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su return text.length > 0 ? [{ type: 'text', text }] : [] } - // A provider is "started" only once the remote child has completed ACP - // initialization and published a session. SubagentService gates its - // `subagent/start` notification on this boundary, just as the in-process - // provider gates it on local Agent publication. Failure or cancellation - // before this point rejects readiness and therefore produces no paired - // lifecycle events for a child that never became live. - const started: Promise = Promise.race([ - (async (): Promise => { - await conn.initialize({ - protocolVersion: PROTOCOL_VERSION, - // Advertise NO optional client capabilities (no fs, no terminal): the - // child self-serves in its own process. - clientCapabilities: {}, - }) - const session = await conn.newSession({ cwd: spec.cwd, mcpServers: [] }) - sessionId = session.sessionId - if (flags.cancelled) throw new Error('subagent cancelled before the ACP session started') - })(), - spawnFailed.then((err): never => { throw err }), - cancelSettled.then((): never => { throw new Error('subagent cancelled before the ACP session started') }), - ]) + // Establish the remote session before publishing a handle. Any failure owns + // the still-private process and therefore reaps it before rejecting. + try { + await Promise.race([ + (async (): Promise => { + await conn.initialize({ + protocolVersion: PROTOCOL_VERSION, + // Advertise NO optional client capabilities (no fs, no terminal): the + // child self-serves in its own process. + clientCapabilities: {}, + }) + const session = await conn.newSession({ cwd: spec.cwd, mcpServers: [] }) + sessionId = session.sessionId + if (flags.cancelled) throw new Error('subagent cancelled before the ACP session started') + })(), + spawnFailed.then((err): never => { throw err }), + cancelSettled.then((): never => { throw new Error('subagent cancelled before the ACP session started') }), + ]) + } catch (error: unknown) { + request.signal.removeEventListener('abort', onAbort) + await disposeProcess() + if (flags.cancelled) throw new Error('subagent request was aborted before the ACP child started') + throw toError(error) + } const result: Promise = (async (): Promise => { try { - // Readiness is the initialize → newSession phase above. Awaiting the SAME - // promise immediately observes its rejection even without the service, - // and guarantees the prompt phase never starts before the provider can - // truthfully announce a live child. - await started - - // Race two post-start outcomes, first to settle wins: + // 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 (the `cancel()` contract: `result` settles `aborted`). - // A spawn error can only precede readiness and is already one arm of - // `started`; after `newSession` succeeds, transport/process failure rejects - // the in-flight prompt RPC through the connection. + // wedge the prompt (`result` settles `aborted`). After `newSession` + // succeeds, transport/process failure rejects the in-flight prompt RPC. const prompt = async (): Promise => { - // `started` cannot fulfill without assigning the session id; the cast - // records that local invariant without an unreachable defensive arm. + // The startup phase cannot fulfill without assigning the session id. const promptResult = await conn.prompt({ sessionId: sessionId as string, prompt: toAcpPrompt(request.prompt) }) return { output: collectOutput(), stopReason: acpStopReason(promptResult.stopReason) } } @@ -354,9 +342,8 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su } catch (error: unknown) { if (flags.cancelled) return { output: collectOutput(), stopReason: 'aborted' } // The seam contract: result resolves (never rejects) on a child-level - // failure. A cancellation is recognized by the flag above even when it - // wins during readiness; every other rejection is a genuine child-level - // error — initialize/newSession/prompt transport/RPC failure or ENOENT. + // 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 { @@ -367,18 +354,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 | undefined return { id, - started, result, - cancel(_reason?: string): void { + dispose(): Promise { + if (disposal !== undefined) return disposal + request.signal.removeEventListener('abort', onAbort) requestCancel() - }, - async dispose(): Promise { - 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 @@ -388,10 +376,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 }, } } diff --git a/packages/subagent/subagent-acp/tests/mock-acp-server.ts b/packages/subagent/subagent-acp/tests/mock-acp-server.ts index 9cfeac1f44..fb200f3505 100644 --- a/packages/subagent/subagent-acp/tests/mock-acp-server.ts +++ b/packages/subagent/subagent-acp/tests/mock-acp-server.ts @@ -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 { + 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') } - diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts b/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts index 826ef198dd..7814dfaca3 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts @@ -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() diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index e25d194c98..4bbafea521 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -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,7 +123,7 @@ 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') @@ -128,7 +132,7 @@ describe('dsh-subagent-acp', () => { 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 +140,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 +165,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 +174,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 ` — 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 +205,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 +252,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 +289,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 +304,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 +314,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 +331,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 +344,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 +353,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 +364,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 +374,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 +382,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 +408,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 +430,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 +440,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 }) }, @@ -487,14 +474,14 @@ describe('dsh-subagent-acp', () => { // 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 +501,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 +513,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 +522,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([ diff --git a/packages/subagent/subagent-fork/README.md b/packages/subagent/subagent-fork/README.md index 2aef651839..bf90ecdf52 100644 --- a/packages/subagent/subagent-fork/README.md +++ b/packages/subagent/subagent-fork/README.md @@ -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** instead of starting with an empty conversation. The seed affects conversation history only. Tool registrations and restrictions follow the child's fresh flat scope; no parent/child authority relation is defined. Fork shares the run driver (`startInProcessRun`) with [`dsh-subagent-spawn`](../subagent-spawn/README.md); the only difference is the seed. The shared `run.started` boundary resolves only after the seeded child is published, so `subagent/start` observers see a live registry entry. +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: true, persona: true }` — identical to spawn because the shared driver owns depth, model, persona, tool-filter, and structured-output behavior. +`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. diff --git a/packages/subagent/subagent-fork/src/index.ts b/packages/subagent/subagent-fork/src/index.ts index 3b9243aa2e..ebf64e7b70 100644 --- a/packages/subagent/subagent-fork/src/index.ts +++ b/packages/subagent/subagent-fork/src/index.ts @@ -72,11 +72,11 @@ class ForkProvider implements SubagentProvider { // Context contract: a forked child IS seeded with the parent's completed-turn prefix. readonly inheritsParentContext = true - constructor(readonly name: string, private readonly ctx: Context) {} + constructor(readonly name: string) {} start(request: SubagentStartRequest) { const seed = completedTurnPrefix(request.parent) - return startInProcessRun(this.ctx, request, { + return startInProcessRun(request, { // Only pass a seed when there's a completed turn to inherit; an empty seed // is equivalent to a fresh child, so omit it to keep the session unseeded. ...seed.length > 0 ? { seed } : {}, @@ -85,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)) } diff --git a/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts b/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts index 1f932fbaf9..8060a77fd4 100644 --- a/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts +++ b/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts @@ -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[0] +function start(ctx: Context, provider: string, request: Omit & { 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') diff --git a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts index 66234a012c..e089cebe5f 100644 --- a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts +++ b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts @@ -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,6 +17,10 @@ import { completedTurnPrefix } from '../src/index.ts' type Script = ConstructorParameters[0] +function start(ctx: Context, provider: string, request: Omit & { 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' } }] @@ -77,9 +81,9 @@ describe('dsh-subagent-fork', () => { if (info.provider === 'fork') childAtStart = ctx.agents.get(info.id) }) - const run = ctx.subagents.start('fork', { prompt: [{ type: 'text', text: 'child q' }], parent }) + const starting = start(ctx, 'fork', { prompt: [{ type: 'text', text: 'child q' }], parent }) expect(childAtStart).toBeUndefined() - await run.started + const run = await starting expect(childAtStart).toBe(ctx.agents.get(run.id)) expect(childAtStart?.id).toBe(run.id) @@ -92,7 +96,7 @@ describe('dsh-subagent-fork', () => { // the seed → the child runs fresh. Exercises the `seed.length > 0` false arm. const { ctx, parent } = await setup([textResponse('fresh child')]) expect(completedTurnPrefix(parent)).toEqual([]) - const run = ctx.subagents.start('fork', { prompt: [{ type: 'text', text: 'child q' }], parent }) + const run = await start(ctx, 'fork', { prompt: [{ type: 'text', text: 'child q' }], parent }) const result = await run.result expect(result.stopReason).toBe('completed') expect(text(result.output)).toBe('fresh child') @@ -110,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') @@ -143,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') @@ -165,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'] }, @@ -189,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. diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index 0980d85aa9..6077a139cd 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -1,41 +1,41 @@ # @deepseek-ai/dsh-subagent-inprocess -The shared **in-process subagent run driver**. A library with no provider or import-time 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. Each accepted run installs one provider-owned cleanup effect. 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` 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. reads every public request and seed field once before asynchronous owner setup: the parent and signal remain identity capabilities, while tool filter, seed, agent options, output schema, and prompt are each materialized by the shared one-pass lossless-JSON snapshot. It rejects malformed `request.maxDepth` and `persona` values, validates the parent's `subagentDepth`, computes child depth = `depthOf(parent) + 1`, rejects a child depth outside the safe-integer domain with `RangeError`, rejects a defined `maxDepth` cap breach with `SubagentDepthError`, reports an invalid schema as `OutputSchemaError`, and derives both the child prefix and `seedLength` from the same detached seed; -2. first installs provider ownership, then attaches the request abort listener and creates one run-owner Cordis fiber under `parent.ctx`; an already-unloading provider therefore leaves no child or orphaned listener. Async child creation goes through that fiber's `ctx.agents` service with fresh IDs, lineage/seed, inherited model, and an unpublished setup transaction for persona, tool restriction, and structured output. Parent teardown, provider teardown, manual `run.dispose()`, and cancellation before readiness all dispose this exact node, preventing publication after it becomes inactive and sharing the same quiescence boundary. `startInProcessRun` still returns its `SubagentRun` immediately: `run.started` resolves only after `ctx.agents.create()` has published the child and rejects when pre-readiness cancellation rolls the transaction back; -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. -`SubagentService` waits for `run.started` before emitting `subagent/start`, so a synchronous start observer can resolve the published child with `ctx.agents.get(run.id)`; the result driver awaits the same boundary before sending the prompt. An attempt that never publishes rejects readiness and emits no false start/end pair; its result reports a deliberate cancel/dispose as `aborted` and propagates an infrastructure fault. `dispose()` awaits creation or rollback and then delegates to `AgentHandle.dispose()` (stop and drain → remove agent → detach session → unwind scope). Before readiness, `cancel()` deactivates the creation owner: before creation notification begins, no agent/session lifecycle edge escapes; if cancellation is triggered synchronously by a creation observer, every begun edge is paired by rollback and the driver never unlocks or starts. After readiness, cancellation reaches the live child immediately. Either path records the cancellation, so a cancel landing before any `turn/end` 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. -The child receives a fresh flat registration scope. Its `toolFilter` masks the live global tool layer and scope-local registrations are merged afterward; parent ownership does not import the parent's tool restrictions or establish an authority subset. The [agent-scope RFC](../../../docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals) owns that explicit non-goal. +## Cancellation and ownership -### `InProcessRunOptions` +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. -`{ seed?: SessionEvent[] }` — the optional child-session seed: absent for spawn, or the parent's balanced completed-turn prefix for fork. +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. -### Structured output (package-internal runtime) +## Spawn and fork inputs -`attachStructuredRuntime(childCtx, schema)` registers the run's whole enforcement surface as SCOPED registrations on the child's `agent.ctx` — riding the child's fiber (a backend hot-reload mid-run cannot unregister anything; a disposed child leaves no residue) and visible to that child alone (two concurrent structured runs never interact; no placeholder schema, no strip-for-everyone-else, no refcounted global state): +`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 `structured_output` capture tool with the run's REAL schema as its registered `parameters`, validating each call (`validateStructuredValue`) — violations become an `INVALID_ARGS` isError the model retries in-turn; a valid call STAGES the value in a `WeakMap` keyed by that call's `ToolExecution` object; -- the calling instruction as an ordinary order-190 scoped prompt section (the demand travels with the tool, as prompt state of exactly one agent); -- a scoped `systemPrompt.protect()` registration making the capture instruction and schema canonical after the complete assembly waterfall. Canonical absence is protected too: pure Code Mode removes `structured_output` from the wire and declares it through the SDK. The tool registry separately owns and protects its `tools:sdk` section and reserved `run_code` transport; protection guarantees those named contributions, while unrelated listener-added schemas remain the listener's responsibility. The loop logs the finalized assembly as the step's `request/header`, so the demand remains reconstructable; -- a scoped `tools/result` observer as the commit point: it promotes a staged value only when that same execution's immutable, JSON-safe authoritative result after the complete pre-execute → guards → execute → post-execute pipeline succeeds. For a Code Mode SDK sub-dispatch, the child's opaque `parent` token matches the enclosing `run_code` execution's registry-assigned `token`, so promotion waits for that outer final result without exposing its live object; a runtime failure or post-policy block discards the value. Execution-object identity prevents call-id reuse or another execution from reaching the stage; -- a scoped monotonic `tools.guard()` denial for every call arriving after capture. Guards run after the extensible pre-execute waterfall and cannot return allow, so terminal means terminal within the step regardless of listener order; -- a scoped `agent/turn-stop` terminal policy stopping the child's turn once its output is captured. It runs after ordinary continuation and steering folding, and its terminal state survives turn close and flush, so later listeners cannot leak steering into another step or turn; ordinary queued prompts remain intact. +`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`. -### `depthOf(agent): number` +## Structured output -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` treats only `undefined` as absent (top-level depth 0) and rejects every malformed present value instead of letting it disable the cap comparison. +`attachStructuredRuntime(childCtx, schema)` installs the whole contract in the child's scope: -### `SubagentDepthError` +- A `structured_output` tool registered with the requested schema validates and stages the model's value. +- An order-190 system-prompt section tells the child that the tool call is the terminal answer. +- Both contributions use `ownerFinal: true`, so their owners control their final presence after prompt/tool assembly while unrelated contributions remain extensible. +- A `tools/result` observer commits a staged value only after that execution's authoritative final tool result succeeds, including the enclosing `run_code` result for Code Mode sub-dispatch. +- A monotonic tool guard blocks later calls after capture, and `agent/turn-stop` ends the turn after the structured result commits. -Thrown by `startInProcessRun` when a spawn would exceed the request's defined `maxDepth` cap; carries `attemptedDepth` and `maxDepth`. A valid parent at `Number.MAX_SAFE_INTEGER` instead produces `RangeError`, because its child depth cannot be represented within the stored safe-integer domain even when `maxDepth` is omitted. +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. diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index 3ccb08fda9..f6de7200cf 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -1,26 +1,17 @@ /** - * 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 declares no provider and performs no import-time registration; - * it is a library the backend packages depend on, so neither backend needs to - * know about the other. Each accepted run does install one provider-owned - * effect for structured-concurrency cleanup. + * Shared driver for in-process subagent providers. The agent factory's + * creation transaction owns unpublished setup and rollback; after publication + * the returned AgentHandle is the one quiescent lifecycle owner held by the + * provider's caller. * * @module @deepseek-ai/dsh-subagent-inprocess */ import { randomUUID } from 'node:crypto' -import type { Context, Fiber } from 'cordis' -import { AgentId, type Agent, type AgentHandle, type AgentOptions } from '@deepseek-ai/dsh-agent' -import { SessionId, snapshotJsonValue, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' +import type { Context } from 'cordis' +import { AgentId, type Agent, type AgentOptions } from '@deepseek-ai/dsh-agent' +import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import { assertSupportedOutputSchema, OutputSchemaError } from '@deepseek-ai/dsh-tools' import { assertSubagentMaxDepth } from '@deepseek-ai/dsh-subagent' import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent' import { @@ -28,9 +19,6 @@ import { type StructuredAttachment, } from './structured.ts' -// The runtime itself (attach) is package-internal: runs attach it inside -// startInProcessRun's setup window, and no other package drives it. Only the -// model-facing vocabulary is public. export { STRUCTURED_OUTPUT_TOOL, STRUCTURED_OUTPUT_INSTRUCTION, @@ -38,24 +26,15 @@ 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. When present it is a non-negative safe - * integer. 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), - * rejecting a malformed stored value instead of letting it disable comparison. - * @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 { const depth = agent.options.subagentDepth @@ -66,7 +45,7 @@ export function depthOf(agent: Agent): number { 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}`) @@ -74,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': @@ -83,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': @@ -94,329 +70,120 @@ function toStopReason(reason: TurnEndReason | undefined): SubagentStopReason { } } -/** Extra inputs the spawn/fork backends supply to {@link startInProcessRun}. */ +/** Extra inputs the spawn and fork providers supply to the shared driver. */ export interface InProcessRunOptions { - /** - * The child session's seed: a balanced, contiguous-from-0 prefix of the - * parent's log (FORK), or `undefined` for a fresh child (SPAWN). - */ + /** Completed-turn seed for fork, or undefined for a fresh spawn. */ readonly seed?: SessionEvent[] } -/** Dispose a run-owner fiber and follow an already-started unload to quiescence. */ -async function quiesceFiber(fiber: Fiber): Promise { - await Promise.resolve(fiber.dispose()) - while (fiber.inertia !== undefined) await fiber.inertia +/** Error used when cancellation wins before the child publication boundary. */ +function prePublicationAbort(): Error { + return new Error('subagent request was aborted before child publication') } /** - * Start an in-process child agent for `request` and return a {@link SubagentRun}. - * - * 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 a published child's in-flight turn; before - * readiness it instead deactivates the unpublished run-owner transaction, so - * `started` rejects, no agent/session lifecycle is published, and `result` - * resolves `aborted`. - * - * Throws {@link SubagentDepthError} before creating anything when the child's - * depth (parent depth + 1) would exceed `request.maxDepth`, and throws a - * `RangeError` when a valid parent depth has no safe-integer successor. - * @param ctx - the provider context that owns the live run as a second - * structured-concurrency boundary alongside the parent agent. - * @param request - the start request (prompt, parent, signal, per-child options). - * @param options - the backend's optional child-session seed. - * @returns the live run handle for the child agent. + * Establish and drive one in-process child. Fulfillment means the agent is + * already published in the registry; rejection means the agent factory's + * creation transaction and any partially-created child have reached quiescence. + * @param request - the trusted typed start request, including its required signal. + * @param options - the optional fork seed. + * @returns a ready holder-owned run. */ -export function startInProcessRun( - ctx: Context, +export async function startInProcessRun( request: SubagentStartRequest, options: InProcessRunOptions, -): SubagentRun { - // Capture every top-level field once. Parent/signal are identity capabilities; - // every data value is materialized below before asynchronous owner setup. +): Promise { + assertSubagentMaxDepth(request.maxDepth) + if (request.signal.aborted) throw prePublicationAbort() const parent = request.parent - const signal = request.signal - const persona = request.persona - const inputToolFilter = request.toolFilter - const inputMaxDepth = request.maxDepth - const inputSchema = request.outputSchema - const inputPrompt = request.prompt - const inputAgentOptions = request.agentOptions - const inputSeed = options.seed - assertSubagentMaxDepth(inputMaxDepth) - if (persona !== undefined && typeof persona !== 'string') { - throw new TypeError('subagent persona must be a string') - } - const toolFilter = inputToolFilter === undefined ? undefined : snapshotJsonValue(inputToolFilter) - if (inputToolFilter !== undefined && toolFilter === undefined) { - throw new TypeError('subagent tool filter must be losslessly JSON-serializable') - } - const seed = inputSeed === undefined ? undefined : snapshotJsonValue(inputSeed) - if (inputSeed !== undefined && seed === undefined) { - throw new TypeError('subagent seed must be losslessly JSON-serializable') - } const childDepth = depthOf(parent) + 1 if (!Number.isSafeInteger(childDepth)) { throw new RangeError('subagent child depth exceeds the safe-integer range') } - if (inputMaxDepth !== undefined && childDepth > inputMaxDepth) { - throw new SubagentDepthError(childDepth, inputMaxDepth) - } - const requestedAgentOptions = inputAgentOptions === undefined - ? {} - : snapshotJsonValue(inputAgentOptions) - if (requestedAgentOptions === undefined) { - throw new TypeError('subagent agent options must be losslessly JSON-serializable') - } - // Materialize, then assert, the schema subset BEFORE any child exists. The - // single traversal rejects non-JSON data without rereading accessors; the - // detached value then pins assertion, model-visible parameters, and runtime - // validation to one provider-owned schema. Contract failures stay typed as - // OutputSchemaError rather than leaking a materialization detail. - const schema = inputSchema === undefined ? undefined : snapshotJsonValue(inputSchema) - if (inputSchema !== undefined && schema === undefined) { - throw new OutputSchemaError(['schema annotation must be JSON data; the complete schema must be losslessly JSON-serializable']) - } - if (schema !== undefined) assertSupportedOutputSchema(schema) - // The accepted request owns a value snapshot, not the caller's mutable - // content array. Use the same one-pass boundary Session.append enforces before - // any child exists so later mutation cannot change what is logged or sent. - const prompt = snapshotJsonValue(inputPrompt) - if (prompt === undefined) { - throw new TypeError('subagent prompt must be losslessly JSON-serializable') + if (request.maxDepth !== undefined && childDepth > request.maxDepth) { + throw new SubagentDepthError(childDepth, request.maxDepth) } 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 = seed?.length ?? 0 + const seedLength = options.seed?.length ?? 0 const parentHeader = parent.session.header - // Inherit the parent's model by default (a child with no model cannot run); - // an explicit `request.agentOptions.model` overrides it. The deployment - // persona needs no inheritance (a context-wide section both render); a - // per-child `request.persona` becomes a SCOPED section of the same name in - // the setup below, shadowing the deployment's for this child alone. const parentModel = parent.options.model - const agentOptions = snapshotJsonValue({ + const agentOptions: AgentOptions = { ...parentModel !== undefined ? { model: parentModel } : {}, - ...requestedAgentOptions, + ...request.agentOptions, subagentDepth: childDepth, - }) - if (agentOptions === undefined) { - throw new TypeError('subagent agent options must be losslessly JSON-serializable') } - // The child's scoped world, composed in the factory's unpublished setup - // window. The factory awaits it before inserting or announcing the child, so - // a throw/rejection exposes neither id and every first assembly sees it: - // - persona: a scoped `deployment:persona` section shadowing the global one; - // - toolFilter: a scoped restrict() masking the global tool surface - // (loud unknown-name validation lives in the registry); - // - outputSchema: the structured runtime, attached as scoped registrations. let structured: StructuredAttachment | undefined const setup = (childCtx: Context): void => { - if (persona !== undefined) { - childCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: persona }) + if (request.persona !== undefined) { + childCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: request.persona }) } - if (toolFilter !== undefined) { - childCtx.tools.restrict(toolFilter) - } - if (schema !== undefined) { - structured = attachStructuredRuntime(childCtx, schema) + if (request.toolFilter !== undefined) childCtx.tools.restrict(request.toolFilter) + if (request.outputSchema !== undefined) { + structured = attachStructuredRuntime(childCtx, request.outputSchema) } } - // Bridge the request's abort signal to the child (the consumer also bridges - // its own exec.signal, but a backend-level bridge keeps the contract local). - // Install it after provider ownership succeeds but BEFORE awaiting creation, - // so an inactive provider cannot leave an orphaned listener and abort/dispose - // during async setup is still recorded and applied the moment a child exists. - // `cancelled` records that a cancel was requested at all. Before readiness, - // cancellation deactivates the unpublished run-owner transaction so the - // factory cannot publish an agent or session. After readiness, it cancels the - // live child. Either path 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 - let child: Agent | undefined - let handle: AgentHandle | undefined - - // One run-owned Cordis fiber is the common ownership node. Install the - // provider effect FIRST: a start racing an already-unloading provider fails - // before it can mint anything under the parent. The owner fiber is then - // nested under the parent scope, and the provider/run handle both dispose - // this exact fiber. AgentFactory binds its lifecycle to `ownerCtx`, so any of - // the three owners moves the fiber out of ACTIVE synchronously and setup - // cannot publish afterward. - let ownerCtx: Context | undefined - function subagentRunOwner(inner: Context): void { ownerCtx = inner } - let ownerFiber: (Fiber & PromiseLike) | undefined - let ownerSetupError: unknown - let ownerDisposing: Promise | undefined - const disposeOwner = (): Promise => { - if (ownerDisposing !== undefined) return ownerDisposing - // An already-aborted request is observed before the owner fiber is minted. - // Do not memoize that no-op: the post-plugin cancellation check below must - // still be able to claim and deactivate the real fiber. - if (ownerFiber === undefined) return Promise.resolve() - ownerDisposing = quiesceFiber(ownerFiber) - // Pre-readiness cancellation is synchronous fire-and-forget at the public - // `cancel()` boundary. Observe a teardown rejection here; dispose() still - // awaits the same memoized promise and reports it to an explicit caller. - void ownerDisposing.catch(() => undefined) - return ownerDisposing - } - const requestCancel = (reason: string): void => { - cancelled = true - if (child === undefined) { - if (ownerFiber !== undefined) void disposeOwner() - return - } - child.cancel(reason) - } - const onAbort = (): void => { requestCancel('subagent cancelled') } - const unlinkProvider = ctx.effect(() => () => { - requestCancel('subagent provider disposed') - return disposeOwner() - }, 'subagent-inprocess.run()') - signal?.addEventListener('abort', onAbort, { once: true }) - if (signal?.aborted) requestCancel('subagent cancelled') - try { - ownerFiber = parent.ctx.plugin(Object.assign(subagentRunOwner, { - inject: ['agents', 'sessions', 'llm', 'tools', 'systemPrompt'], - })) - // `signal.aborted` is checked before this fiber exists. Once it does, make - // that recorded cancellation effective immediately; awaiting creation must - // observe an inactive owner instead of reaching the publication boundary. - if (isCancelled()) void disposeOwner() - } catch (error: unknown) { - ownerSetupError = error + const flags = { cancelled: false } + const handle = await parent.ctx.agents.create({ + agentId: childId, + sessionId: SessionId(randomUUID()), + meta: { + ...parentHeader.cwd !== undefined ? { cwd: parentHeader.cwd } : {}, + parentSession: parentHeader.id, + ...seedLength > 0 ? { seedLength } : {}, + }, + ...options.seed !== undefined ? { seed: options.seed } : {}, + agentOptions, + signal: request.signal, + setup, + }) + const child = handle.agent + // Agent creation detaches its creation-only abort listener before returning. + // Close the narrow handoff race before installing the live-run listener. + // Static analysis does not model the abort that may land between the + // factory's listener detachment and this continuation. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (request.signal.aborted) { + flags.cancelled = true + await handle.dispose() + throw prePublicationAbort() } - const creation: Promise = (async () => { - if (ownerSetupError !== undefined) { - throw ownerSetupError instanceof Error - ? ownerSetupError - : new Error('subagent run owner setup failed with a non-Error value', { cause: ownerSetupError }) - } - await ownerFiber - if (ownerCtx === undefined) { - throw new Error('subagent run owner became inactive before child creation') - } - // Invoke the factory THROUGH the parent scope. Cordis binds the factory's - // lifecycle effect to the accessing context, so parent ownership exists - // before persistence/setup and publication—not as a fallible link added - // after the child is already visible. A disposed parent therefore rejects - // before any session/agent notification, and disposal during async setup - // wins the unpublished transaction. - const created = await ownerCtx.agents.create({ - agentId: childId, - sessionId: SessionId(randomUUID()), - meta: { - ...parentHeader.cwd !== undefined ? { cwd: parentHeader.cwd } : {}, - parentSession: parentHeader.id, - ...seedLength > 0 ? { seedLength } : {}, - }, - ...seed !== undefined ? { seed } : {}, - agentOptions, - setup, - }) - handle = created - child = created.agent - return created.agent - })() - - // Provider readiness is a distinct lifecycle boundary from accepting the - // request. It resolves only after the factory has published the child and - // returned its handle, so SubagentService can emit `subagent/start` while - // `ctx.agents.get(childId)` is guaranteed to resolve. The result path awaits - // THIS SAME promise immediately, which also observes a readiness rejection - // when the driver is invoked directly rather than through SubagentService. - const started: Promise = creation.then(() => undefined) + const onAbort = (): void => { + flags.cancelled = true + child.cancel('subagent request aborted') + } + request.signal.addEventListener('abort', onAbort, { once: true }) const result: Promise = (async () => { try { - let liveChild: Agent - try { - await started - // `creation` assigns `child` before it fulfills, and `started` is its - // direct fulfillment projection. The cast records that local invariant - // without manufacturing an unreachable runtime branch. - liveChild = child as Agent - } catch (error: unknown) { - if (isCancelled()) return { output: [], stopReason: 'aborted' } - throw error instanceof Error ? error : new Error('subagent child creation failed with a non-Error value', { cause: error }) - } - liveChild.send(prompt) - await liveChild.whenIdle() - // Deliberately NO re-prompt when a structured child finishes cleanly - // without calling structured_output: readResult maps that to `error` — - // the shortfall goes to the parent instead of buying extra model turns. - return readResult(liveChild, seedLength, isCancelled(), structured ? { captured: structured.captured() } : undefined) + child.send(request.prompt) + await child.whenIdle() + return readResult( + child, + seedLength, + flags.cancelled, + structured ? { captured: structured.captured() } : undefined, + ) } finally { - signal?.removeEventListener('abort', onAbort) + request.signal.removeEventListener('abort', onAbort) } })() - let disposing: Promise | undefined return { id: childId, - started, result, - cancel(reason?: string): void { - requestCancel(reason ?? 'subagent cancelled') - }, - async dispose(): Promise { - return (disposing ??= (async () => { - signal?.removeEventListener('abort', onAbort) - requestCancel('subagent disposed during creation') - // Removing provider ownership and disposing the common run-owner fiber - // are the same quiescence transaction; parent disposal may already have - // claimed it, in which case disposeOwner follows fiber inertia. - await unlinkProvider() - try { - await creation - } catch { - // Creation rollback already reached quiescence; there is no handle - // left to dispose, and dispose must not mask result's infrastructure - // rejection with the same error from a finally block. - return - } - await disposeOwner() - await handle?.dispose() - })()) + dispose(): Promise { + 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, @@ -424,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 } diff --git a/packages/subagent/subagent-inprocess/src/structured.ts b/packages/subagent/subagent-inprocess/src/structured.ts index d4267ae009..f81d82ef62 100644 --- a/packages/subagent/subagent-inprocess/src/structured.ts +++ b/packages/subagent/subagent-inprocess/src/structured.ts @@ -16,12 +16,12 @@ * * The child scope's registrations enforce the contract: * - * - `systemPrompt.protect()` declaratively protects the capture tool and its - * instruction. The service restores their canonical pre-waterfall state + * - `ownerFinal: true` on the capture tool and instruction declares that the + * owning registrations control their final presence. Prompt assembly restores their canonical state * after EVERY assembly listener. Canonical absence is protected too: pure * Code Mode keeps `structured_output` in the SDK only and never grows a - * second native wire tool. Code Mode's owner independently protects its SDK - * and `run_code` transport. The loop logs the finalized assembly as the + * second native wire tool. Code Mode independently declares its SDK section + * and `run_code` transport owner-final. The loop logs the finalized assembly as the * request header, so the demand is reconstructable log state, never a * wire-only mutation. * - `agent/turn-stop` (serial, scoped): stop the child's turn once its output @@ -79,7 +79,7 @@ export interface StructuredAttachment { * 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 detached, already-asserted schema subset to enforce (see + * @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). */ @@ -110,15 +110,16 @@ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOut childCtx.tools.register({ ...schemaEntry, + ownerFinal: true, execute(args: unknown, exec: ToolExecution): Promise { 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. Snapshot the - // validated value independently of the already-frozen pipeline arguments. - staged.set(exec, { value: structuredClone(args) }) + // 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.' }]) }, }) @@ -127,16 +128,7 @@ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOut name: `tool:${STRUCTURED_OUTPUT_TOOL}`, order: 190, text: STRUCTURED_OUTPUT_INSTRUCTION, - }) - - // Service-owned finalization, not waterfall ordering. The canonical - // assembly determines both presence and absence: native/both modes restore - // the capture schema on the wire, while pure Code Mode removes any injected - // native entry. ToolRegistry's own protection independently restores the SDK - // section and run_code transport that carry the same schema. - childCtx.systemPrompt.protect({ - sections: [`tool:${STRUCTURED_OUTPUT_TOOL}`], - tools: [STRUCTURED_OUTPUT_TOOL], + ownerFinal: true, }) // Stop the child's turn once its output is captured. This monotonic serial diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts index 54ac968f4b..f989d727c8 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -65,7 +65,7 @@ async function setup(script: Script, options: SetupOptions = {}) { name: 'spawn', capabilities: { outputSchema: true, depthLimit: true, toolFilter: false, persona: false }, inheritsParentContext: false, - start: (request: SubagentStartRequest) => startInProcessRun(ctx, request, {}), + start: (request: SubagentStartRequest) => startInProcessRun(request, {}), }) ctx.llm.registerAdapter(['mock'], adapter) const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' }) @@ -73,7 +73,13 @@ async function setup(script: Script, options: SetupOptions = {}) { } function structuredRequest(parent: SubagentStartRequest['parent'], extra?: Partial): SubagentStartRequest { - return { prompt: [{ type: 'text', text: 'produce the answer' }], parent, outputSchema: SCHEMA, ...extra } + return { + prompt: [{ type: 'text', text: 'produce the answer' }], + parent, + signal: new AbortController().signal, + outputSchema: SCHEMA, + ...extra, + } } /** The tool names of one recorded model request. */ @@ -86,7 +92,7 @@ describe('in-process structured output', () => { const { ctx, parent } = await setup([ toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 42, note: 'done' }), ]) - const run = ctx.subagents.start('spawn', structuredRequest(parent)) + const run = await ctx.subagents.start('spawn', structuredRequest(parent)) const result = await run.result expect(result.stopReason).toBe('completed') expect(result.structured).toEqual({ answer: 42, note: 'done' }) @@ -98,7 +104,7 @@ describe('in-process structured output', () => { toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }), textResponse('MUST NOT BE CONSUMED'), ]) - const run = ctx.subagents.start('spawn', structuredRequest(parent)) + const run = await ctx.subagents.start('spawn', structuredRequest(parent)) await run.result // Default continuation would run a second step after the tool call; the // structured runtime's turn-continuation veto stops the turn instead. @@ -129,7 +135,7 @@ describe('in-process structured output', () => { return Promise.resolve([{ type: 'text', text: 'ran' }]) }, }) - const run = ctx.subagents.start('spawn', structuredRequest(parent)) + const run = await ctx.subagents.start('spawn', structuredRequest(parent)) const result = await run.result expect(result.stopReason).toBe('completed') expect(result.structured).toEqual({ answer: 5 }) @@ -157,7 +163,7 @@ describe('in-process structured output', () => { return Promise.resolve([{ type: 'text', text: 'ran' }]) }, }) - const run = ctx.subagents.start('spawn', structuredRequest(parent)) + const run = await ctx.subagents.start('spawn', structuredRequest(parent)) // Registered after the child and prepended: this listener returns allow // after every downstream pre-execute decision. The service-owned guard // runs after the waterfall and can only deny, so the body still cannot run. @@ -194,7 +200,7 @@ describe('in-process structured output', () => { return Promise.resolve([{ type: 'text', text: 'ran' }]) }, }) - const run = ctx.subagents.start('spawn', structuredRequest(parent)) + const run = await ctx.subagents.start('spawn', structuredRequest(parent)) const result = await run.result // The call ran BEFORE captured was set: the deny gate only guards the // window after the terminal answer landed. @@ -203,46 +209,20 @@ describe('in-process structured output', () => { await run.dispose() }) - it('snapshots the schema at start(): caller mutation after start cannot drift enforcement', async () => { - const mutable: StructuredOutputSchema = { - type: 'object', - properties: { answer: { type: 'number' } }, - required: ['answer'], - additionalProperties: false, - } - const pristine = structuredClone(mutable) - const { ctx, parent, adapter } = await setup([ - toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 3 }), - ]) - const run = ctx.subagents.start('spawn', structuredRequest(parent, { outputSchema: mutable })) - // Mutate the caller's object AFTER start() returned but before the child's - // first request assembles: with a live reference this would reach both the - // model-visible parameters and validateStructuredValue. - ;(mutable.properties as Record).answer = { type: 'string' } - const result = await run.result - expect(result.structured).toEqual({ answer: 3 }) - // The child's request carried the PRISTINE schema, not the mutated one. - const childRequest = adapter.requests.at(-1) - const captureTool = (childRequest?.tools ?? []).find(tool => tool.name === STRUCTURED_OUTPUT_TOOL) - expect(captureTool?.parameters).toEqual(pristine) - await run.dispose() - }) - it('a later-prepended continuation wrapper cannot resurrect a captured turn', async () => { const { ctx, parent, adapter } = await setup([ toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 7 }), textResponse('MUST NOT BE CONSUMED'), ]) ctx.on('agent/turn-continuation', () => Promise.resolve({ action: 'stop' })) - const run = ctx.subagents.start('spawn', structuredRequest(parent)) let wrapperInstalled = false - // Register this observer only after start() returns. The child session-start - // boundary is after its unpublished setup attached structured output but - // before the loop can run; install a prepended wrapper there. It awaits the + // Register before the ready-only start. The child session-start boundary is + // after unpublished setup attached structured output but before the loop + // can run. The wrapper awaits the // explicit downstream stop above, then overwrites that result with continue. // The later terminal checkpoint still wins. ctx.on('agent/session-start', (child) => { - if (child.id !== run.id) return + if (child === parent) return wrapperInstalled = true child.ctx.on('agent/turn-continuation', async (_subject, _turn, _decision, next): Promise => { const downstream = await next() @@ -250,6 +230,7 @@ describe('in-process structured output', () => { return { action: 'continue' } }, { prepend: true }) }) + const run = await ctx.subagents.start('spawn', structuredRequest(parent)) const result = await run.result expect(wrapperInstalled).toBe(true) expect(result.structured).toEqual({ answer: 7 }) @@ -268,7 +249,7 @@ describe('in-process structured output', () => { // would turn the stop back into continue. The terminal checkpoint runs // afterwards and discards that steering. ctx.on('agent/turn-continuation', () => Promise.resolve({ action: 'stop' })) - const run = ctx.subagents.start('spawn', structuredRequest(parent)) + const run = await ctx.subagents.start('spawn', structuredRequest(parent)) ctx.on('agent/session-start', (child) => { if (child.id !== run.id) return child.ctx.on('agent/turn-continuation', async (subject, _turn, _decision, next): Promise => { @@ -294,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') @@ -311,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() @@ -325,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) @@ -334,12 +315,13 @@ describe('in-process structured output', () => { it('a cancel landing after a clean capture-less turn settles aborted, not error', async () => { const { ctx, parent } = await setup([textResponse('prose, no capture')]) - const run = ctx.subagents.start('spawn', structuredRequest(parent)) + const controller = new AbortController() + const run = await ctx.subagents.start('spawn', structuredRequest(parent, { signal: controller.signal })) // Cancel synchronously inside the turn's end recording: the cancel // contract outranks the schema shortfall, so the result maps to aborted. ctx.on('session/event', (session, event) => { const child = ctx.agents.get(run.id) - if (session === child?.session && event.type === 'turn/end') run.cancel('cancelled at turn end') + if (session === child?.session && event.type === 'turn/end') controller.abort('cancelled at turn end') }) const result = await run.result expect(result.stopReason).toBe('aborted') @@ -348,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 () => { @@ -377,7 +357,7 @@ describe('in-process structured output', () => { } return next() }) - const run = ctx.subagents.start('spawn', structuredRequest(parent)) + const run = await ctx.subagents.start('spawn', structuredRequest(parent)) const result = await run.result // No capture was committed: the run reports the schema shortfall... expect(result.structured).toBeUndefined() @@ -403,7 +383,7 @@ describe('in-process structured output', () => { } return next() }) - const run = ctx.subagents.start('spawn', structuredRequest(parent)) + const run = await ctx.subagents.start('spawn', structuredRequest(parent)) const result = await run.result expect(result.stopReason).toBe('completed') expect(result.structured).toEqual({ answer: 8 }) @@ -415,7 +395,7 @@ describe('in-process structured output', () => { toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 8 }), textResponse('capture was rejected'), ]) - const run = ctx.subagents.start('spawn', structuredRequest(parent)) + const run = await ctx.subagents.start('spawn', structuredRequest(parent)) // Registered after attachment and prepended, so it wraps every listener // the child installed. It delegates first, then converts the apparent // capture success into the pipeline's authoritative failure. @@ -442,7 +422,7 @@ describe('in-process structured output', () => { // replace it (AgentOptions has no prompt field — the instruction is // per-request wire state added by the final-request listener). ctx.systemPrompt.section({ name: 'test:persona', order: 10, text: 'You are a counter.' }) - const run = ctx.subagents.start('spawn', structuredRequest(parent)) + const run = await ctx.subagents.start('spawn', structuredRequest(parent)) await run.result const childRequest = adapter.requests.at(-1)! expect(childRequest.system).toContain('You are a counter.') @@ -463,7 +443,7 @@ describe('in-process structured output', () => { return { logs: [], value: 'captured' } }, }) - const run = ctx.subagents.start('spawn', structuredRequest(parent)) + const run = await ctx.subagents.start('spawn', structuredRequest(parent)) // This listener is registered after the child's protection and prepended. // Service finalization still restores the stripped transport and prompt @@ -507,7 +487,7 @@ describe('in-process structured output', () => { } as never }, }) - const run = ctx.subagents.start('spawn', structuredRequest(parent)) + const run = await ctx.subagents.start('spawn', structuredRequest(parent)) const result = await run.result expect(result.structured).toBeUndefined() @@ -536,7 +516,7 @@ describe('in-process structured output', () => { ctx.on('tools/post-execute', (exec, _result, next) => exec.name === RUN_CODE_NAME ? Promise.resolve({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'outer blocked' }] }) : next()) - const run = ctx.subagents.start('spawn', structuredRequest(parent)) + const run = await ctx.subagents.start('spawn', structuredRequest(parent)) const result = await run.result expect(result.structured).toBeUndefined() @@ -553,7 +533,7 @@ describe('in-process structured output', () => { parent.send([{ type: 'text', text: 'hello' }]) await parent.whenIdle() expect(adapter.requests[0]!.system ?? '').not.toContain(STRUCTURED_OUTPUT_INSTRUCTION) - const run = ctx.subagents.start('spawn', structuredRequest(parent)) + const run = await ctx.subagents.start('spawn', structuredRequest(parent)) await run.result // The loop always assembles a base prompt (the harness identity section), // so the instruction APPENDS — never replaces. @@ -584,7 +564,7 @@ describe('in-process structured output', () => { await parent.whenIdle() expect(toolNames(adapter.requests[0]!)).not.toContain(STRUCTURED_OUTPUT_TOOL) - const run = ctx.subagents.start('spawn', structuredRequest(parent)) + const run = await ctx.subagents.start('spawn', structuredRequest(parent)) await run.result const childRequest = adapter.requests[1]! expect(toolNames(childRequest)).toContain(STRUCTURED_OUTPUT_TOOL) @@ -617,8 +597,8 @@ describe('in-process structured output', () => { return toolCallResponse('c2', STRUCTURED_OUTPUT_TOOL, args) }, ]) - const runA = ctx.subagents.start('spawn', structuredRequest(parent)) - const runB = ctx.subagents.start('spawn', structuredRequest(parent, { outputSchema: otherSchema })) + const runA = await ctx.subagents.start('spawn', structuredRequest(parent)) + const runB = await ctx.subagents.start('spawn', structuredRequest(parent, { outputSchema: otherSchema })) const [a, b] = await Promise.all([runA.result, runB.result]) expect(a.structured).toEqual({ answer: 1 }) expect(b.structured).toEqual({ verdict: 'real' }) @@ -647,7 +627,7 @@ describe('in-process structured output', () => { variables: { ...replaced.variables }, } }) - const run = ctx.subagents.start('spawn', structuredRequest(parent)) + const run = await ctx.subagents.start('spawn', structuredRequest(parent)) const result = await run.result expect(result.structured).toEqual({ answer: 5 }) const entries = adapter.requests[0]!.tools!.filter(tool => tool.name === STRUCTURED_OUTPUT_TOOL) @@ -672,7 +652,7 @@ describe('in-process structured output', () => { variables: { ...replaced.variables }, } }) - const run = ctx.subagents.start('spawn', structuredRequest(parent)) + const run = await ctx.subagents.start('spawn', structuredRequest(parent)) const result = await run.result expect(result.structured).toEqual({ answer: 5 }) const entry = adapter.requests[0]!.tools!.find(tool => tool.name === STRUCTURED_OUTPUT_TOOL) @@ -697,7 +677,7 @@ describe('in-process structured output', () => { execute: () => Promise.resolve([{ type: 'text', text: 'x' }]), }) ctx.systemPrompt.section({ name: 'after-band', order: 200, text: 'AFTER-BAND' }) - const run = ctx.subagents.start('spawn', structuredRequest(parent)) + const run = await ctx.subagents.start('spawn', structuredRequest(parent)) await run.result const request = adapter.requests[0]! const names = toolNames(request) @@ -731,7 +711,7 @@ describe('in-process structured output', () => { variables: { ...replaced.variables }, } }) - const run = ctx.subagents.start('spawn', structuredRequest(parent)) + const run = await ctx.subagents.start('spawn', structuredRequest(parent)) const result = await run.result expect(result.structured).toEqual({ answer: 3 }) const request = adapter.requests[0]! @@ -759,7 +739,7 @@ describe('in-process structured output', () => { toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 4 }), ]) expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined() - const run = ctx.subagents.start('spawn', structuredRequest(parent)) + const run = await ctx.subagents.start('spawn', structuredRequest(parent)) // A backend hot-reload mid-run must not unregister the capture tool out // from under the live child: the registration rides the CHILD's fiber. await disposeProvider() @@ -800,7 +780,7 @@ describe('in-process structured output', () => { const { ctx, parent } = await setup([ toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }), ]) - const run = ctx.subagents.start('spawn', structuredRequest(parent)) + const run = await ctx.subagents.start('spawn', structuredRequest(parent)) // A prepended post-execute listener blocks the first capture without // delegating. The final-result notification discards that execution's // stage when it observes the error. @@ -841,7 +821,7 @@ describe('in-process structured output', () => { const { ctx, parent } = await setup([ toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }), ]) - const run = ctx.subagents.start('spawn', structuredRequest(parent)) + const run = await ctx.subagents.start('spawn', structuredRequest(parent)) // Block the first capture after its body stages a value. Its final error // discards that execution's stage. let blocks = 1 @@ -879,7 +859,7 @@ describe('in-process structured output', () => { const { ctx, parent } = await setup([ toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }), ]) - const run = ctx.subagents.start('spawn', structuredRequest(parent)) + const run = await ctx.subagents.start('spawn', structuredRequest(parent)) // Discard the first capture's stage via a final post-execute block. let blocks = 1 ctx.on('tools/post-execute', (exec, _result, next) => { diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index 8f710237ab..0005246523 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -1,25 +1,18 @@ -import { describe, expect, it, vi } from 'vitest' -import { Context, type Fiber } from 'cordis' +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore, { type SessionEvent } from '@deepseek-ai/dsh-session' +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, type Agent } 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 SubagentService from '@deepseek-ai/dsh-subagent' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' -import { depthOf, type InProcessRunOptions, SubagentDepthError, startInProcessRun } from '../src/index.ts' +import { depthOf, SubagentDepthError, startInProcessRun } from '../src/index.ts' type Script = ConstructorParameters[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,393 +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([ - { 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 fraction', value: 1.5 }, - { label: 'a negative integer', value: -1 }, - { label: 'negative zero', value: -0 }, - { label: 'an unsafe integer', value: Number.MAX_SAFE_INTEGER + 1 }, - ])('rejects subagentDepth=$label', ({ value }) => { - const agent = { options: { subagentDepth: value } } as unknown as Agent - expect(() => depthOf(agent)).toThrow('agent subagentDepth must be a non-negative safe integer') + 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.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 fraction', value: 1.5 }, - { label: 'a negative integer', value: -1 }, - { label: 'negative zero', value: -0 }, - { label: 'an unsafe integer', value: Number.MAX_SAFE_INTEGER + 1 }, - ])('rejects maxDepth=$label before acquiring run ownership', async ({ value }) => { - const { ctx, parent } = await setup([]) - expect(() => startInProcessRun(ctx, { - prompt: [{ type: 'text', text: 'must never start' }], - parent, - maxDepth: value, - }, {})).toThrow('subagent maxDepth must be a non-negative safe integer') - }) - - it('rejects a non-string persona before acquiring run ownership', async () => { - const { ctx, parent } = await setup([]) - expect(() => startInProcessRun(ctx, { - prompt: [{ type: 'text', text: 'must never start' }], - parent, - persona: 42 as unknown as string, - }, {})).toThrow('subagent persona must be a string') - }) - - it('rejects a child depth with no safe-integer representation before acquiring run ownership', async () => { - const { ctx } = await setup([]) - const parent = { - options: { subagentDepth: Number.MAX_SAFE_INTEGER }, - } as unknown as Agent - - expect(() => startInProcessRun(ctx, { - prompt: [{ type: 'text', text: 'must never start' }], - parent, - }, {})).toThrow(RangeError) - }) - - it('rejects a non-JSON prompt before acquiring any run ownership', async () => { - const { ctx, parent } = await setup([]) - expect(() => startInProcessRun(ctx, { - prompt: [{ type: 'text', text: Number.NaN as unknown as string }], - parent, - }, {})).toThrow('subagent prompt must be losslessly JSON-serializable') - }) - - it('reads each prompt value once before asynchronous child creation', async () => { - const { ctx, parent } = await setup([]) - let reads = 0 - const prompt = [{ - type: 'text' as const, - get text(): string { - reads += 1 - return reads === 1 ? 'valid during pre-check' : Number.NaN as unknown as string - }, - }] - - const run = startInProcessRun(ctx, { prompt, parent }, {}) - expect(reads).toBe(1) - await run.dispose() - }) - - it('reads each public request and seed option field once', async () => { - const { ctx, parent } = await setup([]) - const reads = { prompt: 0, toolFilter: 0, maxDepth: 0, outputSchema: 0, agentOptions: 0, persona: 0, seed: 0 } - const request = Object.defineProperties({ parent }, { - prompt: { enumerable: true, get: () => { reads.prompt += 1; return [{ type: 'text', text: 'accepted' }] } }, - toolFilter: { enumerable: true, get: () => { reads.toolFilter += 1; return reads.toolFilter === 1 ? undefined : { deny: ['ghost'] } } }, - maxDepth: { enumerable: true, get: () => { reads.maxDepth += 1; return reads.maxDepth === 1 ? undefined : 0 } }, - outputSchema: { enumerable: true, get: () => { reads.outputSchema += 1; return undefined } }, - agentOptions: { enumerable: true, get: () => { reads.agentOptions += 1; return {} } }, - persona: { enumerable: true, get: () => { reads.persona += 1; return undefined } }, - }) as unknown as SubagentStartRequest - const options = Object.defineProperty({}, 'seed', { - enumerable: true, - get: () => { reads.seed += 1; return reads.seed === 1 ? undefined : [] }, - }) as InProcessRunOptions - - const run = startInProcessRun(ctx, request, options) - - expect(reads).toEqual({ prompt: 1, toolFilter: 1, maxDepth: 1, outputSchema: 1, agentOptions: 1, persona: 1, seed: 1 }) - await run.dispose() - }) - - it('rejects an exotic seed before asynchronous owner setup can sanitize it', async () => { - const { ctx, parent } = await setup([]) - class ExoticSeedEvent { - readonly type = 'turn/start' - readonly seq = 0 - readonly time = 1 - readonly data = { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } - } - - expect(() => startInProcessRun(ctx, { - prompt: [{ type: 'text', text: 'accepted' }], - parent, - }, { seed: [new ExoticSeedEvent()] as unknown as SessionEvent[] })) - .toThrow(/subagent seed must be losslessly JSON-serializable/) - }) - - it.each([ - { - label: 'tool filter', - overrides: { toolFilter: { deny: [Number.NaN as unknown as string] } }, - message: 'subagent tool filter must be losslessly JSON-serializable', - }, - { - label: 'agent options', - overrides: { agentOptions: { model: Number.NaN as unknown as string } }, - message: 'subagent agent options must be losslessly JSON-serializable', - }, - { - label: 'output schema', - overrides: { - outputSchema: { - type: 'object', - properties: { answer: { type: Number.NaN } }, - } as unknown as NonNullable, - }, - message: 'schema annotation must be JSON data', - }, - ])('rejects non-JSON $label before asynchronous child creation', async ({ overrides, message }) => { - const { ctx, parent } = await setup([]) - - expect(() => startInProcessRun(ctx, { - prompt: [{ type: 'text', text: 'accepted' }], - parent, - ...overrides, - }, {})).toThrow(message) - }) - - it('rejects a non-JSON model inherited from the parent before child creation', async () => { - const { ctx, parent } = await setup([]) - const invalidParent = { - options: { ...parent.options, model: Number.NaN as unknown as string }, - session: parent.session, - } as unknown as Agent - - expect(() => startInProcessRun(ctx, { - prompt: [{ type: 'text', text: 'accepted' }], - parent: invalidParent, - }, {})).toThrow('subagent agent options must be losslessly JSON-serializable') - }) - - it('rejects when the run-owner fiber settles without installing its context', async () => { - const { ctx, parent } = await setup([]) - function inertOwner(): void {} - const inertFiber = ctx.plugin(inertOwner) - await inertFiber - const parentWithoutOwnerContext = { - options: parent.options, - session: parent.session, - ctx: { plugin: () => inertFiber }, - } as unknown as Agent - - const run = startInProcessRun(ctx, { - prompt: [{ type: 'text', text: 'must never start' }], - parent: parentWithoutOwnerContext, - }, {}) - await expect(run.result).rejects.toThrow('subagent run owner became inactive before child creation') - await run.dispose() - }) - - it('normalizes a non-Error thrown while installing the run-owner fiber', async () => { - const { ctx, parent } = await setup([]) - const setupFailure = 'non-Error owner setup failure' - const parentWithFailingOwnerSetup = { - options: parent.options, - session: parent.session, - ctx: { plugin: () => { throw setupFailure } }, - } as unknown as Agent - - const run = startInProcessRun(ctx, { - prompt: [{ type: 'text', text: 'must never start' }], - parent: parentWithFailingOwnerSetup, - }, {}) - await expect(run.result).rejects.toMatchObject({ - message: 'subagent run owner setup failed with a non-Error value', - cause: setupFailure, - }) - await run.dispose() - }) - - it('normalizes a non-Error rejected by asynchronous child creation', async () => { - const { ctx, parent } = await setup([]) - const creationFailure = 'non-Error child creation failure' - function inertOwner(): void {} - const ownerFiber = ctx.plugin(inertOwner) - await ownerFiber - const rejectWithNonError = (): Promise => { - // Deliberately violate the promise contract to exercise boundary normalization. - // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors - return Promise.reject(creationFailure) - } - const rejectingOwnerCtx = { - agents: { create: rejectWithNonError }, - } as unknown as Context - const parentWithRejectingFactory = { - options: parent.options, - session: parent.session, - ctx: { - plugin(plugin: (inner: Context) => void) { - plugin(rejectingOwnerCtx) - return ownerFiber - }, - }, - } as unknown as Agent - - const run = startInProcessRun(ctx, { - prompt: [{ type: 'text', text: 'must never start' }], - parent: parentWithRejectingFactory, - }, {}) - await expect(run.result).rejects.toMatchObject({ - message: 'subagent child creation failed with a non-Error value', - cause: creationFailure, - }) - await run.dispose() - }) - - it('follows owner-fiber inertia when raw teardown was already in flight', async () => { - const { ctx, parent } = await setup([]) - const gate = Promise.withResolvers() - let inertia: Promise | undefined = gate.promise - const fakeFiber = { - dispose: vi.fn(() => undefined), - get inertia() { return inertia }, - } as unknown as Fiber & PromiseLike - const rejectingOwnerCtx = { - agents: { create: () => Promise.reject(new Error('creation stopped by teardown')) }, - } as unknown as Context - const parentWithDisposingOwner = { - options: parent.options, - session: parent.session, - ctx: { - plugin(plugin: (inner: Context) => void) { - plugin(rejectingOwnerCtx) - return fakeFiber - }, - }, - } as unknown as Agent - const run = startInProcessRun(ctx, { - prompt: [{ type: 'text', text: 'must never start' }], - parent: parentWithDisposingOwner, - }, {}) - - let settled = false - const disposing = run.dispose().then(() => { settled = true }) - await Promise.resolve() - await Promise.resolve() - expect(fakeFiber.dispose).toHaveBeenCalledOnce() - expect(settled).toBe(false) - - inertia = undefined - gate.resolve(undefined) - await disposing - await expect(run.result).resolves.toEqual({ output: [], stopReason: 'aborted' }) - }) - - it('observes detached pre-readiness teardown failure and reports it to explicit dispose', async () => { - const { ctx, parent } = await setup([]) - function inertOwner(): void {} - const ownerFiber = ctx.plugin(inertOwner) - await ownerFiber - const disposeFailure = new Error('owner dispose exploded') - const disposeSpy = vi.spyOn(ownerFiber, 'dispose').mockImplementation(() => { throw disposeFailure }) - const rejectingOwnerCtx = { - agents: { create: () => Promise.reject(new Error('creation stopped by cancellation')) }, - } as unknown as Context - const parentWithFailingTeardown = { - options: parent.options, - session: parent.session, - ctx: { - plugin(plugin: (inner: Context) => void) { - plugin(rejectingOwnerCtx) - return ownerFiber - }, - }, - } as unknown as Agent - const run = startInProcessRun(ctx, { - prompt: [{ type: 'text', text: 'must never start' }], - parent: parentWithFailingTeardown, - }, {}) - - run.cancel('cancel before readiness') - await expect(run.result).resolves.toEqual({ output: [], stopReason: 'aborted' }) - await expect(run.dispose()).rejects.toBe(disposeFailure) - disposeSpy.mockRestore() - await ownerFiber.dispose() - }) - - it('does not attach an abort listener when provider ownership is already inactive', async () => { - const { ctx, parent } = await setup([]) - let providerCtx: Context | undefined - function provider(inner: Context): void { providerCtx = inner } - const providerFiber = await ctx.plugin(provider) - await providerFiber.dispose() - if (providerCtx === undefined) throw new Error('provider context was not captured') - const inactiveProviderCtx = providerCtx - - const controller = new AbortController() - const addListener = vi.spyOn(controller.signal, 'addEventListener') - expect(() => startInProcessRun(inactiveProviderCtx, { - prompt: [{ type: 'text', text: 'must never start' }], - parent, - signal: controller.signal, - }, {})).toThrow(/inactive context/) - expect(addListener).not.toHaveBeenCalled() - }) - - it('drives a fresh child (no seed) to completion and returns its output', async () => { - const { ctx, parent } = await setup([textResponse('driver child answer')]) - const run = startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'do X' }], parent }, {}) + it('returns only after publication, drives a fresh child, and disposes it', async () => { + const { ctx, parent } = await setup([textResponse('driver answer')]) + const run = await startInProcessRun(request(parent), {}) + expect(ctx.agents.get(run.id)).toBeDefined() const result = await run.result expect(result.stopReason).toBe('completed') - expect(text(result.output)).toBe('driver child answer') + expect(text(result.output)).toBe('driver answer') expect(depthOf(ctx.agents.get(run.id)!)).toBe(1) await run.dispose() - }) - - it('snapshots the prompt before asynchronous child creation', async () => { - const { ctx, parent } = await setup([textResponse('done')]) - const prompt = [{ type: 'text' as const, text: 'original prompt' }] - const run = startInProcessRun(ctx, { prompt, parent }, {}) - - prompt[0]!.text = 'mutated after start' - prompt.push({ type: 'text', text: 'also injected' }) - await run.result - - const child = ctx.agents.get(run.id)! - const userMessage = child.session.events.find(event => event.type === 'user/message') - expect(userMessage?.type === 'user/message' && userMessage.data.content) - .toEqual([{ type: 'text', text: 'original prompt' }]) await run.dispose() + expect(ctx.agents.get(run.id)).toBeUndefined() }) - it('throws SubagentDepthError when the child would exceed maxDepth', async () => { - const { ctx, parent } = await setup([]) - expect(() => startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'p' }], parent, maxDepth: 0 }, {})) - .toThrow(SubagentDepthError) - }) - - it('seeds the child session when a seed is supplied', async () => { - // Drive the parent through one real turn, then seed the child with that - // completed-turn prefix — the child must SEE the parent's history but its - // result is scoped to its OWN events (not the seeded parent message). - const { ctx, parent } = await setup([textResponse('parent turn'), textResponse('seeded child reply')]) - parent.send([{ type: 'text', text: 'parent q' }]) + it('seeds a forked child but reads only the child-owned output', async () => { + const { ctx, parent } = await setup([textResponse('parent answer'), textResponse('child answer')]) + parent.send([{ type: 'text', text: 'parent question' }]) await parent.whenIdle() const seed = parent.session.events.slice() - const run = startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'child q' }], parent }, { seed }) + const run = await startInProcessRun(request(parent), { seed }) const result = await run.result - expect(result.stopReason).toBe('completed') - expect(text(result.output)).toBe('seeded child reply') + expect(text(result.output)).toBe('child answer') const child = ctx.agents.get(run.id)! - // The child inherited the parent's prefix. - expect(child.session.events.slice(0, seed.length).some(e => e.type === 'user/message')).toBe(true) + expect(child.session.header.seedLength).toBe(seed.length) + expect(child.session.events.slice(0, seed.length)).toEqual(seed) await run.dispose() }) + + it('rejects invalid and exceeded depth before publication', async () => { + const { parent } = await setup([]) + await expect(startInProcessRun({ ...request(parent), maxDepth: -1 }, {})) + .rejects.toThrow('non-negative safe integer') + await expect(startInProcessRun({ ...request(parent), maxDepth: 0 }, {})) + .rejects.toBeInstanceOf(SubagentDepthError) + const maxParent = { options: { subagentDepth: Number.MAX_SAFE_INTEGER } } as unknown as Agent + await expect(startInProcessRun(request(maxParent), {})).rejects.toBeInstanceOf(RangeError) + }) + + it('rejects an already-aborted request without publishing a child', async () => { + const { ctx, parent } = await setup([]) + const beforeAgents = ctx.agents.list().length + const beforeSessions = ctx.sessions.list().length + const controller = new AbortController() + controller.abort('too late') + await expect(startInProcessRun(request(parent, controller.signal), {})) + .rejects.toThrow('aborted before child publication') + expect(ctx.agents.list()).toHaveLength(beforeAgents) + expect(ctx.sessions.list()).toHaveLength(beforeSessions) + }) + + it('uses the request signal after publication and dispose as cancellation paths', async () => { + const { parent } = await setup(['hang', 'hang']) + const controller = new AbortController() + const signalled = await startInProcessRun(request(parent, controller.signal), {}) + await new Promise(resolve => setTimeout(resolve, 30)) + controller.abort('stop child') + await expect(signalled.result).resolves.toMatchObject({ stopReason: 'aborted' }) + await signalled.dispose() + + const disposed = await startInProcessRun(request(parent), {}) + await new Promise(resolve => setTimeout(resolve, 30)) + await disposed.dispose() + await expect(disposed.result).resolves.toMatchObject({ stopReason: 'aborted' }) + }) + + it('cleans a failed unpublished setup before rejecting', async () => { + const { ctx, parent } = await setup([]) + const beforeAgents = ctx.agents.list().length + const beforeSessions = ctx.sessions.list().length + await expect(startInProcessRun({ + ...request(parent), + toolFilter: { deny: ['unknown-tool'] }, + }, {})).rejects.toThrow('unknown global tool') + expect(ctx.agents.list()).toHaveLength(beforeAgents) + expect(ctx.sessions.list()).toHaveLength(beforeSessions) + }) + + it('closes the abort handoff after the factory detaches its creation listener', async () => { + const { ctx, parent } = await setup([]) + const controller = new AbortController() + const beforeAgents = ctx.agents.list().length + const beforeSessions = ctx.sessions.list().length + const parentWithAbortAtHandoff = { + options: parent.options, + session: parent.session, + ctx: { + agents: { + create: async (options: Parameters[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) + }) }) diff --git a/packages/subagent/subagent-spawn/README.md b/packages/subagent/subagent-spawn/README.md index 07c237b303..3c0f739ecf 100644 --- a/packages/subagent/subagent-spawn/README.md +++ b/packages/subagent/subagent-spawn/README.md @@ -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, {})` with no seed: a fresh child agent with the parent's `cwd`/`parentSession` lineage and (by default) the parent's model. The driver creates one run-owner fiber under `parent.ctx`; parent teardown, this provider's teardown, manual disposal, and cancellation before readiness all converge there before child publication. Its `run.started` boundary resolves only after the fresh child is published, so `subagent/start` observers see a live registry entry; a same-tick cancel deactivates the unpublished transaction instead, rejects readiness, resolves the result as `aborted`, and emits no agent/session or subagent lifecycle. 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: true, persona: true }`. It constructs the child, so it enforces a recursion cap and composes the child's persona, global-tool restriction, and [structured runtime](../subagent-inprocess/README.md) inside the agent-creation setup window. At apply it registers this one named provider on `ctx.subagents`; per-run contributions belong to each child's scope. +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 diff --git a/packages/subagent/subagent-spawn/src/index.ts b/packages/subagent/subagent-spawn/src/index.ts index 835cf34aae..6d2d8f3a11 100644 --- a/packages/subagent/subagent-spawn/src/index.ts +++ b/packages/subagent/subagent-spawn/src/index.ts @@ -53,16 +53,16 @@ class SpawnProvider implements SubagentProvider { // Context contract: a spawned child starts fresh — it never sees the parent conversation. readonly inheritsParentContext = false - constructor(readonly name: string, private readonly ctx: Context) {} + constructor(readonly name: string) {} start(request: SubagentStartRequest) { // Fresh child: no seed. The shared driver mints ids, stamps cwd/lineage/ // depth, drives the one-shot (including the structured capture when the // request carries an outputSchema), and maps the result. - return startInProcessRun(this.ctx, request, {}) + return startInProcessRun(request, {}) } } export function apply(ctx: Context, config: Config): void { - ctx.subagents.registerProvider(new SpawnProvider(config.providerName, ctx)) + ctx.subagents.registerProvider(new SpawnProvider(config.providerName)) } diff --git a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts index f00eddeb25..9e4e489882 100644 --- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts +++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts @@ -9,7 +9,7 @@ import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as Invariants from '@deepseek-ai/dsh-invariants' -import SubagentService from '@deepseek-ai/dsh-subagent' +import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent' import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import * as spawn from '../src/index.ts' import { depthOf, STRUCTURED_OUTPUT_TOOL, SubagentDepthError } from '@deepseek-ai/dsh-subagent-inprocess' @@ -44,11 +44,15 @@ function text(blocks: { type: string; text?: string }[]): string { return blocks.filter(b => b.type === 'text').map(b => b.text).join('') } +function start(ctx: Context, provider: string, request: Omit & { signal?: AbortSignal }) { + return ctx.subagents.start(provider, { signal: request.signal ?? new AbortController().signal, ...request }) +} + describe('dsh-subagent-spawn', () => { it('runs a fresh child to completion and returns its final assistant output', async () => { // One model call for the child: a plain text answer. const { ctx, parent } = await setup([textResponse('child answer')]) - const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'do X' }], parent }) + const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'do X' }], parent }) const result = await run.result expect(result.stopReason).toBe('completed') expect(text(result.output)).toBe('child answer') @@ -62,11 +66,11 @@ describe('dsh-subagent-spawn', () => { if (info.provider === 'spawn') childAtStart = ctx.agents.get(info.id) }) - const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'do X' }], parent }) + const starting = start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'do X' }], parent }) // Creation is asynchronous; no lifecycle claim is made while the child is // still inside its unpublished setup transaction. expect(childAtStart).toBeUndefined() - await run.started + const run = await starting expect(childAtStart).toBe(ctx.agents.get(run.id)) expect(childAtStart?.id).toBe(run.id) @@ -76,7 +80,7 @@ describe('dsh-subagent-spawn', () => { it('gives the child its OWN session (not the parent\'s), with parentSession lineage', async () => { const { ctx, parent } = await setup([textResponse('hi')]) - const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent }) + const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent }) await run.result const child = ctx.agents.get(run.id)! expect(child.session.header.id).not.toBe(parent.session.header.id) @@ -92,7 +96,7 @@ describe('dsh-subagent-spawn', () => { const parentEventCount = parent.session.events.length expect(parentEventCount).toBeGreaterThan(0) - const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'child prompt' }], parent }) + const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'child prompt' }], parent }) await run.result const child = ctx.agents.get(run.id)! // The child's first user/message is its OWN prompt, not the parent's history. @@ -103,7 +107,7 @@ describe('dsh-subagent-spawn', () => { it('disposes the child to quiescence (agent removed from the registry)', async () => { const { ctx, parent } = await setup([textResponse('x')]) - const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent }) + const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent }) await run.result expect(ctx.agents.get(run.id)).toBeDefined() await run.dispose() @@ -114,7 +118,7 @@ describe('dsh-subagent-spawn', () => { it('stamps child depth = parent depth + 1 (via the merged AgentOptions field)', async () => { const { ctx, parent } = await setup([textResponse('x')]) expect(depthOf(parent)).toBe(0) - const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent }) + const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent }) await run.result const child = ctx.agents.get(run.id)! expect(depthOf(child)).toBe(1) @@ -124,13 +128,13 @@ describe('dsh-subagent-spawn', () => { it('refuses to spawn past maxDepth (depthLimit capability)', async () => { const { ctx, parent } = await setup([]) // parent is depth 0, child would be depth 1 — cap at 0 forbids any child. - expect(() => ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent, maxDepth: 0 })) - .toThrow(SubagentDepthError) + await expect(start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent, maxDepth: 0 })) + .rejects.toThrow(SubagentDepthError) }) it('maps a child that hit its token ceiling to stopReason "max-tokens"', async () => { const { ctx, parent } = await setup([maxTokensResponse('cut off')]) - const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent }) + const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent }) const result = await run.result expect(result.stopReason).toBe('max-tokens') await run.dispose() @@ -140,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 @@ -156,15 +160,12 @@ 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('same-tick cancellation rejects readiness and prevents child publication', async () => { - // Regression: cancellation before readiness used to set a flag but let the + 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. @@ -177,14 +178,12 @@ describe('dsh-subagent-spawn', () => { 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 run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent }) - run.cancel('early') + const controller = new AbortController() + const starting = start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent, signal: controller.signal }) + controller.abort('early') - await expect(run.started).rejects.toThrow() - await expect(run.result).resolves.toEqual({ output: [], stopReason: 'aborted' }) - await run.dispose() + await expect(starting).rejects.toThrow() await Promise.resolve() - expect(ctx.agents.get(run.id)).toBeUndefined() expect(ctx.agents.list()).toHaveLength(beforeAgents) expect(ctx.sessions.list()).toHaveLength(beforeSessions) expect(published).toEqual([]) @@ -192,10 +191,9 @@ describe('dsh-subagent-spawn', () => { it('a cancel from agent/queued maps a no-turn child log to aborted', async () => { const { ctx, parent } = await setup([]) - ctx.on('agent/queued', (agent) => { - if (agent.id === run.id) run.cancel('queued-window') - }) - const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent }) + const controller = new AbortController() + ctx.on('agent/queued', () => { controller.abort('queued-window') }) + const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent, signal: controller.signal }) const result = await run.result expect(result).toMatchObject({ stopReason: 'aborted', output: [] }) const child = ctx.agents.get(run.id)! @@ -203,30 +201,11 @@ describe('dsh-subagent-spawn', () => { await run.dispose() }) - it('dispose during async child creation waits for rollback and leaves no orphan', async () => { - const { ctx, parent } = await setup([]) - 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')) - const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent }) - - // Same tick: the factory has reserved ids and entered its async setup - // transaction, but has not published the child yet. - await run.dispose() - await expect(run.result).resolves.toMatchObject({ stopReason: 'aborted', output: [] }) - expect(ctx.agents.list()).toHaveLength(beforeAgents) - expect(ctx.sessions.list()).toHaveLength(beforeSessions) - expect(published).toEqual([]) - }) - it('cancelling a running child settles the run as aborted (the abort bridge + cancel())', async () => { // 'hang' makes the child's model stream one chunk then wait until aborted. const controller = new AbortController() const { ctx, parent } = await setup(['hang']) - const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent, signal: controller.signal }) + const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent, signal: controller.signal }) // Let the child's turn start, then abort via the request signal (the // backend bridges it to child.cancel()). await new Promise(r => setTimeout(r, 30)) @@ -236,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 @@ -274,7 +242,7 @@ describe('dsh-subagent-spawn', () => { meta: { cwd: '/tmp/parent-workspace' }, agentOptions: { model: 'mock' }, }) - const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent: parentHandle.agent }) + const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent: parentHandle.agent }) await run.result const child = ctx.agents.get(run.id)! expect(child.session.header.cwd).toBe('/tmp/parent-workspace') @@ -291,7 +259,7 @@ describe('dsh-subagent-spawn', () => { agentOptions: {}, }) // The request supplies the child's model explicitly. - const run = ctx.subagents.start('spawn', { + const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent: parentHandle.agent, agentOptions: { model: 'mock' }, @@ -323,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'] }, @@ -336,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']) @@ -351,52 +319,27 @@ describe('dsh-subagent-spawn', () => { const fiber = await ctx.plugin(spawn, { providerName: 'spawn' }) ctx.llm.registerAdapter(['mock'], adapter) const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' }) - const run = ctx.subagents.start('spawn', { + const controller = new AbortController() + const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'q' }], parent, + signal: controller.signal, outputSchema: { type: 'object', properties: { a: { type: 'number' } } }, }) - // Let the child's step start streaming, then unload the backend. 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 backend unload during child creation prevents every publication notification', async () => { - const ctx = new Context() - await ctx.plugin(LlmService) - await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - await ctx.plugin(AgentRegistry) - await ctx.plugin(Invariants) - await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(SubagentService) - const fiber = await ctx.plugin(spawn, { providerName: 'spawn' }) - ctx.llm.registerAdapter(['mock'], new MockAdapter([])) - const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' }) - const published: string[] = [] - ctx.on('session/created', () => void published.push('session/created')) - ctx.on('agent/created', () => void published.push('agent/created')) - ctx.on('agent/session-start', () => void published.push('agent/session-start')) - - const run = ctx.subagents.start('spawn', { - prompt: [{ type: 'text', text: 'must never run' }], parent, - }) - await fiber.dispose() - await run.result.catch(() => undefined) - await run.dispose() - - expect(ctx.agents.get(run.id)).toBeUndefined() - expect(published).toEqual([]) - }) - - it('a start racing an already-unloading backend cannot mint a run-owner fiber', async () => { + it('a start racing an already-unloading backend cannot begin child creation', async () => { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(SessionStore) @@ -413,10 +356,10 @@ describe('dsh-subagent-spawn', () => { ctx.on('agent/created', () => void published.push('agent/created')) const unloading = fiber.dispose() - expect(() => ctx.subagents.start('spawn', { - prompt: [{ type: 'text', text: 'must never start' }], parent, - })).toThrow(/inactive context/) await unloading + await expect(start(ctx, 'spawn', { + prompt: [{ type: 'text', text: 'must never start' }], parent, + })).rejects.toThrow(/no subagent provider/) expect(parent.ctx.fiber.getEffects()).toHaveLength(parentEffects) expect(published).toEqual([]) @@ -443,7 +386,7 @@ describe('dsh-subagent-spawn', () => { parent.send([{ type: 'text', text: 'hi' }]) await parent.whenIdle() - const run = ctx.subagents.start('spawn', { + const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'do X' }], parent, persona: 'You are the tersest test runner.', @@ -466,7 +409,7 @@ describe('dsh-subagent-spawn', () => { name: 'forbidden_tool', description: 'global', parameters: {}, execute: () => Promise.resolve([{ type: 'text', text: 'ran' }]), }) - const run = ctx.subagents.start('spawn', { + const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'do X' }], parent, toolFilter: { deny: ['forbidden_tool'] }, @@ -486,13 +429,11 @@ describe('dsh-subagent-spawn', () => { it('an unknown toolFilter name fails the spawn loudly with no orphaned child', async () => { const { ctx, parent } = await setup([]) const before = ctx.agents.list().length - const run = ctx.subagents.start('spawn', { + await expect(start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'do X' }], parent, toolFilter: { deny: ['no_such_tool'] }, - }) - await expect(run.result).rejects.toThrow(/unknown tool "no_such_tool"/) - await run.dispose() + })).rejects.toThrow(/unknown global tool "no_such_tool"/) expect(ctx.agents.list().length).toBe(before) }) }) @@ -512,12 +453,10 @@ describe('dsh-subagent-spawn', () => { ctx.on('session/created', () => void published.push('session/created')) ctx.on('agent/created', () => void published.push('agent/created')) ctx.on('agent/session-start', () => void published.push('agent/session-start')) - const run = ctx.subagents.start('spawn', { + await expect(start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'do X' }], parent: parentHandle.agent, - }) - await expect(run.result).rejects.toThrow(/inactive context/) - await run.dispose() + })).rejects.toThrow(/inactive context/) expect(ctx.agents.list().length).toBe(before) expect(ctx.sessions.list()).toHaveLength(sessionsBefore) expect(published).toEqual([]) @@ -535,18 +474,16 @@ describe('dsh-subagent-spawn', () => { ctx.on('agent/created', () => void published.push('agent/created')) ctx.on('agent/session-start', () => void published.push('agent/session-start')) - const run = ctx.subagents.start('spawn', { + const starting = start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'must never run' }], parent: parentHandle.agent, }) - // The factory has entered its awaited unpublished setup transaction. Parent - // ownership was installed before that await, so disposal wins without an + // The factory has entered its awaited unpublished setup transaction. The + // parent context owns that transaction, so disposal wins without an // observer ever seeing the child. await parentHandle.dispose() - await expect(run.result).rejects.toThrow(/owner disposed during setup|inactive context/) - await run.dispose() + await expect(starting).rejects.toThrow(/owner disposed during setup|inactive context/) - expect(ctx.agents.get(run.id)).toBeUndefined() expect(published).toEqual([]) }) }) diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 6470b64ee5..413bbbdafc 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -1,44 +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)` | Read and validate the name, capability object and four boolean flags, `inheritsParentContext`, and `start` callback exactly once, then register a frozen acceptance snapshot under the accepted name. Malformed fixed fields fail loud before registration; later caller mutation cannot change registry behavior or HMR cleanup, while `start` stays bound to the original provider receiver. Throws `SubagentError('DUPLICATE_PROVIDER')` on a name clash. Effect-scoped (HMR-safe); returns the disposer. | -| `assertSubagentMaxDepth(value)` | Shared runtime boundary for recursion caps. Accepts absence or a non-negative safe integer; rejects fractions, non-finite numbers, negative values, negative zero, and unsafe integers. The service, direct in-process driver, and model-facing config adapter all use it. | -| `getProvider(name)` | Look up the frozen registry snapshot (`undefined` if absent). | -| `list()` | Registered provider names (insertion order). | -| `start(name, request)` | Resolve the provider (`NO_PROVIDER` if absent), read every caller field once into one acceptance snapshot, validate every requested START-TIME capability and scalar value before any child is created, and materialize prompt/schema/options/filter data through a single-pass lossless-JSON snapshot before delegating to `provider.start`. Acquire the provider run's disposer before reading the rest of its handle, then return a frozen service-owned wrapper whose fields are captured once, whose methods remain bound to the provider handle, and whose `result` is one detached, deeply frozen normalization shared by the caller and telemetry. The wrapper claims its shared disposal promise before invoking raw provider code, so synchronous reentry and ordinary repeats join one provider call; a raw disposer that directly returns that same reentrant wrapper promise is rejected as a cyclic provider contract instead of hanging forever. Malformed handle access/binding starts rollback before the synchronous fault escapes; malformed terminal data rejects only after rollback reaches quiescence. Emit `subagent/start` only after `run.started` fulfills and the paired `subagent/end` after that started run settles; a pre-publication readiness rejection emits neither. | +| `registerProvider(provider)` | Register one trusted same-process implementation by name. Registration is effect-scoped; removing it prevents new starts but does not revoke runs already returned to callers. Duplicate names fail loud. | +| `getProvider(name)` | Return the provider, or `undefined` when absent. | +| `list()` | Return provider names in insertion order. | +| `start(name, request)` | Validate requested capabilities and semantic values, then await the provider until a real child is ready. Fulfillment returns a holder-owned `SubagentRun`; rejection means the provider has already cleaned every partial startup resource. | -## Capabilities: two kinds, discovered two ways +`SubagentStartRequest.signal` is required and is the canonical cancellation channel. An abort before publication makes `start()` reject after rollback; an abort after publication cancels the live child. The request may also select a model, require structured output, cap delegation depth, restrict child tools, or set a child persona. -- **Start-time features** (`outputSchema`, `depthLimit`, `toolFilter/persona`) are a static `provider.capabilities` descriptor, checked by the service BEFORE a run exists. A request that needs one the provider lacks is **rejected loud** (`UNSUPPORTED_CAPABILITY`), never accepted-then-ignored. -- **Runtime features** (steering, resume) are **optional methods** on `SubagentRun` (`sendMessage?`, `resume?`). The method's presence IS the capability; TS narrowing is the discovery mechanism — a consumer cannot call an absent method without narrowing first, so there is no silent degradation path. +Same-process requests, descriptors, results, and event payloads are trusted typed values borrowed as immutable. The service does not clone or freeze them; serialization and hostile-input validation belong at actual process, worker, persistence, and model boundaries. -Beside `capabilities` sits one DESCRIPTIVE fact: `provider.inheritsParentContext` — whether a child sees the parent conversation (`fork`: true — seeded with the completed-turn prefix; `spawn`/`acp`: false). “Context” here means conversation history only; it says nothing about tool registrations, injected services, or authority inheritance. The service validates that the descriptor is a boolean but does not interpret or enforce its meaning; 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 provider-owned `SubagentRun`; `SubagentService.start` captures that handle once and returns a frozen service-owned wrapper with `started` (the publication/readiness promise), a normalized `result` (the terminal outcome), bound `cancel()` and `dispose()`, and bound optional runtime methods. `started` resolves only after the provider has established a real child and rejects if the attempt fails or is cancelled first. `result` resolves with one detached, deeply frozen `SubagentResult` (`output`, optional `structured`, `stopReason`) that the service and caller share — it does **not** reject on a child-level failure (a model/transport failure resolves with `stopReason: 'error'`), but malformed provider data rejects as an infrastructure contract fault. The consumer maps a non-`completed` reason to an `isError` tool result and 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 frozen registry snapshot) fires after a registration and `subagent/provider-removed` (the accepted 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". Run lifecycle is gated by provider readiness: the service captures the provider handle's public fields once, `subagent/start` (payload `SubagentRunInfo`) fires only after the accepted `started` promise fulfills, and `subagent/end` (payload `SubagentRunEndInfo`) uses the same accepted id and normalized result; readiness rejection emits neither. For spawn/fork, the start listener can resolve the published child via `ctx.agents.get(info.id)`; a remote provider need not have a local registry entry. Both events are **observe-only** plain emits whose service-owned payloads are deeply frozen before per-listener dispatch. A synchronous listener throw or returned-promise rejection is logged per listener; later listeners still run synchronously, and async listeners remain concurrent fire-and-forget. The service observes the normalized `result` immediately even while readiness is pending and buffers that end payload until start has fired; a malformed provider result rejects the returned result promise and becomes contained `error` telemetry, a rejection cannot become an unhandled detached promise, start always precedes end, and one listener cannot corrupt either the caller or later listeners. `subagent/end` carries the same frozen output as `lastAssistantMessage` on a valid settle path and omits it on infrastructure or result-contract failure. Any run-affecting decision 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, and spill semantics are outside this seam; long-running-tool handling is shared work 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` 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. diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index cf1ebf9485..eb0dbf8da0 100644 --- a/packages/subagent/subagent/package.json +++ b/packages/subagent/subagent/package.json @@ -25,7 +25,6 @@ "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-scope": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.6" }, @@ -33,7 +32,6 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "cordis": "^4.0.0-rc.6" } diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index ba44354358..559825d404 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -1,44 +1,23 @@ /** * 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 { assertSupportedOutputSchema, OutputSchemaError } from '@deepseek-ai/dsh-tools' import type { Scoped } from '@deepseek-ai/dsh-scope' -import { deepFreeze, HarnessError } from '@deepseek-ai/dsh-llm' +import { assertSupportedOutputSchema } from '@deepseek-ai/dsh-tools' +import { HarnessError } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import { snapshotJsonValue } from '@deepseek-ai/dsh-session' import type { Agent, AgentId } from '@deepseek-ai/dsh-agent' import type { SubagentCapabilities, @@ -60,10 +39,6 @@ export type { /** * Reject a recursion cap that cannot represent an exact delegation depth. - * Undefined means the caller did not request a cap and is accepted. The - * service, direct in-process driver, and model-facing config adapter share this - * boundary so no entry path can turn a fractional or non-finite value into an - * ineffective limit. * @param maxDepth - the optional runtime value to validate. */ export function assertSubagentMaxDepth(maxDepth: unknown): void { @@ -84,88 +59,56 @@ declare module 'cordis' { interface Events { /** - * A provider became resolvable in the {@link SubagentService} registry. - * Consumers that derive state from a named provider (e.g. the model-facing - * tool wording in `dsh-tool-subagent`) react HERE instead of assuming load - * order — the cordis Loader starts sibling plugins concurrently, so - * "listed earlier in cordis.yml" does not mean "registered earlier". - * @param provider - the registry's frozen acceptance snapshot of the provider. + * A provider became resolvable in the registry. + * @param provider - the registered provider. * @mode emit */ 'subagent/provider-added'(provider: SubagentProvider): void /** - * A provider left the registry (its plugin's fiber was disposed — an - * unload or an HMR reload). Consumers holding provider-derived state drop - * it here; a reload re-fires `subagent/provider-added` with the fresh - * provider. Delivered with per-listener containment: a throwing - * subscriber is logged, never starves later subscribers, and never - * disrupts the provider's teardown. - * @param name - the registry name that no longer resolves. + * A provider left the registry. Accepted runs remain holder-owned. + * @param name - the provider name that no longer resolves. * @mode emit */ 'subagent/provider-removed'(name: string): void /** - * A subagent run started — emitted only after {@link SubagentRun.started} - * fulfills, when the provider has established a live child. For an - * in-process provider, `ctx.agents.get(info.id)` is therefore guaranteed to - * resolve during this notification. A readiness rejection emits neither - * lifecycle event; every emitted start is paired with - * {@link Events['subagent/end']}. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed - * by the DELEGATING PARENT — a listener registered through the parent's - * `agent.ctx` observes only its own delegations; a plain plugin listener - * observes every run. - * @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 by the delegating parent and paired with `subagent/end`. + * @param info - the provider and ready child identity. * @mode emit */ 'subagent/start'(this: Scoped, info: SubagentRunInfo): void /** - * A started subagent run settled — emitted when {@link SubagentRun.result} - * resolves (any stop reason) or rejects (reported as `error`). Paired with - * {@link Events['subagent/start']}; a run whose readiness rejected emits - * neither event. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed - * by the DELEGATING PARENT — a listener registered through the parent's - * `agent.ctx` observes only its own delegations; a plain plugin listener - * observes every run. - * @param info - the run identity plus stop reason and final output. + * A ready child settled. Scope-filtered by the delegating parent and + * paired with `subagent/start`. + * @param info - the run identity and terminal outcome. * @mode emit */ 'subagent/end'(this: Scoped, info: SubagentRunEndInfo): void } } -/** Deep-frozen, observe-only identifying detail for a started subagent run. */ +/** 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 } -/** Deep-frozen, observe-only outcome detail for a settled subagent run. */ +/** 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) @@ -173,10 +116,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() @@ -185,451 +125,88 @@ export class SubagentService extends Service { } /** - * Register a provider under its `provider.name`. Throws {@link SubagentError} - * (`DUPLICATE_PROVIDER`) if the name is already taken. The registry snapshots - * the name, static descriptors, and `start` callback identity at acceptance; - * every fixed field and capability flag is read once and validated before - * registration, so malformed provider objects fail loud without entering the - * registry. Later caller mutation cannot change lookup, capability validation, - * consumer wording, dispatch, or HMR cleanup. The callback remains bound to - * the original provider object, so provider-owned mutable state stays live. - * 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. The exact - * Cordis effect disposer (single-shot): composite (generator) effects may - * yield it directly — exact identity nests the teardown in order. + * Register a provider under its name. Registration is effect-scoped and HMR + * safe; removing a provider blocks new starts but does not revoke runs that + * were already returned to their holders. + * @param provider - the trusted provider implementation. + * @returns the exact Cordis effect disposer. */ registerProvider(provider: SubagentProvider): () => Promise | void { - // Snapshot the accepted registration contract before entering the effect. - // Cleanup must never re-read caller-owned `provider.name`: an HMR host may - // mutate or reuse the provider object before its old fiber unloads. Binding - // preserves the provider method's receiver while making replacement of the - // public callback field after registration inert. - const name: unknown = provider.name - const inputCapabilities: unknown = provider.capabilities - const inheritsParentContext: unknown = provider.inheritsParentContext - // eslint-disable-next-line @typescript-eslint/unbound-method - const inputStart: unknown = provider.start - if (typeof name !== 'string') { - throw new TypeError('subagent provider name must be a string') - } - if (inputCapabilities === null || typeof inputCapabilities !== 'object' || Array.isArray(inputCapabilities)) { - throw new TypeError(`subagent provider "${name}" capabilities must be an object`) - } - const inputCapabilityFields = inputCapabilities as Record - const outputSchema = inputCapabilityFields.outputSchema - const depthLimit = inputCapabilityFields.depthLimit - const toolFilter = inputCapabilityFields.toolFilter - const persona = inputCapabilityFields.persona - for (const [capability, value] of [ - ['outputSchema', outputSchema], - ['depthLimit', depthLimit], - ['toolFilter', toolFilter], - ['persona', persona], - ] as const) { - if (typeof value !== 'boolean') { - throw new TypeError(`subagent provider "${name}" capability "${capability}" must be a boolean`) + const name = provider.name + return this.ctx.effect(function* (this: SubagentService) { + if (this.providers.has(name)) { + throw new SubagentError(`a subagent provider named "${name}" is already registered`, 'DUPLICATE_PROVIDER') } - } - if (typeof inheritsParentContext !== 'boolean') { - throw new TypeError(`subagent provider "${name}" inheritsParentContext must be a boolean`) - } - if (typeof inputStart !== 'function') { - throw new TypeError(`subagent provider "${name}" start must be a function`) - } - const capabilities: SubagentCapabilities = Object.freeze({ - outputSchema: outputSchema as boolean, - depthLimit: depthLimit as boolean, - toolFilter: toolFilter as boolean, - persona: persona as boolean, - }) - const snapshot: SubagentProvider = Object.freeze({ - name, - capabilities, - inheritsParentContext, - start: Function.prototype.bind.call(inputStart, provider) as SubagentProvider['start'], - }) - const dispose = this.ctx.effect(function* (this: SubagentService) { - if (this.providers.has(snapshot.name)) { - throw new SubagentError(`a subagent provider named "${snapshot.name}" is already registered`, 'DUPLICATE_PROVIDER') - } - this.providers.set(snapshot.name, snapshot) - // Yield the rollback BEFORE emitting `subagent/provider-added`: a - // throwing added-listener then unregisters the provider (and announces - // the removal) instead of leaking it into the registry. 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(snapshot.name) - this.emitLifecycle('subagent/provider-removed', snapshot.name) + this.providers.delete(name) + this.emitLifecycle('subagent/provider-removed', name) } - this.ctx.emit('subagent/provider-added', snapshot) + // A throwing added-listener unwinds the yielded rollback, matching the + // repository's fail-loud registration semantics. + this.ctx.emit('subagent/provider-added', provider) }.bind(this), 'subagents.registerProvider()') - // The EXACT cordis effect disposer, not a wrapper: a composite (generator) - // effect that owns a teardown ORDER must be able to yield THIS function — - // cordis nests a disposer out of the fiber's concurrent sibling list by - // exact function identity, so a wrapper would silently break the nesting - // (the agents.register() lesson). Fire-and-forget callers may still - // discard the (always-resolved) promise. - return dispose } /** - * Look up the registry's frozen provider snapshot by its accepted name - * (`undefined` if absent). - * @param name - the provider name accepted at registration. - * @returns the frozen acceptance snapshot, or undefined when the name is unknown. + * Look up a provider by name. + * @param name - the provider name. + * @returns the provider, or undefined when absent. */ getProvider(name: string): SubagentProvider | undefined { return this.providers.get(name) } /** - * The names of all registered providers (insertion order). - * @returns the registered provider names. + * List registered provider names in insertion order. + * @returns the registered names. */ list(): string[] { return [...this.providers.keys()] } /** - * Start a subagent run on the named provider. Resolves the provider (throws - * `NO_PROVIDER` if absent), reads the caller request once into a coherent - * acceptance snapshot, 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 - * validates the request's scalar values, materializes model-bound data in one - * lossless-JSON traversal, and delegates the detached request to - * {@link SubagentProvider.start}. The returned handle is a service-owned, - * frozen wrapper: provider fields are captured once, methods stay bound to the - * provider handle, and `result` resolves to one detached, deeply frozen value - * shared by the caller and lifecycle telemetry. Once a provider returns a - * callable disposer, malformed handle access/binding starts rollback before - * the synchronous fault escapes; malformed terminal data rejects only after - * that same memoized disposal reaches quiescence. Emits `subagent/start` / - * `subagent/end` only after the run's readiness boundary fulfills. A provider - * that fails before establishing a child emits neither event. - * @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 { const provider = this.providers.get(name) - if (!provider) { + if (provider === undefined) { throw new SubagentError(`no subagent provider registered for "${name}"`, 'NO_PROVIDER') } - // Read every top-level field exactly once before capability checks or - // detachment. A stateful accessor must not look absent to validation and then - // appear in the provider request (or vice versa). - const input = this.snapshotStartRequest(request) - const parent = input.parent - this.assertCapabilities(provider, input) - assertSubagentMaxDepth(input.maxDepth) - if (input.persona !== undefined && typeof input.persona !== 'string') { - throw new TypeError('subagent persona must be a string') - } - // Model/session-bound values are validated and detached in a single - // recursive pass. A check followed by structuredClone would reread getters - // and could erase an exotic prototype returned only to the clone. - const prompt = snapshotJsonValue(input.prompt) - if (prompt === undefined) { - throw new TypeError('subagent prompt must be losslessly JSON-serializable') - } - const outputSchema = input.outputSchema === undefined - ? undefined - : snapshotJsonValue(input.outputSchema) - if (input.outputSchema !== undefined && outputSchema === undefined) { - throw new OutputSchemaError(['schema annotation must be JSON data; the complete schema must be losslessly JSON-serializable']) - } - if (outputSchema !== undefined) assertSupportedOutputSchema(outputSchema) - const agentOptions = input.agentOptions === undefined - ? undefined - : snapshotJsonValue(input.agentOptions) - if (input.agentOptions !== undefined && agentOptions === undefined) { - throw new TypeError('subagent agent options must be losslessly JSON-serializable') - } - const toolFilter = input.toolFilter === undefined - ? undefined - : snapshotJsonValue(input.toolFilter) - if (input.toolFilter !== undefined && toolFilter === undefined) { - throw new TypeError('subagent tool filter must be losslessly JSON-serializable') - } + this.assertCapabilities(provider, request) + assertSubagentMaxDepth(request.maxDepth) + if (request.outputSchema !== undefined) assertSupportedOutputSchema(request.outputSchema) - // Detach every data field before crossing into a provider. Parent/signal - // are live identity capabilities and stay exact; the mutable request record - // and its arrays/objects are never retained, so every backend (including an - // async out-of-process one) observes the request accepted at start. - const accepted: SubagentStartRequest = { - prompt, - parent, - ...input.signal !== undefined ? { signal: input.signal } : {}, - ...agentOptions !== undefined ? { agentOptions } : {}, - ...outputSchema !== undefined ? { outputSchema } : {}, - ...input.maxDepth !== undefined ? { maxDepth: input.maxDepth } : {}, - ...toolFilter !== undefined ? { toolFilter } : {}, - ...input.persona !== undefined ? { persona: input.persona } : {}, - } - const providerRun: unknown = provider.start(accepted) - if (providerRun === null || (typeof providerRun !== 'object' && typeof providerRun !== 'function')) { - throw new TypeError(`subagent provider "${name}" start must return a SubagentRun object`) - } - const acceptedRun = providerRun as SubagentRun - // Acquire the one rollback capability BEFORE touching any other provider-run - // field. Once start() returned a handle, the service owns an accepted live - // attempt; a hostile later accessor or bind must not make that attempt - // unreachable. The wrapper also memoizes provider disposal, so automatic - // rollback and a racing caller join one quiescence transaction even if a - // contract-violating provider forgot to make its own method idempotent. - // eslint-disable-next-line @typescript-eslint/unbound-method - const inputDispose = acceptedRun.dispose - if (typeof inputDispose !== 'function') { - throw new TypeError(`subagent provider "${name}" run dispose must be a function`) - } - let disposal: Promise | undefined - const dispose = (): Promise => { - if (disposal === undefined) { - // Claim the shared transaction before invoking provider code: a raw - // disposer can synchronously reenter this wrapper through a reference - // retained by its caller, and both calls must join one provider call. - const claimed = Promise.withResolvers() - disposal = claimed.promise - try { - // Invoke through the captured callable without reading its public - // `bind`/`length`/`name` properties. Disposal is the recovery - // capability itself; hostile function metadata must not prevent the - // seam from exercising it when a later handle field is malformed. - const returned: unknown = Reflect.apply(inputDispose, acceptedRun, []) - // A raw disposer can reenter the service wrapper and directly return - // that same shared promise. Awaiting it here would make the promise - // depend on itself forever; reject the cyclic provider contract loud. - if (returned === claimed.promise) { - claimed.reject(new TypeError(`subagent provider "${name}" run dispose returned its own wrapper disposal promise`)) - return disposal - } - void Promise.resolve(returned).then( - () => { claimed.resolve(undefined) }, - (error: unknown) => { claimed.reject(error) }, - ) - } catch (error: unknown) { - claimed.reject(error instanceof Error - ? error - : new Error('subagent provider run dispose threw a non-Error value', { cause: error })) - } - } - return disposal - } - // Provider-owned run objects can be accessor-backed too. Capture every - // public field exactly once, bind methods to the provider's original handle, - // and expose only this service-owned wrapper. The normalized result promise - // is also the one lifecycle telemetry observes, so the caller and observers - // cannot receive different values from stateful accessors. - try { - const id = acceptedRun.id - if (typeof id !== 'string') { - throw new TypeError(`subagent provider "${name}" run id must be a string`) - } - const started = acceptedRun.started - if (!(started instanceof Promise)) { - throw new TypeError(`subagent provider "${name}" run started must be a Promise`) - } - // Observe each accepted provider promise before reading the next hostile - // field. A later accessor/validation failure prevents a wrapper from being - // returned, but must not leave an already-rejected provider promise - // unhandled while rollback proceeds. - void started.catch(() => undefined) - const providerResult = acceptedRun.result - if (!(providerResult instanceof Promise)) { - throw new TypeError(`subagent provider "${name}" run result must be a Promise`) - } - void providerResult.catch(() => undefined) - // eslint-disable-next-line @typescript-eslint/unbound-method - const inputCancel = acceptedRun.cancel - if (typeof inputCancel !== 'function') { - throw new TypeError(`subagent provider "${name}" run cancel must be a function`) - } - // eslint-disable-next-line @typescript-eslint/unbound-method - const inputSendMessage = acceptedRun.sendMessage - if (inputSendMessage !== undefined && typeof inputSendMessage !== 'function') { - throw new TypeError(`subagent provider "${name}" run sendMessage must be a function when provided`) - } - // eslint-disable-next-line @typescript-eslint/unbound-method - const inputResume = acceptedRun.resume - if (inputResume !== undefined && typeof inputResume !== 'function') { - throw new TypeError(`subagent provider "${name}" run resume must be a function when provided`) - } - const cancel = Function.prototype.bind.call(inputCancel, acceptedRun) as SubagentRun['cancel'] - const sendMessage = inputSendMessage === undefined - ? undefined - : Function.prototype.bind.call(inputSendMessage, acceptedRun) as NonNullable - const resume = inputResume === undefined - ? undefined - : Function.prototype.bind.call(inputResume, acceptedRun) as NonNullable - const result = providerResult.then(async (value) => { - try { - return this.snapshotRunResult(value) - } catch (error: unknown) { - // A malformed terminal value is an infrastructure contract fault. The - // result rejects only after the accepted provider attempt has reached - // quiescence, so a caller cannot lose the only cleanup handle by merely - // observing the normalization failure. - await this.rollbackProviderRun(name, dispose) - throw error - } - }) - const run: SubagentRun = Object.freeze({ - id, - started, - result, - cancel, - dispose, - ...sendMessage === undefined - ? {} - : { sendMessage }, - ...resume === undefined - ? {} - : { resume }, - }) - - // Observe result settlement IMMEDIATELY, before waiting on readiness. A - // provider may fail both promises in the same turn; deferring the rejection - // handler until `started` fulfilled would leave `result` transiently - // unhandled. The settled event is buffered until start has been announced, - // preserving start → end order even for an already-settled scripted run. - let readiness: 'pending' | 'started' | 'failed' = 'pending' - let pendingEnd: SubagentRunEndInfo | undefined - const deliverEnd = (info: SubagentRunEndInfo): void => { - if (readiness === 'started') this.emitLifecycle('subagent/end', info, parent) - else if (readiness === 'pending') pendingEnd = info - // A pre-publication readiness failure has no lifecycle pair; result - // remains observable by the run's consumer, but telemetry must not claim - // that a child started. - } - void result.then( - (value) => { - deliverEnd({ - provider: name, - id, - stopReason: value.stopReason, - lastAssistantMessage: value.output, - }) - }, - () => { deliverEnd({ provider: name, id, stopReason: 'error' }) }, - ) - - // Readiness is the publication boundary owned by the provider. For - // in-process runs, fulfillment means the agent registry already contains - // `run.id`; for ACP it means the remote session exists. Emit start with - // per-listener containment, then flush an outcome that settled unusually - // early. A readiness rejection is handled here and deliberately emits no - // false start/end pair; the result path above remains independently handled. - void started.then( - () => { - readiness = 'started' - this.emitLifecycle('subagent/start', { provider: name, id }, parent) - if (pendingEnd !== undefined) { - const info = pendingEnd - pendingEnd = undefined - this.emitLifecycle('subagent/end', info, parent) - } - }, - () => { - readiness = 'failed' - pendingEnd = undefined - }, - ) - return run - } catch (error: unknown) { - // start() has already transferred a live attempt to the seam. Begin - // rollback synchronously before surfacing the malformed-handle failure; - // the contained cleanup promise prevents either a resource leak or an - // unhandled rejection even though this API cannot synchronously await it. - void this.rollbackProviderRun(name, dispose) - throw error - } - } - - /** Dispose one malformed provider attempt without letting cleanup mask the contract fault. */ - private async rollbackProviderRun(providerName: string, dispose: () => Promise): Promise { - try { - await dispose() - } catch (error: unknown) { - this.ctx.logger.warn(`subagent provider "${providerName}" malformed run rollback failed: ${renderThrown(error)}`) - } - } - - /** Normalize one provider result into the immutable seam value. */ - private snapshotRunResult(value: SubagentResult): SubagentResult { - // Capture every provider-owned field once before validation. In particular, - // lifecycle telemetry must not reread accessors after the caller receives - // the result and observe a different terminal outcome. - const output = value.output - const structured = value.structured - const stopReason = value.stopReason - if (!Array.isArray(output)) { - throw new TypeError('subagent result output must be an array') - } - if (typeof stopReason !== 'string') { - throw new TypeError('subagent result stopReason must be a string') - } - const accepted: SubagentResult = { - output, - ...structured === undefined ? {} : { structured }, - stopReason, - } - const snapshot = snapshotJsonValue(accepted) - if (snapshot === undefined) { - throw new TypeError('subagent result must be losslessly JSON-serializable') - } - return deepFreeze(snapshot) - } - - /** Read one coherent caller request into immutable data properties. */ - private snapshotStartRequest(request: SubagentStartRequest): Readonly { - const prompt = request.prompt const parent = request.parent - const signal = request.signal - const agentOptions = request.agentOptions - const outputSchema = request.outputSchema - const maxDepth = request.maxDepth - const toolFilter = request.toolFilter - const persona = request.persona - return Object.freeze({ - prompt, - parent, - ...signal !== undefined ? { signal } : {}, - ...agentOptions !== undefined ? { agentOptions } : {}, - ...outputSchema !== undefined ? { outputSchema } : {}, - ...maxDepth !== undefined ? { maxDepth } : {}, - ...toolFilter !== undefined ? { toolFilter } : {}, - ...persona !== undefined ? { persona } : {}, - }) + 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) => { + 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/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) either a synchronous - * throw or a returned-promise rejection, 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. Async - * listeners remain concurrent fire-and-forget; dispatch does not await or - * serialize them. 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, parent: Agent): void private emitLifecycle(name: 'subagent/end', info: SubagentRunEndInfo, parent: Agent): void @@ -639,21 +216,12 @@ export class SubagentService extends Service { info: SubagentRunInfo | SubagentRunEndInfo | string, parent?: Agent, ): void { - // Run lifecycle events dispatch in the DELEGATING PARENT's scope (a - // parent-scoped listener observes only its own delegations); the - // provider-removed registry notification stays unfiltered. The carrier is - // args[0] of the dispatch call, exactly as cordis' own emit spells it. - const acceptedInfo = typeof info === 'string' ? info : deepFreeze(info) const dispatchArgs: unknown[] = parent === undefined - ? [name, acceptedInfo] - : [scopeTarget(this, parent), name, acceptedInfo] + ? [name, info] + : [scopeTarget(this, parent), name, info] for (const callback of this.ctx.events.dispatch('emit', dispatchArgs)) { try { - const returned: unknown = callback(acceptedInfo) - // Plain emits remain fire-and-forget and every callback is still invoked - // synchronously in this loop. Observe a returned promise independently so - // an async listener rejection is contained without serializing listeners - // or delaying provider/run lifecycle. + const returned: unknown = callback(info) void Promise.resolve(returned).catch((error: unknown) => { this.ctx.logger.warn(`subagent: ${name} listener rejected: ${renderThrown(error)}`) }) @@ -663,11 +231,7 @@ export class SubagentService extends Service { } } - /** - * 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' }, @@ -686,7 +250,7 @@ export class SubagentService extends Service { } } -/** Render an arbitrary thrown value without allowing coercion to throw again. */ +/** 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) diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index 42f55b300d..f08d8a0622 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -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,13 +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). */ - persona: boolean + readonly persona: boolean } /** @@ -41,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 + readonly signal: AbortSignal /** Per-child agent options (model, system prompt). */ - agentOptions?: AgentOptions + readonly agentOptions?: AgentOptions /** * Optional structured-output schema — an object-rooted JSON Schema within the * enforced subset (see `assertSupportedOutputSchema` in dsh-tools; a schema @@ -67,14 +69,14 @@ export interface SubagentStartRequest { * data — a caller holding foreign-realm data materializes it first. * Requesting it against a provider that lacks the capability is rejected at start. */ - outputSchema?: StructuredOutputSchema + readonly outputSchema?: StructuredOutputSchema /** * Optional absolute delegation-depth cap for the child being started: its * computed depth must be less than or equal to this non-negative safe * integer. Requires {@link SubagentCapabilities.depthLimit}; rejected at * start otherwise. */ - maxDepth?: number + readonly maxDepth?: number /** * Optional child tool scoping. Requires {@link SubagentCapabilities.toolFilter}; * rejected at start otherwise. In-process backends apply it as a scoped @@ -82,7 +84,7 @@ export interface SubagentStartRequest { * from the child's prompt AND refuse to execute (one visibility), with loud * unknown-name validation. */ - toolFilter?: { allow?: string[]; deny?: string[] } + readonly toolFilter?: ToolRestriction /** * Optional per-child persona. Requires {@link SubagentCapabilities.persona}; * rejected at start otherwise. In-process backends register it as a scoped @@ -90,7 +92,7 @@ export interface SubagentStartRequest { * persona for this child alone — same template semantics as the deployment * persona (strict `{{…}}` interpolation against the registered variables). */ - persona?: string + readonly persona?: string } /** @@ -102,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' @@ -120,7 +122,7 @@ export type SubagentStopReason = SubagentStopReasonMap[keyof SubagentStopReasonM */ export interface SubagentResult { /** The child's final assistant output (the last assistant message's content). */ - output: ContentBlock[] + readonly output: ContentBlock[] /** * The structured result after a requested `outputSchema` was successfully * satisfied. Requesting a schema does not guarantee presence: a provider can @@ -128,32 +130,24 @@ export interface SubagentResult { * valid capture. Shape is validated against the request schema by the * provider; `unknown` here because the seam is schema-agnostic. */ - structured?: unknown + readonly structured?: unknown /** Why the run ended. A non-`completed` reason means `output` may be partial. */ - stopReason: SubagentStopReason + readonly stopReason: SubagentStopReason } /** * A live subagent run: a handle the consumer holds while a child executes. - * Returned by {@link SubagentProvider.start} (via the service). The consumer - * awaits {@link result}, may {@link cancel} mid-flight, and MUST {@link dispose} - * on every path to reach child quiescence (no leaked idle child / session). + * Returned by {@link SubagentProvider.start} (via the service) only after the + * child is ready. The consumer awaits {@link result} and MUST {@link dispose} + * on every path to cancel any remaining work and reach child quiescence. * * {@link sendMessage} and {@link resume} are OPTIONAL: a provider that supports * the runtime capability defines the method; one that doesn't omits it. The * presence of the method IS the capability — narrow before calling. */ export interface SubagentRun { - /** The child agent's id (local in-process runs publish it in `ctx.agents`; remote transports need not). */ + /** The child agent's id (local in-process runs are already published in `ctx.agents`; remote transports need not publish locally). */ readonly id: AgentId - /** - * The provider's publication/readiness boundary. Resolves only after a real - * child is established: an in-process agent is live in `ctx.agents`, or a - * remote transport has created its child session. Rejects when the attempt - * fails or is cancelled before that boundary. The service emits the paired - * `subagent/start`/`subagent/end` lifecycle only after this fulfills. - */ - readonly started: Promise /** * Resolves with the child's terminal {@link SubagentResult} when the run * settles. Does NOT reject on a child-level failure — a model/transport @@ -162,12 +156,10 @@ export interface SubagentRun { * cannot represent as a stop reason. */ readonly result: Promise - /** Request cancellation of the in-flight run; {@link result} settles `aborted`. */ - cancel(reason?: string): void /** - * Reach child quiescence and release the run's resources (in-process: dispose - * the owned agent handle and remove its session; ACP: kill the subprocess). - * Idempotent; awaits the child actually stopping, not merely requesting it. + * 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 /** @@ -179,7 +171,7 @@ export interface SubagentRun { * OPTIONAL (resume capability): send a follow-up task to a settled child, * continuing its session, and return a fresh run for the continuation. */ - resume?(content: ContentBlock[]): SubagentRun + resume?(content: ContentBlock[]): Promise } /** @@ -187,8 +179,8 @@ export interface SubagentRun { * spawn/fork, ACP to another process, …). Implementations register under a * unique name via {@link SubagentService.registerProvider}; multiple providers * coexist in one context (unlike the single-implementation bash seam). The - * service freezes the public descriptor and callback identity at registration; - * the captured `start` remains bound to the original provider receiver. + * Providers are trusted same-process implementations; callers treat their + * descriptors and returned values as borrowed immutable data. */ export interface SubagentProvider { /** Unique registry name (e.g. `spawn`, `fork`, `acp`). */ @@ -208,12 +200,12 @@ export interface SubagentProvider { */ readonly inheritsParentContext: boolean /** - * Start preparing a child run and return its handle synchronously. The + * Establish a child and return its handle only after publication. The * service has already validated that every requested start-time capability * is supported, so an implementation may assume e.g. `request.maxDepth` is - * honorable when present. The returned {@link SubagentRun.started} must mark - * the real publication/readiness boundary; the result path must observe that - * promise immediately so a pre-start rejection cannot become unhandled. + * honorable when present. If setup fails or `request.signal` aborts before + * fulfillment, the provider owns and cleans all partial resources before this + * promise rejects. Ownership transfers to the caller only on fulfillment. */ - start(request: SubagentStartRequest): SubagentRun + start(request: SubagentStartRequest): Promise } diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index 182ee7ac8b..23c2caf445 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -5,6 +5,7 @@ import { HarnessError } from '@deepseek-ai/dsh-llm' import { carrierKeyOf } from '@deepseek-ai/dsh-scope' import SubagentService, { SubagentError, + assertSubagentMaxDepth, type SubagentCapabilities, type SubagentProvider, type SubagentResult, @@ -12,1350 +13,218 @@ import SubagentService, { type SubagentStartRequest, } from '@deepseek-ai/dsh-subagent' -/** A minimal parent Agent stand-in — the service only reads `parent.id`. */ function fakeParent(id = 'parent-1'): Agent { return { id: AgentId(id) } as unknown as Agent } -const ALL_CAPS: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: true, persona: false } +const ALL_CAPS: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: true, persona: true } const NO_CAPS: SubagentCapabilities = { outputSchema: false, depthLimit: false, toolFilter: false, persona: false } -/** A scripted provider whose run settles immediately with a fixed result. */ +function baseRequest(overrides: Partial = {}): SubagentStartRequest { + return { + prompt: [{ type: 'text', text: 'do a thing' }], + parent: fakeParent(), + signal: new AbortController().signal, + ...overrides, + } +} + class StubProvider implements SubagentProvider { - startCount = 0 readonly inheritsParentContext = false + startCount = 0 + constructor( readonly name: string, readonly capabilities: SubagentCapabilities = ALL_CAPS, - private readonly result: SubagentResult = { output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' }, + private readonly outcome: SubagentResult = { + output: [{ type: 'text', text: 'ok' }], + stopReason: 'completed', + }, ) {} - start(request: SubagentStartRequest): SubagentRun { - this.startCount++ + async start(request: SubagentStartRequest): Promise { + this.startCount += 1 return { id: AgentId(`child:${this.name}:${request.parent.id}`), - started: Promise.resolve(), - result: Promise.resolve(this.result), - cancel() {}, + result: Promise.resolve(this.outcome), async dispose() {}, } } } -function baseRequest(overrides: Partial = {}): SubagentStartRequest { - return { prompt: [{ type: 'text', text: 'do a thing' }], parent: fakeParent(), ...overrides } +async function service(): Promise<{ ctx: Context; subagents: SubagentService }> { + const ctx = new Context() + await ctx.plugin(SubagentService) + return { ctx, subagents: ctx.subagents } } describe('SubagentService', () => { - it('announces provider lifecycle: added on register, removed on dispose', async () => { - const ctx = new Context() - await ctx.plugin(SubagentService) + it('registers, lists, looks up, starts, and removes providers', async () => { + const { ctx, subagents } = await service() const added: string[] = [] const removed: string[] = [] ctx.on('subagent/provider-added', provider => void added.push(provider.name)) ctx.on('subagent/provider-removed', name => void removed.push(name)) - - const dispose = ctx.subagents.registerProvider(new StubProvider('alpha')) - expect(added).toEqual(['alpha']) - expect(removed).toEqual([]) - - await dispose() - expect(removed).toEqual(['alpha']) - }) - - it('rolls back the registration when a provider-added listener throws', async () => { - const ctx = new Context() - await ctx.plugin(SubagentService) - let threw = false - const off = ctx.on('subagent/provider-added', () => { - if (!threw) { threw = true; throw new Error('boom added listener') } - }) - - expect(() => ctx.subagents.registerProvider(new StubProvider('alpha'))).toThrow('boom added listener') - expect(ctx.subagents.getProvider('alpha')).toBeUndefined() // nothing leaked - - off() - ctx.subagents.registerProvider(new StubProvider('alpha')) - expect(ctx.subagents.getProvider('alpha')).toBeDefined() - }) - - it('contains a throwing provider-removed listener: later mirrors still hear it, teardown completes', async () => { - // provider-removed fires inside the registration's DISPOSER, so a - // propagating listener would disrupt the backend's teardown; and cordis - // emit halts on the first throw, so an uncontained one would starve every - // mirror registered after it (a stale model-facing tool). 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(() => void dispose()).not.toThrow() - expect(heard).toEqual(['alpha']) // the listener AFTER the thrower still ran - expect(ctx.subagents.getProvider('alpha')).toBeUndefined() // teardown reached quiescence - expect(warnings.some(w => w.includes('boom removed listener'))).toBe(true) - }) - - it('registers a provider and starts a run on it by name', async () => { - const ctx = new Context() - await ctx.plugin(SubagentService) const provider = new StubProvider('alpha') - ctx.subagents.registerProvider(provider) - expect(ctx.subagents.list()).toEqual(['alpha']) - expect(ctx.subagents.getProvider('alpha')).toMatchObject({ name: 'alpha' }) - - const run = ctx.subagents.start('alpha', baseRequest()) - expect(provider.startCount).toBe(1) - await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' }) - }) - - it('lets multiple providers coexist (the defining requirement)', async () => { - const ctx = new Context() - await ctx.plugin(SubagentService) - ctx.subagents.registerProvider(new StubProvider('spawn')) - ctx.subagents.registerProvider(new StubProvider('acp')) - - expect(ctx.subagents.list()).toEqual(['spawn', 'acp']) - expect(ctx.subagents.getProvider('spawn')).toBeDefined() - expect(ctx.subagents.getProvider('acp')).toBeDefined() - }) - - it('throws NO_PROVIDER when starting on an unregistered name', async () => { - const ctx = new Context() - await ctx.plugin(SubagentService) - try { - ctx.subagents.start('missing', baseRequest()) - expect.fail('expected NO_PROVIDER') - } catch (error: unknown) { - expect(error).toBeInstanceOf(SubagentError) - expect((error as SubagentError).code).toBe('NO_PROVIDER') - } - }) - - it('rejects duplicate provider names with DUPLICATE_PROVIDER', async () => { - const ctx = new Context() - await ctx.plugin(SubagentService) - ctx.subagents.registerProvider(new StubProvider('dup')) - try { - ctx.subagents.registerProvider(new StubProvider('dup')) - expect.fail('expected DUPLICATE_PROVIDER') - } catch (error: unknown) { - expect(error).toBeInstanceOf(SubagentError) - expect((error as SubagentError).code).toBe('DUPLICATE_PROVIDER') - } - }) - - it('claims wrapper disposal before a raw provider disposer can reenter it', async () => { - const ctx = new Context() - await ctx.plugin(SubagentService) - const observed: { reentrant?: Promise } = {} - const providerDispose = vi.fn(() => { - observed.reentrant = run.dispose() - return Promise.resolve() - }) - ctx.subagents.registerProvider({ - name: 'dispose-reentry', - capabilities: NO_CAPS, - inheritsParentContext: false, - start: () => ({ - id: AgentId('dispose-reentry-child'), - started: Promise.resolve(), - result: Promise.resolve({ output: [], stopReason: 'completed' }), - cancel() {}, - dispose: providerDispose, - }), - }) - const run = ctx.subagents.start('dispose-reentry', baseRequest()) - - const disposal = run.dispose() - - expect(observed.reentrant).toBe(disposal) - await disposal - expect(providerDispose).toHaveBeenCalledOnce() - }) - - it('rejects a raw disposer that directly returns its reentrant wrapper promise instead of hanging', async () => { - const ctx = new Context() - await ctx.plugin(SubagentService) - const providerDispose = vi.fn(() => run.dispose()) - ctx.subagents.registerProvider({ - name: 'dispose-self-cycle', - capabilities: NO_CAPS, - inheritsParentContext: false, - start: () => ({ - id: AgentId('dispose-self-cycle-child'), - started: Promise.resolve(), - result: Promise.resolve({ output: [], stopReason: 'completed' }), - cancel() {}, - dispose: providerDispose, - }), - }) - const run = ctx.subagents.start('dispose-self-cycle', baseRequest()) - - await expect(run.dispose()).rejects.toThrow('run dispose returned its own wrapper disposal promise') - expect(providerDispose).toHaveBeenCalledOnce() - }) - - it.each([ - { label: 'a non-string name', patch: { name: 42 }, message: 'name must be a string' }, - { label: 'null capabilities', patch: { capabilities: null }, message: 'capabilities must be an object' }, - { label: 'primitive capabilities', patch: { capabilities: 42 }, message: 'capabilities must be an object' }, - { label: 'array capabilities', patch: { capabilities: [] }, message: 'capabilities must be an object' }, - { - label: 'a non-boolean outputSchema capability', - patch: { capabilities: { ...NO_CAPS, outputSchema: 'yes' } }, - message: 'capability "outputSchema" must be a boolean', - }, - { - label: 'a non-boolean depthLimit capability', - patch: { capabilities: { ...NO_CAPS, depthLimit: 'yes' } }, - message: 'capability "depthLimit" must be a boolean', - }, - { - label: 'a non-boolean toolFilter capability', - patch: { capabilities: { ...NO_CAPS, toolFilter: 'yes' } }, - message: 'capability "toolFilter" must be a boolean', - }, - { - label: 'a non-boolean persona capability', - patch: { capabilities: { ...NO_CAPS, persona: 'yes' } }, - message: 'capability "persona" must be a boolean', - }, - { label: 'a non-boolean conversation-history descriptor', patch: { inheritsParentContext: 'yes' }, message: 'inheritsParentContext must be a boolean' }, - { label: 'a non-callable start field', patch: { start: 42 }, message: 'start must be a function' }, - ])('rejects a provider registration with $label before entering the registry', async ({ patch, message }) => { - const ctx = new Context() - await ctx.plugin(SubagentService) - const provider = Object.assign(new StubProvider('invalid'), patch) - - expect(() => ctx.subagents.registerProvider(provider as unknown as SubagentProvider)).toThrow(message) - expect(ctx.subagents.list()).toEqual([]) - expect(Object.isFrozen(provider)).toBe(false) - }) - - it('reads every registration field once and binds the accepted start callback to the provider', async () => { - const ctx = new Context() - await ctx.plugin(SubagentService) - const reads = { - name: 0, - capabilities: 0, - outputSchema: 0, - depthLimit: 0, - toolFilter: 0, - persona: 0, - inheritsParentContext: 0, - start: 0, - } - const capabilities = Object.defineProperties({}, { - outputSchema: { enumerable: true, get: () => { reads.outputSchema += 1; return false } }, - depthLimit: { enumerable: true, get: () => { reads.depthLimit += 1; return false } }, - toolFilter: { enumerable: true, get: () => { reads.toolFilter += 1; return false } }, - persona: { enumerable: true, get: () => { reads.persona += 1; return false } }, - }) as SubagentCapabilities - const acceptedStart = function (this: SubagentProvider, request: SubagentStartRequest): SubagentRun { - expect(this).toBe(provider) - return { - id: AgentId(`one-read:${request.parent.id}`), - started: Promise.resolve(), - result: Promise.resolve({ output: [], stopReason: 'completed' }), - cancel() {}, - async dispose() {}, - } - } - const provider = Object.defineProperties({}, { - name: { enumerable: true, get: () => { reads.name += 1; return 'one-read' } }, - capabilities: { enumerable: true, get: () => { reads.capabilities += 1; return capabilities } }, - inheritsParentContext: { enumerable: true, get: () => { reads.inheritsParentContext += 1; return false } }, - start: { enumerable: true, get: () => { reads.start += 1; return acceptedStart } }, - }) as SubagentProvider - - ctx.subagents.registerProvider(provider) - await expect(ctx.subagents.start('one-read', baseRequest()).result).resolves.toMatchObject({ stopReason: 'completed' }) - expect(reads).toEqual({ - name: 1, - capabilities: 1, - outputSchema: 1, - depthLimit: 1, - toolFilter: 1, - persona: 1, - inheritsParentContext: 1, - start: 1, - }) - }) - - it('unregisters a provider when its owning fiber is disposed (HMR safety)', async () => { - const ctx = new Context() - await ctx.plugin(SubagentService) - - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - inner.subagents.registerProvider(new StubProvider('scoped')) - }, { inject: ['subagents'] })) - expect(ctx.subagents.list()).toEqual(['scoped']) - - await fiber.dispose() - expect(ctx.subagents.list()).toEqual([]) - }) - - it('snapshots a provider registration so caller mutation cannot corrupt dispatch or HMR cleanup', async () => { - const ctx = new Context() - await ctx.plugin(SubagentService) - const capabilities: SubagentCapabilities = { - outputSchema: true, - depthLimit: true, - toolFilter: true, - persona: true, - } - const provider = new StubProvider('stable', capabilities) - let capabilityReads = 0 - let capabilityValue = capabilities - Object.defineProperty(provider, 'capabilities', { - configurable: true, - get: () => { - capabilityReads += 1 - return capabilityValue - }, - set: (value: SubagentCapabilities) => { capabilityValue = value }, - }) - const added: SubagentProvider[] = [] - const removed: string[] = [] - ctx.on('subagent/provider-added', registered => void added.push(registered)) - ctx.on('subagent/provider-removed', name => void removed.push(name)) - const owner = await ctx.plugin({ - name: 'mutable-provider-owner', - inject: ['subagents'], - apply(pluginCtx: Context) { - pluginCtx.subagents.registerProvider(provider) - }, - }) - expect(capabilityReads).toBe(1) - const accepted = ctx.subagents.getProvider('stable') - - const mutable = provider as unknown as { - name: string - capabilities: SubagentCapabilities - inheritsParentContext: boolean - start: SubagentProvider['start'] - } - mutable.name = 'mutated' - capabilities.outputSchema = false - capabilities.depthLimit = false - capabilities.toolFilter = false - capabilities.persona = false - mutable.capabilities = NO_CAPS - mutable.inheritsParentContext = true - const replacementStart = vi.fn((_request: SubagentStartRequest): SubagentRun => { - throw new Error('replacement start must not run') - }) - mutable.start = replacementStart - - expect(added).toEqual([accepted]) - expect(accepted).not.toBe(provider) - expect(Object.isFrozen(accepted)).toBe(true) - expect(Object.isFrozen(accepted?.capabilities)).toBe(true) - expect(accepted).toMatchObject({ - name: 'stable', - capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: true }, - inheritsParentContext: false, - }) - expect(ctx.subagents.list()).toEqual(['stable']) - expect(ctx.subagents.getProvider('mutated')).toBeUndefined() - - const controller = new AbortController() - const run = ctx.subagents.start('stable', baseRequest({ - signal: controller.signal, - agentOptions: { model: 'mock' }, - outputSchema: { type: 'object', properties: { answer: { type: 'string' } } }, - maxDepth: 2, - toolFilter: { deny: ['bash'] }, - persona: 'reviewer', - })) + const dispose = subagents.registerProvider(provider) + expect(subagents.list()).toEqual(['alpha']) + expect(subagents.getProvider('alpha')).toBe(provider) + const run = await subagents.start('alpha', baseRequest()) await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' }) expect(provider.startCount).toBe(1) - expect(replacementStart).not.toHaveBeenCalled() - await owner.dispose() - expect(removed).toEqual(['stable']) - expect(ctx.subagents.list()).toEqual([]) - expect(() => ctx.subagents.registerProvider(new StubProvider('stable'))).not.toThrow() - }) - - it('re-registers a name after its prior registration is disposed (not wedged)', async () => { - const ctx = new Context() - await ctx.plugin(SubagentService) - - const dispose = ctx.subagents.registerProvider(new StubProvider('reuse')) - expect(ctx.subagents.list()).toEqual(['reuse']) await dispose() - expect(ctx.subagents.list()).toEqual([]) - - const disposeAgain = ctx.subagents.registerProvider(new StubProvider('reuse')) - expect(ctx.subagents.list()).toEqual(['reuse']) - await disposeAgain() - expect(ctx.subagents.list()).toEqual([]) + expect(added).toEqual(['alpha']) + expect(removed).toEqual(['alpha']) + expect(subagents.getProvider('alpha')).toBeUndefined() }) - describe('start-time capability validation (fail loud, before any child)', () => { - it.each([ - { field: 'outputSchema', request: baseRequest({ outputSchema: { type: 'object', properties: { x: { type: 'string' } } } }) }, - { field: 'maxDepth', request: baseRequest({ maxDepth: 2 }) }, - { field: 'toolFilter', request: baseRequest({ toolFilter: { deny: ['bash'] } }) }, - ])('rejects $field against a provider that lacks the capability — before start() runs', ({ request }) => { - const ctx = new Context() - return ctx.plugin(SubagentService).then(() => { - const provider = new StubProvider('weak', NO_CAPS) - ctx.subagents.registerProvider(provider) - try { - ctx.subagents.start('weak', request) - expect.fail('expected UNSUPPORTED_CAPABILITY') - } catch (error: unknown) { - expect(error).toBeInstanceOf(SubagentError) - expect((error as SubagentError).code).toBe('UNSUPPORTED_CAPABILITY') - } - // The child was never started — the check is pre-spawn. - expect(provider.startCount).toBe(0) - }) - }) - - it('allows a capability request when the provider supports it', async () => { - const ctx = new Context() - await ctx.plugin(SubagentService) - const provider = new StubProvider('strong', ALL_CAPS) - ctx.subagents.registerProvider(provider) - ctx.subagents.start('strong', baseRequest({ outputSchema: { type: 'object', properties: { x: { type: 'string' } } }, maxDepth: 1 })) - expect(provider.startCount).toBe(1) - }) - - it.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 fraction', value: 1.5 }, - { label: 'a negative integer', value: -1 }, - { label: 'negative zero', value: -0 }, - { label: 'an unsafe integer', value: Number.MAX_SAFE_INTEGER + 1 }, - ])('rejects maxDepth=$label before the provider starts', async ({ value }) => { - const ctx = new Context() - await ctx.plugin(SubagentService) - const provider = new StubProvider('invalid-depth', ALL_CAPS) - ctx.subagents.registerProvider(provider) - - expect(() => ctx.subagents.start('invalid-depth', baseRequest({ maxDepth: value }))) - .toThrow('subagent maxDepth must be a non-negative safe integer') - expect(provider.startCount).toBe(0) - }) - - it('rejects a non-string persona before the provider starts', async () => { - const ctx = new Context() - await ctx.plugin(SubagentService) - const provider = new StubProvider('invalid-persona', { ...ALL_CAPS, persona: true }) - ctx.subagents.registerProvider(provider) - - expect(() => ctx.subagents.start('invalid-persona', baseRequest({ - persona: 42 as unknown as string, - }))).toThrow('subagent persona must be a string') - expect(provider.startCount).toBe(0) - }) - - it('reads an optional capability accessor once so it cannot appear after validation', async () => { - const ctx = new Context() - await ctx.plugin(SubagentService) - let accepted: SubagentStartRequest | undefined - const provider: SubagentProvider = { - name: 'weak-getter', - capabilities: NO_CAPS, - inheritsParentContext: false, - start: (request) => { - accepted = request - return { - id: AgentId('weak-getter-child'), - started: Promise.resolve(), - result: Promise.resolve({ output: [], stopReason: 'completed' }), - cancel() {}, - async dispose() {}, - } - }, - } - ctx.subagents.registerProvider(provider) - let reads = 0 - const request = baseRequest() - Object.defineProperty(request, 'toolFilter', { - enumerable: true, - get: () => { - reads += 1 - return reads === 1 ? undefined : { deny: ['bash'] } - }, - }) - - ctx.subagents.start('weak-getter', request) - - expect(reads).toBe(1) - expect(accepted?.toolFilter).toBeUndefined() - }) + 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('rejects an exotic public prompt before the provider starts', async () => { - const ctx = new Context() - await ctx.plugin(SubagentService) - const provider = new StubProvider('prompt-boundary') - ctx.subagents.registerProvider(provider) - class ExoticTextBlock { - readonly type = 'text' - readonly text = 'hello' - } + 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' }) + }) - expect(() => ctx.subagents.start('prompt-boundary', baseRequest({ - prompt: [new ExoticTextBlock()] as unknown as SubagentStartRequest['prompt'], - }))).toThrow('subagent prompt must be losslessly JSON-serializable') + it.each([ + ['outputSchema', { outputSchema: { type: 'object', properties: {} } }], + ['depthLimit', { maxDepth: 1 }], + ['toolFilter', { toolFilter: { deny: ['bash'] } }], + ['persona', { persona: 'reviewer' }], + ] as const)('rejects unsupported %s before provider startup', async (_capability, override) => { + const { subagents } = await service() + const provider = new StubProvider('weak', NO_CAPS) + subagents.registerProvider(provider) + await expect(subagents.start('weak', baseRequest(override))) + .rejects.toMatchObject({ code: 'UNSUPPORTED_CAPABILITY' }) expect(provider.startCount).toBe(0) }) - it.each([ - { - label: 'agent options', - overrides: { agentOptions: { model: Number.NaN as unknown as string } }, - message: 'subagent agent options must be losslessly JSON-serializable', - }, - { - label: 'tool filter', - overrides: { toolFilter: { deny: [Number.NaN as unknown as string] } }, - message: 'subagent tool filter must be losslessly JSON-serializable', - }, - { - label: 'output schema', - overrides: { - outputSchema: { - type: 'object', - properties: { answer: { type: Number.NaN } }, - } as unknown as NonNullable, - }, - message: 'schema annotation must be JSON data', - }, - ])('rejects non-JSON $label before the provider starts', async ({ overrides, message }) => { - const ctx = new Context() - await ctx.plugin(SubagentService) - const provider = new StubProvider('invalid-request-data', ALL_CAPS) - ctx.subagents.registerProvider(provider) - - expect(() => ctx.subagents.start('invalid-request-data', baseRequest(overrides))) - .toThrow(message) + it('validates depth and schema semantics before provider startup', async () => { + const { subagents } = await service() + const provider = new StubProvider('strong') + subagents.registerProvider(provider) + await expect(subagents.start('strong', baseRequest({ maxDepth: -1 }))) + .rejects.toThrow('non-negative safe integer') + await expect(subagents.start('strong', baseRequest({ outputSchema: { type: 'string' } as never }))) + .rejects.toThrow() expect(provider.startCount).toBe(0) + expect(() => { assertSubagentMaxDepth(undefined) }).not.toThrow() }) - it('reads each nested prompt value once into the provider snapshot', async () => { - const ctx = new Context() - await ctx.plugin(SubagentService) - const provider = new StubProvider('unstable-prompt') - ctx.subagents.registerProvider(provider) - let reads = 0 - const block = Object.defineProperties({}, { - type: { enumerable: true, value: 'text' }, - text: { - enumerable: true, - get: () => { - reads += 1 - return reads === 1 ? 'hello' : new Map([['not', 'json']]) - }, - }, - }) - - expect(() => ctx.subagents.start('unstable-prompt', baseRequest({ - prompt: [block] as unknown as SubagentStartRequest['prompt'], - }))).not.toThrow() - expect(reads).toBe(1) - expect(provider.startCount).toBe(1) - }) - - it('emits subagent/start then subagent/end around a run', async () => { - const ctx = new Context() - await ctx.plugin(SubagentService) - ctx.subagents.registerProvider(new StubProvider('events')) - - const started = vi.fn() - const ended = vi.fn() - ctx.on('subagent/start', started) - ctx.on('subagent/end', ended) - - const run = ctx.subagents.start('events', baseRequest()) - await run.started - expect(started).toHaveBeenCalledWith(expect.objectContaining({ provider: 'events', id: run.id })) - - await run.result - // `subagent/end` fires from a `.then` on the result — let the microtask run. - await Promise.resolve() - expect(ended).toHaveBeenCalledWith(expect.objectContaining({ provider: 'events', id: run.id, stopReason: 'completed' })) - }) - - it('captures a provider run once and gives callers and telemetry one normalized result', async () => { - const ctx = new Context() - await ctx.plugin(SubagentService) - const reads = { - id: 0, - started: 0, - result: 0, - cancel: 0, - sendMessage: 0, - dispose: 0, - resume: 0, - output: 0, - structured: 0, - stopReason: 0, - } - const methodReceivers: string[] = [] - const providerResult = Object.defineProperties({}, { - output: { - enumerable: true, - get: () => { - reads.output += 1 - return reads.output === 1 - ? [{ type: 'text', text: 'accepted output' }] - : [{ type: 'text', text: 'drifted output' }] - }, - }, - structured: { - enumerable: true, - get: () => { - reads.structured += 1 - return { verdict: reads.structured === 1 ? 'accepted' : 'drifted' } - }, - }, - stopReason: { - enumerable: true, - get: () => { - reads.stopReason += 1 - return reads.stopReason === 1 ? 'completed' : 'error' - }, - }, - }) as SubagentResult - const providerRun = Object.defineProperties({}, { - id: { - enumerable: true, - get: () => { - reads.id += 1 - return AgentId(reads.id === 1 ? 'accepted-child' : 'drifted-child') - }, - }, - started: { - enumerable: true, - get: () => { - reads.started += 1 - if (reads.started !== 1) throw new Error('started reread') - return Promise.resolve() - }, - }, - result: { - enumerable: true, - get: () => { - reads.result += 1 - if (reads.result !== 1) throw new Error('result reread') - return Promise.resolve(providerResult) - }, - }, - cancel: { - enumerable: true, - get: () => { - reads.cancel += 1 - if (reads.cancel !== 1) throw new Error('cancel reread') - return function (this: SubagentRun): void { - expect(this).toBe(providerRun) - methodReceivers.push('cancel') - } - }, - }, - sendMessage: { - enumerable: true, - get: () => { - reads.sendMessage += 1 - if (reads.sendMessage !== 1) throw new Error('sendMessage reread') - return function (this: SubagentRun): void { - expect(this).toBe(providerRun) - methodReceivers.push('sendMessage') - } - }, - }, - dispose: { - enumerable: true, - get: () => { - reads.dispose += 1 - if (reads.dispose !== 1) throw new Error('dispose reread') - return async function (this: SubagentRun): Promise { - expect(this).toBe(providerRun) - methodReceivers.push('dispose') - } - }, - }, - resume: { - enumerable: true, - get: () => { - reads.resume += 1 - if (reads.resume !== 1) throw new Error('resume reread') - return function (this: SubagentRun): SubagentRun { - expect(this).toBe(providerRun) - methodReceivers.push('resume') - return providerRun - } - }, - }, - }) as SubagentRun - ctx.subagents.registerProvider({ - name: 'stateful-run', - capabilities: NO_CAPS, - inheritsParentContext: false, - start: () => providerRun, - }) - const ended = vi.fn() - ctx.on('subagent/end', ended) - - const run = ctx.subagents.start('stateful-run', baseRequest()) - expect(Object.is(run, providerRun)).toBe(false) - expect(Object.isFrozen(run)).toBe(true) - run.cancel() - run.sendMessage?.([]) - expect(Object.is(run.resume?.([]), providerRun)).toBe(true) - await run.dispose() - const result = await run.result - await run.started - await Promise.resolve() - - expect(reads).toEqual({ - id: 1, - started: 1, - result: 1, - cancel: 1, - sendMessage: 1, - dispose: 1, - resume: 1, - output: 1, - structured: 1, - stopReason: 1, - }) - expect(methodReceivers).toEqual(['cancel', 'sendMessage', 'resume', 'dispose']) - expect(result).toEqual({ - output: [{ type: 'text', text: 'accepted output' }], - structured: { verdict: 'accepted' }, - stopReason: 'completed', - }) - expect(Object.isFrozen(result)).toBe(true) - expect(Object.isFrozen(result.output)).toBe(true) - expect(ended).toHaveBeenCalledWith({ - provider: 'stateful-run', - id: 'accepted-child', - stopReason: 'completed', - lastAssistantMessage: [{ type: 'text', text: 'accepted output' }], - }) - }) - - it.each([ - { label: 'a non-string id', field: 'id', value: 42, message: 'run id must be a string' }, - { label: 'a non-Promise started field', field: 'started', value: undefined, message: 'run started must be a Promise' }, - { label: 'a non-Promise result field', field: 'result', value: undefined, message: 'run result must be a Promise' }, - { label: 'a non-callable cancel field', field: 'cancel', value: undefined, message: 'run cancel must be a function' }, - { label: 'a non-callable sendMessage field', field: 'sendMessage', value: 42, message: 'run sendMessage must be a function' }, - { label: 'a non-callable resume field', field: 'resume', value: 42, message: 'run resume must be a function' }, - ])('rolls back a provider run with $label', async ({ field, value, message }) => { - const ctx = new Context() - await ctx.plugin(SubagentService) - const providerDispose = vi.fn(async () => {}) - const providerRun = { - id: AgentId('invalid-handle-child'), - started: Promise.resolve(), - result: Promise.resolve({ output: [], stopReason: 'completed' } satisfies SubagentResult), - cancel() {}, - dispose: providerDispose, - [field]: value, - } as unknown as SubagentRun - ctx.subagents.registerProvider({ - name: 'invalid-handle', - capabilities: NO_CAPS, - inheritsParentContext: false, - start: () => providerRun, - }) - - expect(() => ctx.subagents.start('invalid-handle', baseRequest())).toThrow(message) - expect(providerDispose).toHaveBeenCalledOnce() - }) - - it.each([ - { label: 'null', value: null }, - { label: 'a primitive', value: 42 }, - ])('rejects $label returned by provider.start before reading a disposer', async ({ value }) => { - const ctx = new Context() - await ctx.plugin(SubagentService) - ctx.subagents.registerProvider({ - name: 'invalid-run-shell', - capabilities: NO_CAPS, - inheritsParentContext: false, - start: () => value as unknown as SubagentRun, - }) - - expect(() => ctx.subagents.start('invalid-run-shell', baseRequest())).toThrow('must return a SubagentRun object') - }) - - it('rejects a run without a callable disposer before accepting ownership', async () => { - const ctx = new Context() - await ctx.plugin(SubagentService) - ctx.subagents.registerProvider({ - name: 'invalid-dispose', - capabilities: NO_CAPS, - inheritsParentContext: false, - start: () => ({ dispose: 42 }) as unknown as SubagentRun, - }) - - expect(() => ctx.subagents.start('invalid-dispose', baseRequest())).toThrow('run dispose must be a function') - }) - - it('observes accepted provider promises when a later handle field is malformed', async () => { - const ctx = new Context() - await ctx.plugin(SubagentService) - const providerDispose = vi.fn(async () => {}) - ctx.subagents.registerProvider({ - name: 'rejected-malformed-handle', - capabilities: NO_CAPS, - inheritsParentContext: false, - start: () => ({ - id: AgentId('rejected-malformed-child'), - started: Promise.reject(new Error('readiness already rejected')), - result: Promise.reject(new Error('result already rejected')), - cancel: 42, - dispose: providerDispose, - }) as unknown as SubagentRun, - }) - - expect(() => ctx.subagents.start('rejected-malformed-handle', baseRequest())).toThrow('run cancel must be a function') - expect(providerDispose).toHaveBeenCalledOnce() - // Let both provider rejections run: the seam's immediate observers keep - // them from surfacing as unhandled after no wrapper was returned. - await Promise.resolve() - }) - - it('starts rollback before surfacing a hostile run accessor failure', async () => { - const ctx = new Context() - await ctx.plugin(SubagentService) - const disposalGate = Promise.withResolvers() - const order: string[] = [] - const providerRun = Object.defineProperties({}, { - dispose: { - get: () => { - order.push('dispose:get') - return async function (this: SubagentRun): Promise { - expect(this).toBe(providerRun) - order.push('dispose:call') - await disposalGate.promise - order.push('dispose:quiescent') - } - }, - }, - id: { get: () => { order.push('id:get'); return AgentId('hostile-handle-child') } }, - started: { get: () => { order.push('started:get'); return Promise.resolve() } }, - result: { get: () => { order.push('result:get'); throw new Error('result accessor exploded') } }, - }) as SubagentRun - ctx.subagents.registerProvider({ - name: 'hostile-handle', - capabilities: NO_CAPS, - inheritsParentContext: false, - start: () => providerRun, - }) - - expect(() => ctx.subagents.start('hostile-handle', baseRequest())).toThrow('result accessor exploded') - expect(order).toEqual(['dispose:get', 'id:get', 'started:get', 'result:get', 'dispose:call']) - disposalGate.resolve(undefined) - await vi.waitFor(() => { expect(order).toContain('dispose:quiescent') }) - }) - - it('rolls back when binding a hostile optional run method fails', async () => { - const ctx = new Context() - await ctx.plugin(SubagentService) - const providerDispose = vi.fn(async () => {}) - const hostileCancel = new Proxy(() => {}, { - get(_target, property) { - if (property === 'length') throw new Error('cancel bind exploded') - return undefined - }, - }) - ctx.subagents.registerProvider({ - name: 'hostile-bind', - capabilities: NO_CAPS, - inheritsParentContext: false, - start: () => ({ - id: AgentId('hostile-bind-child'), - started: Promise.resolve(), - result: Promise.resolve({ output: [], stopReason: 'completed' }), - cancel: hostileCancel, - dispose: providerDispose, - }), - }) - - expect(() => ctx.subagents.start('hostile-bind', baseRequest())).toThrow('cancel bind exploded') - expect(providerDispose).toHaveBeenCalledOnce() - }) - - it.each([ - { label: 'an Error', thrown: new Error('cleanup exploded'), warning: 'cleanup exploded' }, - { label: 'a non-Error value', thrown: 'naked cleanup fault', warning: 'dispose threw a non-Error value' }, - ])('contains rollback failure from $label while preserving the malformed-handle fault', async ({ thrown, warning }) => { - const ctx = new Context() - await ctx.plugin(SubagentService) - const warnings: string[] = [] - ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn - ctx.subagents.registerProvider({ - name: 'rollback-failure', - capabilities: NO_CAPS, - inheritsParentContext: false, - start: () => ({ - id: 42, - started: Promise.resolve(), - result: Promise.resolve({ output: [], stopReason: 'completed' }), - cancel() {}, - dispose: () => { - // Deliberately violate the seam contract to exercise normalization. - throw thrown - }, - }) as unknown as SubagentRun, - }) - - expect(() => ctx.subagents.start('rollback-failure', baseRequest())).toThrow('run id must be a string') - await vi.waitFor(() => { expect(warnings.some(message => message.includes(warning))).toBe(true) }) - }) - - it('waits for provider readiness and observes an early result rejection without reordering lifecycle', async () => { - const ctx = new Context() - await ctx.plugin(SubagentService) - const readiness = Promise.withResolvers() - ctx.subagents.registerProvider({ - name: 'delayed-start', - capabilities: NO_CAPS, - inheritsParentContext: false, - start: () => ({ - id: AgentId('delayed-child'), - started: readiness.promise, - // Already rejected: SubagentService must attach its result handler in - // the same synchronous start() call, before awaiting readiness. - result: Promise.reject(new Error('early infrastructure fault')), - cancel() {}, - async dispose() {}, - }), - }) - const lifecycle: string[] = [] - ctx.on('subagent/start', () => void lifecycle.push('start')) - ctx.on('subagent/end', info => void lifecycle.push(`end:${info.stopReason}`)) - - const run = ctx.subagents.start('delayed-start', baseRequest()) - await expect(run.result).rejects.toThrow('early infrastructure fault') - expect(lifecycle).toEqual([]) - - readiness.resolve(undefined) - await run.started - expect(lifecycle).toEqual(['start', 'end:error']) - }) - - it('emits no lifecycle pair when readiness rejects before a child exists', async () => { - const ctx = new Context() - await ctx.plugin(SubagentService) - const readiness = Promise.withResolvers() + it('publishes lifecycle only after async provider start and keeps parent scope', async () => { + const { ctx, subagents } = await service() + const ready = Promise.withResolvers() const result = Promise.withResolvers() - ctx.subagents.registerProvider({ - name: 'never-started', + subagents.registerProvider({ + name: 'deferred', capabilities: NO_CAPS, inheritsParentContext: false, - start: () => ({ - id: AgentId('never-started-child'), - started: readiness.promise, - result: result.promise, - cancel() {}, - async dispose() {}, - }), + start: () => ready.promise, + }) + const parent = fakeParent('delegator') + const events: string[] = [] + const keys: unknown[] = [] + ctx.on('subagent/start', function () { events.push('start'); keys.push(carrierKeyOf(this)) }) + ctx.on('subagent/end', function () { events.push('end'); keys.push(carrierKeyOf(this)) }) + + const starting = subagents.start('deferred', baseRequest({ parent })) + await Promise.resolve() + expect(events).toEqual([]) + ready.resolve({ id: AgentId('child'), result: result.promise, async dispose() {} }) + const run = await starting + expect(events).toEqual(['start']) + result.resolve({ output: [{ type: 'text', text: 'answer' }], stopReason: 'completed' }) + await run.result + await Promise.resolve() + expect(events).toEqual(['start', 'end']) + expect(keys).toEqual([parent, parent]) + }) + + it('emits no run lifecycle when provider startup rejects', async () => { + const { ctx, subagents } = await service() + subagents.registerProvider({ + name: 'failed', + capabilities: NO_CAPS, + inheritsParentContext: false, + start: async () => { throw new Error('setup rolled back') }, }) const lifecycle = vi.fn() ctx.on('subagent/start', lifecycle) ctx.on('subagent/end', lifecycle) - - const run = ctx.subagents.start('never-started', baseRequest()) - readiness.reject(new Error('publication rolled back')) - await expect(run.started).rejects.toThrow('publication rolled back') - result.resolve({ output: [], stopReason: 'aborted' }) - await run.result - await Promise.resolve() + await expect(subagents.start('failed', baseRequest())).rejects.toThrow('setup rolled back') expect(lifecycle).not.toHaveBeenCalled() }) - it('pins start and end to the parent accepted at start despite caller mutation', async () => { - const ctx = new Context() - await ctx.plugin(SubagentService) - const gate = Promise.withResolvers() - let acceptedRequest: SubagentStartRequest | undefined - ctx.subagents.registerProvider({ - name: 'deferred', + 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/end', ended) + const run = await subagents.start('completed', baseRequest()) + await run.result + await Promise.resolve() + expect(ended).toHaveBeenCalledWith(expect.objectContaining({ + provider: 'completed', + lastAssistantMessage: [{ type: 'text', text: 'answer' }], + stopReason: 'completed', + })) + + const failure = Promise.withResolvers() + subagents.registerProvider({ + name: 'infra', capabilities: NO_CAPS, inheritsParentContext: false, - start: (accepted) => { - acceptedRequest = accepted - return { - id: AgentId('deferred-child'), - started: Promise.resolve(), - result: gate.promise, - cancel() {}, - async dispose() {}, - } + async start() { + return { id: AgentId('infra-child'), result: failure.promise, async dispose() {} } }, }) - const accepted = fakeParent('accepted-parent') - const replacement = fakeParent('replacement-parent') - const keys: unknown[] = [] - ctx.on('subagent/start', function () { keys.push(carrierKeyOf(this)) }) - ctx.on('subagent/end', function () { keys.push(carrierKeyOf(this)) }) - const request = baseRequest({ parent: accepted }) - - const run = ctx.subagents.start('deferred', request) - request.parent = replacement - request.prompt[0] = { type: 'text', text: 'mutated prompt' } - expect(acceptedRequest?.parent).toBe(accepted) - expect(acceptedRequest?.prompt).toEqual([{ type: 'text', text: 'do a thing' }]) - expect(acceptedRequest?.prompt).not.toBe(request.prompt) - gate.resolve({ output: [], stopReason: 'completed' }) - await run.result + const failedRun = await subagents.start('infra', baseRequest()) + failure.reject(new Error('transport')) + await expect(failedRun.result).rejects.toThrow('transport') await Promise.resolve() - - expect(keys).toEqual([accepted, accepted]) + expect(ended).toHaveBeenCalledWith(expect.objectContaining({ provider: 'infra', stopReason: 'error' })) }) - it('carries lastAssistantMessage (the child output) onto the end event', async () => { - const ctx = new Context() - await ctx.plugin(SubagentService) - ctx.subagents.registerProvider(new StubProvider( - 'enriched', - ALL_CAPS, - { output: [{ type: 'text', text: 'the child answer' }], stopReason: 'completed' }, - )) - - const started = vi.fn() - const ended = vi.fn() - ctx.on('subagent/start', started) - ctx.on('subagent/end', ended) - - const run = ctx.subagents.start('enriched', baseRequest()) - await run.started - expect(started).toHaveBeenCalledWith(expect.objectContaining({ provider: 'enriched', id: run.id })) - - await run.result - await Promise.resolve() - expect(ended).toHaveBeenCalledWith(expect.objectContaining({ - provider: 'enriched', - id: run.id, - stopReason: 'completed', - lastAssistantMessage: [{ type: 'text', text: 'the child answer' }], - })) - }) - - it('observe-only: a mutating subagent/end listener cannot corrupt the caller or later listeners', 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 or the value - // a later observer sees. The service freezes one normalized result and the - // lifecycle payload before dispatching either public surface. - 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 later = vi.fn() - ctx.on('subagent/end', later) - - 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 and the listener after the mutator both retain the accepted value. - expect(result.output).toEqual([{ type: 'text', text: 'original' }]) - expect(Object.isFrozen(result.output)).toBe(true) - expect(later).toHaveBeenCalledWith(expect.objectContaining({ - stopReason: 'completed', - lastAssistantMessage: [{ type: 'text', text: 'original' }], - })) - const laterInfo = later.mock.calls[0]![0] as Record - expect(Object.isFrozen(laterInfo)).toBe(true) - expect(Object.isFrozen(laterInfo.lastAssistantMessage)).toBe(true) - }) - - 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', - capabilities: NO_CAPS, - inheritsParentContext: false, - start: () => ({ - id: AgentId('rej-child'), - started: Promise.resolve(), - result: Promise.reject(new Error('infra fault')), - cancel() {}, - dispose: async () => {}, - }), - }) - - const ended = vi.fn() - ctx.on('subagent/end', ended) - const run = ctx.subagents.start('rej', baseRequest()) - await run.result.catch(() => {}) - await Promise.resolve() - - const endInfo = ended.mock.calls[0]![0] as Record - expect(endInfo.stopReason).toBe('error') - expect('lastAssistantMessage' in endInfo).toBe(false) // no output exists on reject - }) - - it('rejects an invalid provider output and maps the contract fault to error telemetry', async () => { - // A function is outside the lossless JSON vocabulary. The service-owned - // result promise rejects instead of exposing the malformed provider value; - // its already-attached lifecycle observer maps that infrastructure fault to - // error telemetry without producing an unhandled rejection. - const ctx = new Context() - await ctx.plugin(SubagentService) - const nonJsonOutput = [{ type: 'text', text: 'x', evil: () => 0 }] as unknown as SubagentResult['output'] - const disposalGate = Promise.withResolvers() - const providerDispose = vi.fn(async () => { await disposalGate.promise }) - ctx.subagents.registerProvider({ - name: 'unclone', - capabilities: NO_CAPS, - inheritsParentContext: false, - start: () => ({ - id: AgentId('unclone-child'), - started: Promise.resolve(), - result: Promise.resolve({ output: nonJsonOutput, stopReason: 'completed' } as SubagentResult), - cancel() {}, - dispose: providerDispose, - }), - }) - - const ended = vi.fn() - ctx.on('subagent/end', ended) - const run = ctx.subagents.start('unclone', baseRequest()) - let resultSettled = false - void run.result.catch(() => { resultSettled = true }) - await vi.waitFor(() => { expect(providerDispose).toHaveBeenCalledOnce() }) - expect(resultSettled).toBe(false) - disposalGate.resolve(undefined) - await expect(run.result).rejects.toThrow('subagent result must be losslessly JSON-serializable') - await run.dispose() - await Promise.resolve() - - expect(providerDispose).toHaveBeenCalledOnce() - const endInfo = ended.mock.calls[0]![0] as Record - expect(endInfo.stopReason).toBe('error') - expect('lastAssistantMessage' in endInfo).toBe(false) - }) - - it.each([ - { - label: 'a non-array output', - value: { output: { type: 'text', text: 'not an array' }, stopReason: 'completed' }, - message: 'subagent result output must be an array', - }, - { - label: 'a non-string stopReason', - value: { output: [], stopReason: 42 }, - message: 'subagent result stopReason must be a string', - }, - ])('rejects a provider result with $label', async ({ value, message }) => { - const ctx = new Context() - await ctx.plugin(SubagentService) - ctx.subagents.registerProvider({ - name: 'invalid-shape', - capabilities: NO_CAPS, - inheritsParentContext: false, - start: () => ({ - id: AgentId('invalid-shape-child'), - started: Promise.resolve(), - result: Promise.resolve(value as unknown as SubagentResult), - cancel() {}, - async dispose() {}, - }), - }) - const ended = vi.fn() - ctx.on('subagent/end', ended) - - const run = ctx.subagents.start('invalid-shape', baseRequest()) - await expect(run.result).rejects.toThrow(message) - await Promise.resolve() - - expect(ended).toHaveBeenCalledWith(expect.objectContaining({ - provider: 'invalid-shape', - id: 'invalid-shape-child', - stopReason: 'error', - })) - }) - - it('emits subagent/end with stopReason "error" when the run result promise rejects', async () => { - const ctx = new Context() - await ctx.plugin(SubagentService) - // A provider whose run.result REJECTS (an infrastructure fault — the seam - // contract says child-level failures resolve with stopReason 'error', but a - // rejection is still surfaced as an 'error' telemetry event). - ctx.subagents.registerProvider({ - name: 'rejecter', - capabilities: NO_CAPS, - inheritsParentContext: false, - start: () => ({ - id: AgentId('rej-child'), - started: Promise.resolve(), - result: Promise.reject(new Error('infra fault')), - cancel() {}, - dispose: async () => {}, - }), - }) - - const ended = vi.fn() - ctx.on('subagent/end', ended) - const run = ctx.subagents.start('rejecter', baseRequest()) - // Observe (and swallow) the rejection the consumer would see, then let the - // detached `.then` settle the telemetry emit. - await run.result.catch(() => {}) - await Promise.resolve() - expect(ended).toHaveBeenCalledWith(expect.objectContaining({ provider: 'rejecter', id: run.id, stopReason: 'error' })) - }) - - it('contains a throwing subagent/start listener per-listener: a later listener still observes the event and start() returns the run', async () => { - const ctx = new Context() - await ctx.plugin(SubagentService) - ctx.subagents.registerProvider(new StubProvider('contain')) - // Two listeners; the FIRST throws. 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() - await run.started - expect(second).toHaveBeenCalledWith(expect.objectContaining({ provider: 'contain', id: run.id })) - await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' }) - }) - - it('contains asynchronous lifecycle-listener rejections without serializing later listeners', async () => { - const ctx = new Context() - await ctx.plugin(SubagentService) + it('contains synchronous and asynchronous lifecycle observer failures', async () => { + const { ctx, subagents } = await service() const warnings: string[] = [] - ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn - const laterStart = vi.fn() - const laterEnd = vi.fn() - const laterRemoved = vi.fn() - const asyncStart = (async () => { await Promise.resolve(); throw new Error('async start listener') }) as unknown as () => void - const asyncEnd = (async () => { await Promise.resolve(); throw new Error('async end listener') }) as unknown as () => void - const asyncRemoved = (async () => { await Promise.resolve(); throw new Error('async removed listener') }) as unknown as () => void - ctx.on('subagent/start', asyncStart) - ctx.on('subagent/start', laterStart) - ctx.on('subagent/end', asyncEnd) - ctx.on('subagent/end', laterEnd) - ctx.on('subagent/provider-removed', asyncRemoved) - ctx.on('subagent/provider-removed', laterRemoved) - const unregister = ctx.subagents.registerProvider(new StubProvider('async-listeners')) + 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 run = ctx.subagents.start('async-listeners', baseRequest()) - await run.started - expect(laterStart).toHaveBeenCalledOnce() - await run.result - await vi.waitFor(() => { - expect(laterEnd).toHaveBeenCalledOnce() - expect(warnings.some(message => message.includes('async start listener'))).toBe(true) - expect(warnings.some(message => message.includes('async end listener'))).toBe(true) - }) - - await unregister() - expect(laterRemoved).toHaveBeenCalledWith('async-listeners') - await vi.waitFor(() => { - expect(warnings.some(message => message.includes('async removed listener'))).toBe(true) - }) - }) - - it('contains a listener whose thrown value cannot be stringified', async () => { - const ctx = new Context() - await ctx.plugin(SubagentService) - ctx.subagents.registerProvider(new StubProvider('hostile-listener')) - const warnings: string[] = [] - ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn - const hostile = { - [Symbol.toPrimitive]() { throw new Error('render failed') }, - } - const second = vi.fn() - ctx.on('subagent/start', () => { throw hostile }) - ctx.on('subagent/start', second) - - const run = ctx.subagents.start('hostile-listener', baseRequest()) - await run.started - - expect(second).toHaveBeenCalledOnce() + await dispose() + await Promise.resolve() + 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(''))).toBe(true) - await run.result }) - it('rejects a throwing provider result accessor and maps it to error telemetry', async () => { - const ctx = new Context() - await ctx.plugin(SubagentService) - ctx.subagents.registerProvider({ - name: 'hostile-result', - capabilities: NO_CAPS, - inheritsParentContext: false, - start: () => ({ - id: AgentId('hostile-result-child'), - started: Promise.resolve(), - result: Promise.resolve({ - output: [], - get stopReason(): 'completed' { throw new Error('stop reason exploded') }, - }), - cancel() {}, - async dispose() {}, - }), - }) - const ended = vi.fn() - ctx.on('subagent/end', ended) - - const run = ctx.subagents.start('hostile-result', baseRequest()) - await run.started - await expect(run.result).rejects.toThrow('stop reason exploded') - await Promise.resolve() - - expect(ended).toHaveBeenCalledWith(expect.objectContaining({ - provider: 'hostile-result', - id: 'hostile-result-child', - stopReason: 'error', - })) - }) - - 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') }) }) diff --git a/packages/subagent/subagent/tsconfig.json b/packages/subagent/subagent/tsconfig.json index 3d3c9be6a9..f93f929241 100644 --- a/packages/subagent/subagent/tsconfig.json +++ b/packages/subagent/subagent/tsconfig.json @@ -17,9 +17,6 @@ { "path": "../../core/agent" }, - { - "path": "../../core/session" - }, { "path": "../../llm/llm" }, diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index 2163d4c884..fbd01a0cb2 100644 --- a/packages/subagent/tool-subagent/README.md +++ b/packages/subagent/tool-subagent/README.md @@ -1,28 +1,28 @@ # @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 conversation-history descriptor +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-conversation provider (spawn, ACP) gets the standalone-prompt wording ("it does not see this conversation"), while fork tells the model the child is seeded with the conversation's completed turns and its prompt should state only what is new. The descriptor concerns conversation history only, not tool or authority inheritance. 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 plugins 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 directly as the required `SubagentStartRequest.signal`, awaits `ctx.subagents.start(...)`, then awaits `run.result` inside a `try/finally` that always calls `run.dispose()`. The same signal therefore covers startup and live execution, while disposal guarantees quiescence on success, failure, and abort. + +A non-`completed` stop reason becomes an `isError` tool result; partial child output is never reported as success. The current tool blocks the parent turn until collection finishes; background and polling modes are deferred. + +## Config + +| Key | Meaning | |---|---| -| `provider` (required) | 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. | -| `persona` | Per-child persona that shadows the deployment persona; requires the provider's `persona` capability. | -| `toolFilter` | Per-child `{ allow?, deny? }` restriction over global tools; requires the provider's `toolFilter` capability. | -| `maxDepth` | Maximum absolute delegation-tree depth for every child this tool starts; a non-negative safe integer validated when this plugin loads. Requires the provider's `depthLimit` capability. | +| `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. | -`toolFilter` uses [`ToolRegistry.restrict()`](../../core/tools/README.md)'s live global-view semantics and 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). - -## 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. +`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). diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 25bd5e3acf..322a76e825 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -242,24 +242,14 @@ export function apply(ctx: Context, config: Config): void { const request: SubagentStartRequest = { prompt: [{ type: 'text', text: args.prompt }], parent, - ...exec.signal ? { signal: exec.signal } : {}, + signal: exec.signal ?? new AbortController().signal, ...config.agentOptions !== undefined ? { agentOptions: config.agentOptions } : {}, ...config.persona !== undefined ? { persona: config.persona } : {}, ...config.toolFilter !== undefined ? { toolFilter: config.toolFilter } : {}, ...config.maxDepth !== undefined ? { maxDepth: config.maxDepth } : {}, } - const run: SubagentRun = ctx.subagents.start(config.provider, request) - - // Bridge the tool's abort signal to the run: if the parent step is - // aborted while the child is in flight, cancel the child too. - const onAbort = (): void => { run.cancel('parent step aborted') } - exec.signal?.addEventListener('abort', onAbort, { once: true }) - // `addEventListener` does NOT fire for a signal already aborted before this - // line, so a step cancelled before the tool ran would never reach the - // child. 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 @@ -271,7 +261,6 @@ export function apply(ctx: Context, config: Config): void { } return [{ type: 'text', text: outputText(result.output) }] } finally { - exec.signal?.removeEventListener('abort', onAbort) // Always reach child quiescence — never leak a live idle child/session. await run.dispose() } diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 701a4dc667..5830513cf4 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -113,11 +113,9 @@ describe('dsh-tool-subagent', () => { name: 'weird', capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, - start: () => ({ + start: async () => ({ id: AgentId('weird-child'), - started: Promise.resolve(), result: Promise.resolve({ output: [{ type: 'text', text: 'partial' }], stopReason: 'frobnicated' as never }), - cancel() {}, dispose: async () => {}, }), }) @@ -140,13 +138,11 @@ describe('dsh-tool-subagent', () => { name: 'capture', capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, - start: (request) => { + start: async (request) => { seen = request return { id: AgentId('capture-child'), - started: Promise.resolve(), result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), - cancel() {}, dispose: async () => {}, } }, @@ -171,13 +167,11 @@ describe('dsh-tool-subagent', () => { name: 'bare', capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, - start: (request) => { + start: async (request) => { seen = request return { id: AgentId('bare-child'), - started: Promise.resolve(), result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), - cancel() {}, dispose: async () => {}, } }, @@ -302,11 +296,9 @@ describe('dsh-tool-subagent', () => { name: 'spy', capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, - start: () => ({ + start: async () => ({ id: AgentId('spy-child'), - started: Promise.resolve(), result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), - cancel() {}, dispose: async () => void disposed(), }), }) @@ -326,11 +318,9 @@ describe('dsh-tool-subagent', () => { name: 'spy', capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, - start: () => ({ + start: async () => ({ id: AgentId('spy-child'), - started: Promise.resolve(), result: Promise.resolve({ output: [], stopReason: 'error' as const }), - cancel() {}, dispose: async () => void disposed(), }), }) @@ -341,7 +331,7 @@ describe('dsh-tool-subagent', () => { expect(disposed).toHaveBeenCalledTimes(1) }) - it('bridges the tool abort signal to run.cancel()', async () => { + it('passes the tool abort signal as the provider cancellation channel', async () => { const cancelled = vi.fn() const ctx = new Context() await ctx.plugin(SystemPrompt) @@ -351,17 +341,17 @@ describe('dsh-tool-subagent', () => { name: 'spy', capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, - start: () => { + start: async (request) => { + if (request.signal.aborted) throw new Error('start aborted') let resolveResult: (r: { output: never[]; stopReason: 'aborted' }) => void const result = new Promise<{ output: never[]; stopReason: 'aborted' }>((res) => { resolveResult = res }) + request.signal.addEventListener('abort', () => { + cancelled() + resolveResult({ output: [], stopReason: 'aborted' }) + }, { once: true }) return { id: AgentId('spy-child'), - started: Promise.resolve(), result, - cancel: () => { - cancelled() - resolveResult({ output: [], stopReason: 'aborted' }) - }, dispose: async () => {}, } }, @@ -370,12 +360,7 @@ describe('dsh-tool-subagent', () => { const controller = new AbortController() const pending = callSubagent(ctx, { description: 'd', prompt: 'p' }, { signal: controller.signal }) - // Abort AFTER the tool body has had a chance to register its abort listener - // (ctx.tools.execute now awaits the tools/pre-execute waterfall before the - // body runs, so the listener is not registered synchronously). 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() @@ -384,13 +369,8 @@ describe('dsh-tool-subagent', () => { expect(result.isError).toBe(true) }) - it('cancels the run when the tool signal is ALREADY aborted before execute (no missed abort)', async () => { - // `addEventListener('abort')` does not fire for a signal already aborted - // before the listener is added, so a step cancelled before the tool ran - // would never reach the child unless the bridge re-checks `signal.aborted`. - // 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) @@ -399,19 +379,9 @@ describe('dsh-tool-subagent', () => { name: 'spy', capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, - start: () => { - let resolveResult: (r: { output: never[]; stopReason: 'aborted' }) => void - const result = new Promise<{ output: never[]; stopReason: 'aborted' }>((res) => { resolveResult = res }) - return { - id: AgentId('spy-child'), - started: Promise.resolve(), - result, - cancel: () => { - cancelled() - resolveResult({ output: [], stopReason: 'aborted' }) - }, - dispose: async () => {}, - } + start: async (request) => { + if (request.signal.aborted) sawAborted() + throw new Error('start aborted') }, }) await ctx.plugin(tool, { provider: 'spy' }) @@ -419,7 +389,7 @@ describe('dsh-tool-subagent', () => { const controller = new AbortController() controller.abort() // already aborted BEFORE the tool runs const result = await callSubagent(ctx, { description: 'd', prompt: 'p' }, { signal: controller.signal }) - expect(cancelled).toHaveBeenCalledTimes(1) + expect(sawAborted).toHaveBeenCalledTimes(1) expect(result.isError).toBe(true) }) @@ -469,13 +439,11 @@ describe('dsh-tool-subagent', () => { name: 'capture2', capabilities: { outputSchema: false, depthLimit: true, toolFilter: true, persona: true }, inheritsParentContext: false, - start: (request) => { + start: async (request) => { seen = request return { id: AgentId('capture2-child'), - started: Promise.resolve(), result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), - cancel() {}, dispose: async () => {}, } }, @@ -519,7 +487,7 @@ describe('dsh-tool-subagent', () => { }) it('a partial toolFilter (deny only) does not materialize an empty allow-list (deny-all trap)', async () => { - let seen: { toolFilter?: { allow?: string[]; deny?: string[] } } | undefined + let seen: { toolFilter?: { readonly allow?: readonly string[]; readonly deny?: readonly string[] } } | undefined const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) @@ -528,13 +496,11 @@ describe('dsh-tool-subagent', () => { name: 'capture3', capabilities: { outputSchema: false, depthLimit: false, toolFilter: true, persona: false }, inheritsParentContext: false, - start: (request) => { + start: async (request) => { seen = request return { id: AgentId('capture3-child'), - started: Promise.resolve(), result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), - cancel() {}, dispose: async () => {}, } }, @@ -559,13 +525,11 @@ describe('dsh-tool-subagent', () => { name: 'capture4', capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, - start: (request) => { + start: async (request) => { seen = request return { id: AgentId('capture4-child'), - started: Promise.resolve(), result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), - cancel() {}, dispose: async () => {}, } }, diff --git a/packages/support/subagent-mock/README.md b/packages/support/subagent-mock/README.md index 6114bdf2c9..b8d9fbba44 100644 --- a/packages/support/subagent-mock/README.md +++ b/packages/support/subagent-mock/README.md @@ -2,7 +2,7 @@ A scripted `SubagentProvider` for testing the [subagent seam](../../subagent/subagent/README.md) without a model or a real child agent — the subagent analog of [`dsh-llm-replay`](../llm-replay/README.md). -It lets a test drive `ctx.subagents` and the model-facing `dsh-tool-subagent` through the **real cordis Loader / export path**, exercising provider registration, start-time capability validation, the run lifecycle (`result` / `cancel` / `dispose`), and the structured-output branch — all deterministically and keylessly. +It lets a test drive `ctx.subagents` and the model-facing `dsh-tool-subagent` through the real Cordis loader/export path, exercising provider registration, async start, start-time capability validation, required-signal cancellation, `result`, `dispose`, and structured output deterministically and keylessly. ## Usage @@ -13,8 +13,8 @@ Load it as a plugin (functional shape: `name`/`inject`/`Config`/`apply`, no defa | `name` | `mock` | Registry name to register the provider under. | | `reply` | `mock subagent reply` | The scripted child's final answer text. | | `stopReason` | `completed` | The stop reason `result` settles with. | -| `capabilities` | all `true` | Which start-time capabilities (`outputSchema`/`depthLimit`/`toolFilter`) the provider advertises. | +| `capabilities` | all `true` | Which start-time capabilities (`outputSchema`, `depthLimit`, `toolFilter`, and `persona`) the provider advertises. | | `inheritsParentContext` | `false` | Conversation-history descriptor: `false` means fresh, while `true` exercises seeded/fork wording. It says nothing about tool, service, scope, or authority inheritance. | | `structured` | `{ reply }` | Structured value surfaced when a request carries an `outputSchema` and the capability is on. | -A `cancel()` issued before `result` settles flips the stop reason to `aborted`, so the cancellation path is observable. +Aborting the required request signal or disposing before `result` settles flips the stop reason to `aborted`, so both holder-facing cancellation paths are observable. diff --git a/packages/support/subagent-mock/src/index.ts b/packages/support/subagent-mock/src/index.ts index 554889db71..abc1a6c5e9 100644 --- a/packages/support/subagent-mock/src/index.ts +++ b/packages/support/subagent-mock/src/index.ts @@ -29,11 +29,10 @@ const STOP_REASONS = ['completed', 'aborted', 'error', 'max-tokens', 'refusal'] const DEFAULT_CAPS: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: true, persona: true } /** - * A scripted provider: every {@link start} returns a run whose `result` - * resolves on a microtask with the configured reply (and a structured value - * when the request asked for one and the capability is on). `dispose` is a - * no-op; a `cancel()` before the result settles flips the stop reason to - * `aborted`, so the cancellation path is observable in a test. + * A scripted provider: every {@link start} returns a ready run whose `result` + * resolves on the next task with the configured reply (and a structured value + * when the request asked for one and the capability is on). The required + * signal and `dispose()` both flip an unsettled result to `aborted`. */ class MockSubagentProvider implements SubagentProvider { readonly capabilities: SubagentCapabilities @@ -47,12 +46,22 @@ class MockSubagentProvider implements SubagentProvider { this.inheritsParentContext = config.inheritsParentContext ?? false } - start(request: SubagentStartRequest): SubagentRun { + async start(request: SubagentStartRequest): Promise { + if (request.signal.aborted) throw new Error('mock subagent start aborted before publication') const reply = this.config.reply ?? 'mock subagent reply' const output: ContentBlock[] = [{ type: 'text', text: reply }] const wantsStructured = request.outputSchema !== undefined && this.capabilities.outputSchema const baseStop: SubagentStopReason = this.config.stopReason ?? 'completed' - let cancelled = false + const flags = { cancelled: false } + const onAbort = (): void => { flags.cancelled = true } + request.signal.addEventListener('abort', onAbort, { once: true }) + // Make publication genuinely asynchronous so a same-turn abort is still + // a provider-owned startup failure rather than a returned live run. + await Promise.resolve() + if (flags.cancelled) { + request.signal.removeEventListener('abort', onAbort) + throw new Error('mock subagent start aborted before publication') + } // A deterministic child id derived from the parent — no clock/random (both // banned in deterministic paths here, and unnecessary for a scripted run). @@ -60,21 +69,22 @@ class MockSubagentProvider implements SubagentProvider { const resultFor = (): SubagentResult => ({ output, - structured: wantsStructured ? (this.config.structured ?? { reply }) : undefined, - stopReason: cancelled ? 'aborted' : baseStop, + ...wantsStructured ? { structured: this.config.structured ?? { reply } } : {}, + stopReason: flags.cancelled ? 'aborted' : baseStop, }) + const result = new Promise((resolve) => { + setTimeout(() => { resolve(resultFor()) }, 0) + }).finally(() => { + request.signal.removeEventListener('abort', onAbort) + }) return { id, - // A scripted run has no asynchronous publication phase; it is ready as - // soon as the provider returns the handle. - started: Promise.resolve(), - result: Promise.resolve().then(resultFor), - cancel() { - cancelled = true - }, - async dispose() { - // Scripted run holds no resources — nothing to await. + result, + dispose(): Promise { + flags.cancelled = true + request.signal.removeEventListener('abort', onAbort) + return Promise.resolve() }, } } diff --git a/packages/support/subagent-mock/tests/subagent-mock.spec.ts b/packages/support/subagent-mock/tests/subagent-mock.spec.ts index 8a96cc4141..3ddfaad4d4 100644 --- a/packages/support/subagent-mock/tests/subagent-mock.spec.ts +++ b/packages/support/subagent-mock/tests/subagent-mock.spec.ts @@ -11,7 +11,7 @@ function fakeParent(id = 'parent-1'): Agent { } function baseRequest(over: Partial = {}): SubagentStartRequest { - return { prompt: [{ type: 'text', text: 'task' }], parent: fakeParent(), ...over } + return { prompt: [{ type: 'text', text: 'task' }], parent: fakeParent(), signal: new AbortController().signal, ...over } } async function mount(config: Partial = {}): Promise { @@ -26,7 +26,7 @@ describe('dsh-subagent-mock', () => { const ctx = await mount({ reply: 'hello from mock' }) expect(ctx.subagents.list()).toEqual(['mock']) - const run = ctx.subagents.start('mock', baseRequest()) + const run = await ctx.subagents.start('mock', baseRequest()) await expect(run.result).resolves.toEqual({ output: [{ type: 'text', text: 'hello from mock' }], structured: undefined, @@ -41,13 +41,13 @@ describe('dsh-subagent-mock', () => { it('surfaces a structured result when the request carries an outputSchema', async () => { const ctx = await mount({ reply: 'r', structured: { answer: 42 } }) - const run = ctx.subagents.start('mock', baseRequest({ outputSchema: { type: 'object', properties: { answer: { type: 'number' } } } })) + const run = await ctx.subagents.start('mock', baseRequest({ outputSchema: { type: 'object', properties: { answer: { type: 'number' } } } })) await expect(run.result).resolves.toMatchObject({ structured: { answer: 42 } }) }) it('defaults structured output to { reply } when outputSchema is requested but no structured value is configured', async () => { const ctx = await mount({ reply: 'fallback reply' }) - const run = ctx.subagents.start('mock', baseRequest({ outputSchema: { type: 'object', properties: { answer: { type: 'number' } } } })) + const run = await ctx.subagents.start('mock', baseRequest({ outputSchema: { type: 'object', properties: { answer: { type: 'number' } } } })) await expect(run.result).resolves.toMatchObject({ structured: { reply: 'fallback reply' } }) }) @@ -56,21 +56,22 @@ describe('dsh-subagent-mock', () => { // The service rejects an outputSchema request against a no-cap provider, so // the structured path is only reachable when the cap is on; with it off and // no schema requested, the result has no structured field. - const run = ctx.subagents.start('mock', baseRequest()) + const run = await ctx.subagents.start('mock', baseRequest()) const result = await run.result expect(result).not.toHaveProperty('structured') }) it('honors a configured stop reason', async () => { const ctx = await mount({ stopReason: 'refusal' }) - const run = ctx.subagents.start('mock', baseRequest()) + const run = await ctx.subagents.start('mock', baseRequest()) await expect(run.result).resolves.toMatchObject({ stopReason: 'refusal' }) }) - it('flips the stop reason to aborted when cancelled before the result settles', async () => { + it('flips the stop reason to aborted when the signal fires before the result settles', async () => { const ctx = await mount() - const run = ctx.subagents.start('mock', baseRequest()) - run.cancel() + const controller = new AbortController() + const run = await ctx.subagents.start('mock', baseRequest({ signal: controller.signal })) + controller.abort() await expect(run.result).resolves.toMatchObject({ stopReason: 'aborted' }) }) diff --git a/packages/ui/acp/README.md b/packages/ui/acp/README.md index f5734e1f10..efd5e5247a 100644 --- a/packages/ui/acp/README.md +++ b/packages/ui/acp/README.md @@ -36,7 +36,7 @@ The `initialize` handshake reports a fixed server identity (`agentInfo: { name: ## Multi-session -The bridge multiplexes N sessions over one connection. Live sessions are held in a `Map` (forward) with a `WeakMap` reverse map so `agent/*` events — which carry only the `Agent` — demux in O(1). Every `session/event` and `agent/status` is routed strictly to its owning record, so concurrent sessions never cross-settle or interleave their `session/update` notifications. State is per session: one in-flight prompt each, `session/cancel` aborts and settles only its own agent/prompt, and disposal drains every live session in parallel to quiescence. Permission prompts follow the same ownership: the `approval/request` answerer resolves the owning session through the reverse map and prompts only there. +The bridge multiplexes N sessions over one connection. Live sessions are held in a `Map` (forward) with a `WeakMap` reverse map so agent-scoped approval events demultiplex in O(1). Every `session/event` is routed strictly to its owning record, so concurrent sessions never cross-settle or interleave their `session/update` notifications. State is per session: one in-flight prompt each, `session/cancel` aborts and settles only its own agent/prompt, and disposal drains every live session in parallel to quiescence. Permission prompts follow the same ownership: the `approval/request` answerer resolves the owning session through the reverse map and prompts only there. ## Session config options @@ -71,7 +71,7 @@ When the client does NOT advertise the capability, none of the `_meta`/terminal ## Settle-exactly-once -A `session/prompt` resolves (or rejects) exactly once, keyed off the canonical session log (the `session/event` stream). One listener captures the prompt's owning turn from the log's `turn/start` and settles on the matching `turn/end` — the durable boundary event (`closeTurn` appends it unconditionally; there is no `agent/*` turn mirror). A prompt settles only on ITS OWN turn (`inflight.turn === turn/end.turn`), so a stale `turn/end` for a previously-cancelled turn whose end arrives late can never settle the wrong prompt. A turn that ends `error` REJECTS the RPC with an internal error carrying the failure message (ACP has no error stop reason); every other reason resolves via the codec. Session contains post-commit observer failures per listener, so another subscriber cannot starve the bridge. As defensive cross-seam reconciliation, an `agent/status` handler checks the log whenever the agent reaches `idle`/`disposed` with a prompt still pending, settling from the owning turn's `turn/end` or as `cancelled` if teardown left no clean boundary. An empty/whitespace prompt is rejected up front — it would queue no work, so no turn would start and the RPC would hang. +A `session/prompt` resolves or rejects exactly once from the canonical `session/event` stream. The listener captures the prompt's owning turn from `turn/start` and settles in a `finally` block when the matching `turn/end` is appended, so a presentation/streaming failure cannot strand the RPC after the durable terminal event exists. Correlation by turn id prevents a late end from a cancelled prompt from settling its successor. A turn ending in `error` rejects the RPC with an internal error carrying the failure message because ACP has no error stop reason; every other reason resolves through the codec. An empty or whitespace-only prompt is rejected before enqueue because it would start no turn and otherwise leave the RPC pending. ## Permission prompts diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index 5cd24c2c73..5a5ce9e51f 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -71,7 +71,7 @@ import { } from '@agentclientprotocol/sdk' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { assertNever, CallId } from '@deepseek-ai/dsh-llm' -import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' import { AgentId } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-bash' @@ -297,7 +297,8 @@ interface SessionRecord { /** * The in-flight `session/prompt`, or `undefined` when none is pending. A * prompt resolves with a {@link StopReason} or rejects with an Error (a - * turn that ended in failure). Settled exactly once via {@link settlePrompt}. + * turn that ended in failure). Settled exactly once by its matching + * `turn/end`, direct cancellation, or teardown. * * `turn` is the loop turn number this prompt owns, captured from the log's * `turn/start` after `send()`. Until then it is `undefined` (the turn has not @@ -307,17 +308,11 @@ interface SessionRecord { * wrong prompt. A direct cancel/dispose settle clears the whole in-flight slot, * so a later stale `turn/end` finds no pending prompt. * - * `logWatermark` is the session log length at the moment the prompt was - * installed (before `send()`). Defensive settle-from-log reconciliation uses - * it to infer the owning `turn/start` if status reaches idle/disposed before - * live correlation settled the prompt: the prompt owns the FIRST message - * `turn/start` appended at or after this watermark. */ inflight: { resolve: (reason: StopReason) => void reject: (error: Error) => void turn: number | undefined - logWatermark: number } | undefined /** * Config switches accepted while the session was IDLE, not yet anchored in @@ -336,12 +331,9 @@ interface SessionRecord { /** * Drive the in-flight prompt's settle from the harness event stream. The bridge - * settles off the durable log: the `turn/end` session event on the - * `session/event` feed for the prompt's own turn, with idle/disposed status as - * defensive log reconciliation (docs/defensive-patterns.md "honor cross-seam - * contracts on BOTH sides"). Session contains post-commit observer failures, - * so peers cannot starve this feed. The first settlement path clears the slot, - * making every later signal a no-op. + * settles off the durable `turn/end` event for the prompt's own turn. Session + * contains post-commit observers independently, and this listener performs + * correlation in a `finally` so presentation failure cannot starve settlement. */ export function apply(ctx: Context, config: AcpConfig): void { // Capture the injected services NOW, during apply(), while we are inside this @@ -499,77 +491,24 @@ export function apply(ctx: Context, config: AcpConfig): void { ctx.on('session/event', (session, event: SessionEvent) => { const rec = sessions.get(session.header.id) if (rec === undefined) return - streamSessionEventUpdate(rec.sessionId, event, notify, rec.presenter, { - enabled: rec.terminalEnabled, - cwd: session.header.cwd, - }, { includeUserMessages: false }) - const inflight = rec.inflight - if (inflight === undefined) return - if (event.type === 'turn/start') { - // Tag the in-flight prompt with its owning turn — but ONLY a - // `message`-triggered turn (the kind a `send()` prompt produces). A turn - // a plugin opens between prompt-install and the prompt's own turn (an idle - // `agent.inject()` writes a one-shot `injection`-triggered turn) must NOT - // be mistaken for the prompt's turn, or its turn/end would settle the RPC - // early. The first message turn at/after install owns the prompt - // (`turn === undefined` guard); the loop batches queued messages into one - // turn, so there is exactly one. - if (inflight.turn === undefined && event.data.trigger.kind === 'message') { - inflight.turn = event.data.turn + try { + streamSessionEventUpdate(rec.sessionId, event, notify, rec.presenter, { + enabled: rec.terminalEnabled, + cwd: session.header.cwd, + }, { includeUserMessages: false }) + } finally { + const inflight = rec.inflight + if (inflight !== undefined && event.type === 'turn/start') { + // The first message-triggered turn after prompt installation owns the + // prompt; injection-triggered turns must not settle it early. + if (inflight.turn === undefined && event.data.trigger.kind === 'message') { + inflight.turn = event.data.turn + } + } else if (inflight !== undefined && event.type === 'turn/end' && inflight.turn === event.data.turn) { + rec.inflight = undefined + settleFromTurnEnd(inflight, event.data.reason) } - return } - // Settle only on the OWNING turn's end. - if (event.type !== 'turn/end' || inflight.turn !== event.data.turn) return - rec.inflight = undefined - settleFromTurnEnd(inflight, event.data.reason) - }) - - // Defensive settle fallback: when the agent reaches idle/disposed while a - // prompt is still pending, reconcile against the canonical log. Determine - // the owning turn from live capture or the first message turn after the - // install-time watermark, then settle from its turn/end; if no clean owning - // turn exists, settle cancelled. The slot is cleared first, so this cannot - // double-settle against the live session/event path. - const settleFromLog = (rec: SessionRecord): void => { - const inflight = rec.inflight - if (inflight === undefined) return - const events = rec.agent.session.events - // The owning turn number: the captured one, or inferred from the log as the - // first MESSAGE-triggered turn opened at/after the watermark. The filter matches the live - // capture: a one-shot `injection` turn a plugin may open between - // prompt-install and the prompt's turn is NOT the prompt's turn. Undefined - // only if no message turn ever started for this prompt. - const owningTurn = inflight.turn ?? events.slice(inflight.logWatermark).find( - (e): e is Extract => - e.type === 'turn/start' && e.data.trigger.kind === 'message', - )?.data.turn - // The owning turn's end in the log. If `owningTurn` is undefined (no turn - // ever started for this prompt — a torn-down-before-turn case that quiesce's - // direct settle normally pre-empts), no `turn/end` matches (turn numbers are - // >= 1) and `findLast` returns undefined, falling through to cancelled. - const end = events.findLast( - (e): e is Extract => - e.type === 'turn/end' && e.data.turn === owningTurn, - ) - rec.inflight = undefined - if (end === undefined) { - // No owning turn / no clean turn/end (torn down mid-turn) → cancelled. - inflight.resolve('cancelled') - return - } - settleFromTurnEnd(inflight, end.data.reason) - } - - // On idle/disposed, reconcile any still-pending prompt from the log. A - // mid-step disposal that never appended a clean turn/end resolves `cancelled`. - // Demux via the agent→sessionId reverse map. - ctx.on('agent/status', (agent, status: AgentStatus) => { - const sessionId = bySession.get(agent) - if (sessionId === undefined) return - const rec = sessions.get(sessionId) - if (rec === undefined) return - if (status === 'idle' || status === 'disposed') settleFromLog(rec) }) // --- Approval answerer ----------------------------------------------------- @@ -894,12 +833,10 @@ export function apply(ctx: Context, config: AcpConfig): void { // Install the in-flight slot BEFORE send() (send does not synchronously // flip status to running; the session/event listener records the turn // number and settle/rejects it). Capture the log length now as the - // watermark: defensive status reconciliation can infer the owning - // turn/start if status arrives reentrantly after commit but before this - // bridge's live callback. A turn that ends in error rejects this promise (the codec - // never produces an error stop reason). + // A turn that ends in error rejects this promise (the codec never + // produces an error stop reason). const stopReason = await new Promise((resolve, reject) => { - rec.inflight = { resolve, reject, turn: undefined, logWatermark: rec.agent.session.events.length } + rec.inflight = { resolve, reject, turn: undefined } rec.agent.send([{ type: 'text', text }]) }) return { stopReason } @@ -918,8 +855,7 @@ export function apply(ctx: Context, config: AcpConfig): void { // as cancelled directly here: do NOT rely on the resulting turn/end to // settle it, because cancel() may drop the turn before any turn/end is // emitted, and removing this direct settle would move the RPC's - // resolution onto the settleFromLog/agent-status path, changing its - // timing. + // resolution onto a later observer path, changing its timing. rec.agent.cancel('session/cancel') settlePrompt(rec, 'cancelled') return Promise.resolve() diff --git a/packages/ui/acp/tests/edges.spec.ts b/packages/ui/acp/tests/edges.spec.ts index 143bd7193c..e86fb9fc94 100644 --- a/packages/ui/acp/tests/edges.spec.ts +++ b/packages/ui/acp/tests/edges.spec.ts @@ -20,7 +20,7 @@ describe('acp bridge — demux & config edges', () => { it('ignores events from an agent the bridge does not own (strict id demux)', async () => { // A second agent created directly on the registry (NOT via the bridge) runs - // a turn. Its session/event + agent/status must NOT produce ACP updates and + // a turn. Its session events must NOT produce ACP updates and // must not settle anything — the bridge demuxes strictly by its own id. harness = await makeBridgeHarness({ storageDir, script: [textResponse('foreign')] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) diff --git a/packages/ui/acp/tests/turns.spec.ts b/packages/ui/acp/tests/turns.spec.ts index 3fc6eef8a6..f559ca36d9 100644 --- a/packages/ui/acp/tests/turns.spec.ts +++ b/packages/ui/acp/tests/turns.spec.ts @@ -3,7 +3,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { defineTool } from '@deepseek-ai/dsh-tools' -import { AgentId, agentEvents } from '@deepseek-ai/dsh-agent' +import { AgentId } from '@deepseek-ai/dsh-agent' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' import { errorResponse, @@ -271,37 +271,6 @@ describe('acp bridge — turn outcomes', () => { expect(result.stopReason).toBe('end_turn') }) - it('status reconciliation infers the owning message turn when teardown wins after turn/start', async () => { - harness = await makeBridgeHarness({ storageDir, script: [textResponse('background completion')] }) - const sessionId = await newSession(harness) - const agent = harness.ctx.agents.get(AgentId(sessionId))! - harness.ctx.on('session/event', (session, event) => { - if (session !== agent.session || event.type !== 'turn/start') return - // Inject the signal ordering the defensive fallback handles: disposal - // status after turn/start commits but before ACP's later live observer. - // This is event-level simulation; it does not mutate the test agent. - agentEvents(harness!.ctx, agent).emit('agent/status', 'disposed') - }, { prepend: true }) - - const result = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }) - expect(result.stopReason).toBe('cancelled') - }) - - it('status reconciliation can settle from a committed turn/end before live delivery', async () => { - harness = await makeBridgeHarness({ storageDir, script: [textResponse('answer')] }) - const sessionId = await newSession(harness) - const agent = harness.ctx.agents.get(AgentId(sessionId))! - harness.ctx.on('session/event', (session, event) => { - if (session !== agent.session || event.type !== 'turn/end') return - // Inject a reentrant status signal after the boundary commits to exercise - // the defensive log path before ACP's captured callback runs. - agentEvents(harness!.ctx, agent).emit('agent/status', 'idle') - }, { prepend: true }) - - const result = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }) - expect(result.stopReason).toBe('end_turn') - }) - it('a between-turn injection does not settle the prompt early (message-trigger correlation)', async () => { // A plugin injects context (a one-shot injection-triggered turn) right after // the prompt is queued but before the prompt's own message turn runs. The diff --git a/packages/workflow/workflow-workerthread/README.md b/packages/workflow/workflow-workerthread/README.md index 409ab57132..b9786ae1e2 100644 --- a/packages/workflow/workflow-workerthread/README.md +++ b/packages/workflow/workflow-workerthread/README.md @@ -1,59 +1,80 @@ # @deepseek-ai/dsh-workflow-workerthread -The [`WorkflowService`](../workflow/README.md) implementation, on **`node:worker_threads`**: each run gets its OWN worker thread (one run = one worker, no pooling — a run is heavyweight, so the ~tens-of-ms thread spin-up is noise), the script executes in a vm context INSIDE that worker with the workflow hooks injected, and every `agent()` call bridges back over the message port to [`ctx.subagents`](../../subagent/README.md) on the host. Child agents are I/O-bound LLM loops and stay on the host event loop; the thread isolates the SCRIPT, the only part that can spin synchronously. +This package implements `WorkflowService` with one Node worker thread per run. The worker executes the orchestration script; child agents remain on the host and are reached through `ctx.subagents` over a typed host/worker protocol. -## Trust premise: what the thread buys (and what it does not) +The split has one primary purpose: a synchronous script loop cannot block the harness event loop, and a script that ignores cancellation can be terminated with its worker. It is not a security sandbox. -Workflow scripts are **model-written** — the same trust level as the model's existing bash access — so this engine defends against **buggy** scripts, never hostile ones. A worker thread is NOT a security boundary: the vm context inside it is escapable by construction (`node:vm` shares object machinery with its surrounding realm, so a script can reach the `Function` constructor via `globalThis.constructor.constructor` and from it `process` and every Node builtin), and an escapee holds the same process privileges as the host — Node's permission model is process-wide. The absent globals are API surface that keeps honest scripts portable, not walls. What the thread concretely buys: +## Trust and isolation boundary -- **The host never blocks**: `start()` returns without running any script code on the host; a synchronous spin anywhere in the script occupies the worker's loop, not the harness's. -- **Termination is real**: a script that outlives its post-cancel grace is `worker.terminate()`d — nothing of it survives `dispose()`, where an in-process engine could only abandon the spin on its own loop. -- **No ambient credentials**: the worker spawns with an EMPTY environment (`env: {}` plus hermetic `execArgv`, the same stance as `dsh-code-runtime-worker`; the unbuilt dev shape forwards exactly one loader variable, `TSX_TSCONFIG_PATH` — path plumbing, not a secret), so an escapee reading `process.env` finds no harness secrets — ambient-channel hardening only; the process-wide privileges above (fs and the rest) remain, so a genuine sandbox is still the engine swap. -- **Serialization by construction**: everything crossing the thread is structured-clone data, and plain JSON before that — the `materializeFromRealm` walk rejects loud what JSON cannot carry, which is also what makes every postMessage hop total. +Workflow scripts are model-written and have the same trust premise as the model's existing bash access. `node:vm` inside a worker is an API-shaping mechanism, not a security boundary: an escaped script can recover Node capabilities with the host process's privileges. -What the seam guarantees regardless, because benign scripts hit these constantly: `result` never rejects, a dropped hook promise never becomes an unhandled rejection, values JSON cannot carry are rejected **loud** instead of silently mangled, and hook misuse is fatal instead of dissolving into a per-item `null`. Genuine sandboxing (containing what an escaped script may touch) remains an isolated-vm/separate-process engine swap behind the seam, still deferred. +The worker still provides useful containment: -## The script contract it executes +- Script CPU work and synchronous spins stay off the host event loop. +- `worker.terminate()` gives disposal a real final stop. +- The worker starts with an empty environment, except unbuilt loader plumbing, so ambient credentials do not cross through `process.env`. +- Host/worker messages use structured-clone data, with plain-JSON validation at the script boundary. -- **Meta as DATA** (`validateMeta`, host-side): the workflow's identity arrives on the start request as plain JSON (the tool carries it as its schema-validated `meta` parameter — never as script text) and is shape-validated loud, every violation named (`name`/`description` required; unknown fields rejected). The engine deliberately evaluates NO script text to obtain meta: an evaluated meta literal could smuggle getters that run on the host outside any vm timeout — the exact spin the worker thread exists to isolate. A body that still opens with a Claude Code-style `export const meta` statement is rejected with a pointed `SCRIPT_PARSE` message. -- **Hooks**: `agent(prompt, {label, phase, schema, model})` (schema = the [structured-output subset](../../core/tools/README.md), forwarded as `outputSchema`; result = validated object, or final text without a schema; a failed child resolves `null`), `parallel(thunks)`, `pipeline(items, ...stages)` with NO cross-stage barrier and `(prev, item, index)` stage callbacks, `phase(title)`, `log(message)`, and the `args` global. Anything else — `effort`/`isolation`/`agentType`, unknown options, malformed arguments, schemas outside the subset — throws a FATAL `WorkflowError` that `parallel`/`pipeline` re-throw rather than nulling (see the seam README's failure discipline). -- **No ambient APIs**: no timers, filesystem, or Node APIs are injected into the context (absence is API surface, not containment — see the trust premise). +A genuinely untrusted-script sandbox would require a different engine behind the same workflow seam. -## How a run executes +## Script contract -`start()` shape-validates the meta DATA host-side and parse-checks the body with the identical wrapper the worker compiles (`new vm.Script`, discarded), preserving the seam's synchronous `META_INVALID`/`SCRIPT_PARSE` throws; one redundant parse per run is the deliberate price. It then spawns the worker (unbuilt: a JavaScript data-URL bootstrap registers tsx's ESM and CommonJS transforms inside the worker before importing `src/worker.ts`, giving the whole mixed-module source graph full TypeScript and tsconfig-path transformation on every supported Node line; built: the sibling `lib/worker.js` bundle) with the meta, body, `args`, and worker-side limits as `workerData`. +The workflow's `meta` is host-provided data, not evaluated script text. The engine validates its required `name` and `description`, rejects unknown fields, and parse-checks the body before returning a run. -Inside the worker, `runWorkerSession` builds the execution core (hooks, combinators, concurrency semaphore, caps, fatal-error discipline) over a **child port**. `agent()` sends `child-start`, and the host starts the child through the holder-bound `SubagentService` handle captured synchronously by `start()`, with parent attribution, the shared per-run abort signal, and `outputSchema`/`model` pass-through. This capture is part of the seam's holder-owned lifetime: unloading the engine removes `ctx.workflows` for new calls but does not invalidate an already returned run whose worker starts another child afterward. +Inside the worker, the script receives `args` and these hooks: -The host observes `run.result` immediately but buffers its snapshotted wire projection until `run.started` fulfills. Provider `start()` is arbitrary code and can synchronously reenter workflow cancellation before its returned run reaches the host registry, so the host registers the run, attaches both promise observers, and re-checks admission after `start()` returns and again at readiness. A closed boundary never admits or announces the run to the worker: while the exact run remains registered, the host invokes explicit cancel once and disposes it; `child-start-error` is sent only while worker-message admission remains open. If the run was already retired, the identity guard sends no cleanup through the deleted call ID. An ordinary readiness rejection sends `child-start-error` while possible and disposes the provider attempt without adding an explicit cancellation. Otherwise the host replies `child-started` with the child id before forwarding settlement, so `workflow/agent-start` always names a ready child and precedes its end. The worker classifies a start error as fatal `AGENT_START` unless cancellation already owns the run. If readiness fulfills, an infrastructure result rejection crosses as `child-failed`/`AGENT_RESULT` regardless of whether that rejection settled before or after readiness. Child disposal acknowledgements complete the RPC. +- `agent(prompt, { label, phase, schema, model })` starts one host-side subagent. With a schema it returns the structured value; otherwise it returns final text. An ordinary failed child yields `null`. +- `parallel(thunks)` runs thunks under the configured concurrency limit. +- `pipeline(items, ...stages)` passes `(previous, item, index)` without a cross-stage barrier. +- `phase(title)` and `log(message)` emit observer narration. -Observer narration (`phase`/`log`/`agent-start`/`agent-end`) crosses as messages and re-emits as the seam's `workflow/*` events. A **ready→go handshake** gates the body: a cancellation racing worker boot arrives before `go`, so a run cancelled before start never executes the body at all. +Unknown options, malformed arguments, unsupported schemas, tripped caps, provider-start failures, and infrastructure result failures are fatal workflow errors. No timers, filesystem API, or Node globals are intentionally injected, though the trust caveat above still applies. -## The value boundary +## Run sequence -Values LEAVING the script (hook options/schemas, the script's return) are materialized by `materializeFromRealm`: a plain recursive walk that rejects loud everything JSON cannot carry (exotic prototypes, functions, symbols, cycles, sparse arrays, non-finite numbers, nested `undefined`), copying into plain containers via `defineProperty` so a `"__proto__"` key becomes a data property, never a prototype mutation. Getters are read ordinarily — the RESULT is what crosses; a read that throws fails loud; both run in the WORKER, never on the host. Values ENTERING the realm (`args`, `agent()` results, hook promises and their failures, combinator arrays) are handed over directly as worker-realm values — the script is trusted, so outer prototypes are not a leak; `args` is cloned once at start so a script scribbling on it cannot mutate the caller's object. One script-visible consequence: an error thrown by a hook is built OUTSIDE the script's vm context, so `e instanceof Error` inside the script is `false` — branch on `e.name`/`e.code` instead (the combinators recognize fatality by `instanceof` against their own realm's class, which a script-built object can never pass, so fatal-vs-null cannot be forged or dissolved). +`start()` validates meta and parses the body, creates the worker, and returns a holder-owned `WorkflowRun`. A ready/go handshake prevents a start-signal cancellation racing worker boot from executing the script's initial synchronous slice. -## Cancellation, death, disposal +For each `agent()` call: -Cancellation is bounded and host-driven. Per-run limits are a concurrency semaphore (`maxConcurrentAgents`), a total-`agent()` cap (`maxTotalAgents`), and a per-call item cap (`maxItemsPerCall`), all config. `cancel()` first records its reason, then posts to the worker (its hooks start throwing `CANCELLED`; the script dies at its next await) and cancels every host-side child NOW on **both seam channels**: the shared request signal aborts and each registered child's explicit `cancel()` runs host-side. The seam leaves a provider free to honor either channel, and a worker wedged in a synchronous spin could not relay its own per-child cancel RPCs. A host-side per-call gate turns the worker's later explicit-cancel relay into a no-op, because the seam does not require `SubagentRun.cancel()` to be idempotent. Each explicit child `cancel()` callback is exception-contained independently, post-cancel `phase`/`log` narration is suppressed host-side, and cancelled children still deliver paired `agent-end` events. The caller's optional start-signal callback is retained by exact identity only while the run is live and removed at the first settlement or teardown. +1. The worker sends `child-start` with a plain-data prompt and options. +2. The host calls the configured provider through async `SubagentService.start`, passing the workflow's parent and one canonical per-run abort signal. +3. If start rejects, the host sends `child-start-error`; provider startup has already reached quiescence and no child lifecycle event is emitted. +4. If start fulfills while the workflow still admits work, the host records the run, observes `result`, then sends `child-started`. Even an already-settled result is forwarded afterward, preserving start-before-result order. +5. The worker emits paired `workflow/agent-start` and `workflow/agent-end` narration and requests child disposal after collection. -Terminal arbitration is first-wins at explicit host-side claim points. A cancellation before ready→go reports `cancelled` without executing the body. For a later race, the worker queues Result before its settlement-reap `ChildCancel` messages; external `cancel()` records its reason before its fanout, while Result receipt snapshots any earlier cancellation and records the terminal outcome before settlement-cleanup fanout. Same-port FIFO and those claim points mean earlier caller/signal/dispose cancellation overrides a non-cancelled report, while an arrived report cannot be rewritten by a cleanup callback. Once Result has won, a losing reentrant `cancel()` has no state, message, child-fanout, or grace-timer effect. If no earlier terminal source settles the run, the grace callback claims `cancelled`, synthesizes missing lifecycle ends, settles the result, and terminates the worker after `disposeGraceMs`. +Provider starts are tracked separately from published children. If cancellation, worker death, or normal workflow settlement closes admission while a start is pending, the shared signal aborts it. A provider that nevertheless fulfills after closure is disposed by the host and never announced to the worker. -Worker death separates outcome ownership, message admission, and resource cleanup. An unexpected OOM, `error`, message failure, or premature exit claims `stopReason: 'error'` with diagnostics—or preserves an external cancellation already in flight—before reaping children or synthesizing observer events. Reentrant provider cancellation therefore cannot turn a death-first error into cancellation. The first death signal also closes worker-message admission because Node may deliver a queued `message` between `error` and `exit`; late protocol data cannot start a child, emit narration, or compete with the outcome. If Result or grace already claimed the outcome, death preserves it while still reaping promptly. The eventual `exit` then performs a final disposal-only sweep, joining any in-flight disposal without repeating explicit child cancellation. This separation lets grace settlement become observable before `worker.terminate()` reports exit without leaking the host-side registry. +## Value boundary -Disposal is the holder's bounded resource guarantee: cancel, begin host-driven disposal of every registered child immediately, wait for result plus child-registry quiescence up to the same grace, and unconditionally terminate the worker. `handle.dispose()` claims its public promise before that traversal invokes cancellation or disposal callbacks. Independently, every `disposeChild` path claims the call ID's promise before invoking the wrapped child disposer. Public-first reentry therefore returns the existing holder promise; worker-first reentry may begin holder disposal, whose child traversal joins the already-claimed call ID promise. Neither order can start a second provider disposal. A wedged worker can relay no dispose RPC, so host-driven teardown overlaps the grace; any later worker RPC joins the same per-child disposal. Before ordinary settlement becomes observable, the host also cancels every stray on both channels, including a fire-and-forget run still waiting on readiness. That work is settlement-only cleanup after the terminal claim, so provider reentry cannot rewrite the chosen result; `dispose()` then waits for its completion within the bound. +Values leaving the script pass through `materializeFromRealm`, which accepts plain, lossless JSON data and rejects exotic prototypes, functions, symbols, cycles, sparse arrays, non-finite numbers, and nested `undefined`. The walk runs in the worker, and defines object keys as data properties so `__proto__` cannot mutate a prototype. -Lifecycle pairing is host-guaranteed independently of outcome arbitration. Forwarded starts live in a ledger and worker-reported ends pair them on graceful paths. When death or grace is the terminal source, the host synthesizes missing ends with outcome `cancelled` before `workflow/end`. If Result settled first, later death cleanup may synthesize a survivor's end afterward; a start already crossing force-settlement may likewise surface after `workflow/end`. The same ledger still pairs every forwarded start exactly once. +Child results are projected and snapshotted before crossing from the host to the worker. This is a real process-like serialization boundary; it is deliberately different from trusted same-process workflow and subagent event payloads, which are borrowed immutable values. -**Engine-specific limitations**: worker startup is paid per run; on a termination path `agentsStarted` reports the HOST-observed count (accepted `child-start`s — calls still queued worker-side for a concurrency slot are unknowable then); and a returned promise or thenable resolves per JavaScript semantics BEFORE materialization — that is what makes an un-awaited `return agent('x')` work — with the value-boundary guard applying to the resolution. +## Cancellation and disposal + +`WorkflowRun.cancel()` records the first reason, tells the worker to cancel, aborts the one signal shared by every pending and published child, and arms the `disposeGraceMs` timer. Worker hooks then throw `CANCELLED` at their next await. If the run remains unsettled at the deadline, the host resolves it as cancelled, pairs stranded child lifecycle events, and terminates the worker. + +The subagent seam has one cancellation channel: the request signal. There is no separate child-cancel RPC. Published child teardown uses `run.dispose()`; pending provider starts remain provider-owned until their promise rejects or fulfills. + +Normal settlement also aborts pending starts and begins disposing any published fire-and-forget children before the result becomes externally settled. The host's quiescence condition includes both pending starts and published child disposals, so cleanup does not forget an async startup transaction. + +`dispose()` is idempotent. It cancels the run, starts host-driven disposal immediately, waits for result plus child quiescence up to the same grace, terminates the worker unconditionally, and performs a final survivor sweep. Per-child disposal is memoized so worker RPC, host cancellation, death cleanup, and public disposal all join one operation. + +## Outcome and event guarantees + +Terminal outcome is first-wins at host claim points. An accepted external cancellation overrides a later non-cancelled worker result; a result or worker death that claims first cannot be rewritten by reentrant cleanup callbacks. + +Worker error, message failure, or premature exit closes message admission before cleanup, then resolves `error` unless cancellation already owns the run. Late queued messages cannot create children or narrate after that logical boundary. + +The host keeps a ledger of forwarded child starts. A graceful worker supplies their ends; death or force termination synthesizes any missing end as cancelled. Every forwarded `workflow/agent-start` is therefore paired exactly once, although cleanup after an already-arrived workflow result may complete afterward. ## Config | Key | Default | Meaning | |---|---|---| -| `provider` | `spawn` | The `ctx.subagents` provider children run on (host-side). | -| `maxConcurrentAgents` | `0` (auto) | Concurrent `agent()` ceiling; `0` resolves to `min(16, max(1, cores - 2))`. | -| `maxTotalAgents` | `1000` | Total `agent()` calls one run may start (runaway-loop backstop). | -| `maxItemsPerCall` | `4096` | Items accepted by one `parallel()`/`pipeline()` call. | -| `syncTimeoutMs` | `5000` | vm timeout for the script's initial synchronous slice (in the worker). | -| `disposeGraceMs` | `5000` | How long a cancelled run may stay unsettled before force-settle + terminate; also bounds `dispose()`. | +| `provider` | `spawn` | Host-side subagent provider used by `agent()`. | +| `maxConcurrentAgents` | `0` | Concurrent `agent()` ceiling; `0` resolves from available CPU parallelism. | +| `maxTotalAgents` | `1000` | Total `agent()` calls in one run. | +| `maxItemsPerCall` | `4096` | Items accepted by one `parallel()` or `pipeline()` call. | +| `syncTimeoutMs` | `5000` | VM timeout for the script's initial synchronous slice. | +| `disposeGraceMs` | `5000` | Bound before force-settlement/termination and for public disposal. | diff --git a/packages/workflow/workflow-workerthread/src/host.ts b/packages/workflow/workflow-workerthread/src/host.ts index ef5fdcbfac..44ea569382 100644 --- a/packages/workflow/workflow-workerthread/src/host.ts +++ b/packages/workflow/workflow-workerthread/src/host.ts @@ -25,33 +25,18 @@ * eventual exit performs a final disposal-only sweep without repeating child * cancellation. * - * Children live in a host-side registry (callId → run) as soon as the provider - * accepts them, so cancellation reaches even a pre-publication attempt. Both - * explicit run cancellation and the shared request signal are driven when the - * workflow is cancelled OR normally settles, so a fire-and-forget child cannot - * survive merely by honoring only one channel. A per-call gate invokes each - * explicit provider `cancel()` at most once even though host fanout and the - * worker's later relay can both request it. The host observes `result` - * immediately but acknowledges the child to the worker only after `started` - * fulfills; readiness failure is a start error and the host disposes the - * attempt because the worker never received a handle. The - * worker drives disposal by RPC on the graceful path, `dispose()` host-drives - * every registered child's disposal immediately (a wedged worker can relay no - * dispose RPC, and child teardown must overlap the grace, not start after it), - * and the registry lets the host abort and dispose every survivor when the - * worker dies or is terminated mid-flight. The three - * paths share ONE disposal per child (memoized by callId; the seam's - * dispose() is idempotent anyway, the memo keeps the bookkeeping and the - * containment warn single). Lifecycle pairing is host-guaranteed the same - * way: every forwarded `agent-start` lives in a ledger, and a start the dead - * or terminated worker never paired is closed exactly once by a synthesized - * `agent-end` (outcome `'cancelled'`). When death or grace is the terminal - * source, already-known pairs close before the run settles; cleanup after an - * earlier Result can close a survivor afterward. On a termination path - * `agentsStarted` reports the - * HOST-observed count (accepted `child-start` messages) — `agent()` calls - * still queued worker-side for a concurrency slot are unknowable then; the - * worker's own count rides the result message on every graceful path. + * Provider starts and published children are tracked separately. Every start + * receives one shared per-run abort signal; the provider owns partial setup + * until its promise fulfills. If admission closes while a start is pending, + * the signal aborts it; a late fulfillment is disposed without publication to + * the worker. Ready runs enter a callId registry whose memoized disposal is + * shared by graceful worker RPC, public disposal, normal-settlement reap, and + * worker-death cleanup. Quiescence requires both pending starts and published + * children to drain. Lifecycle pairing is host-guaranteed independently: + * every forwarded `agent-start` enters a ledger, and a dead or terminated + * worker's missing `agent-end` is synthesized exactly once as cancelled. On a + * termination path `agentsStarted` reports the host-observed child-start count; + * calls still queued worker-side for a concurrency slot are unknowable. * * @module @deepseek-ai/dsh-workflow-workerthread/host */ @@ -71,6 +56,12 @@ import { HostToWorkerType, WorkerToHostType } from './protocol.ts' import type { HostToWorkerPayloads, WorkerToHostMessage } from './protocol.ts' import type { ChildResult, ChildStartRequest, WorkerInit } from './types.ts' +/** One published child and its shared quiescent-disposal transaction. */ +interface ChildRecord { + readonly run: SubagentRun + disposal?: Promise +} + /** * Resolve the worker entry and spawn options for the current runtime shape. * Unbuilt (tsx demos, vitest — `import.meta.url` points into `src/`), the @@ -135,9 +126,9 @@ function resolveWorkerSpawn(init: WorkerInit): { entry: URL; options: WorkerOpti /** * One live worker-engine run — the seam's {@link WorkflowRun}, returned by * `start()` directly. Owns the Worker, the child registry, and the result - * settlement; `result` never rejects. `meta` is this handle's OWN clone - * (event payloads carry separate clones), so a consumer mutating it corrupts - * nothing. The holder-bound SubagentService handle is captured before the + * settlement; `result` never rejects. `meta` is trusted same-process data + * borrowed as immutable by the handle and lifecycle events. The holder-bound + * SubagentService handle is captured before the * engine returns this run, so unloading the engine removes only the ability to * start another workflow; this run can still start and clean up its children. */ @@ -157,12 +148,10 @@ export class WorkerRun implements WorkflowRun { private workerGone = false /** Accepted `child-start` messages — the terminate-path `agentsStarted` (see module doc). */ private hostStarted = 0 - /** Live children by callId; an entry leaves ONLY after its dispose settles (quiescence = empty). */ - private readonly children = new Map() - /** In-flight child disposals by callId — the memo that gives every path (worker RPC, dispose(), reap) ONE shared disposal per child. */ - private readonly childDisposals = new Map>() - /** callIds whose explicit provider cancel callback has already been invoked. */ - private readonly childCancellations = new Set() + /** Published children by callId; an entry leaves only after disposal settles. */ + private readonly children = new Map() + /** Provider starts that have not yet fulfilled or rejected. */ + private readonly pendingStarts = new Set>() /** Started-but-not-ended agents by seq — the pairing ledger the HOST guarantees (see {@link endAgent}). */ private readonly liveAgents = new Map() private readonly quiescenceWaiters: (() => void)[] = [] @@ -214,11 +203,8 @@ export class WorkerRun implements WorkflowRun { /** * Cancel the run: the worker is told (its hooks start throwing and the - * script dies at its next await), every host-side child is cancelled NOW on - * BOTH seam channels — the shared request signal aborts and each registered - * child's explicit `cancel()` is called (the seam leaves a provider free to - * honor either, and a worker wedged in a synchronous spin could not relay - * its own per-child cancel RPCs until far too late) — and the grace timer + * script dies at its next await), the required signal shared by every child + * start is aborted, and the grace timer * arms: a run still unsettled `disposeGraceMs` later force-settles * `cancelled` and its worker is TERMINATED. Idempotent; the first reason * wins. @@ -234,11 +220,7 @@ export class WorkerRun implements WorkflowRun { if (this.settled || this.terminalClaimed || this.cancelReason !== undefined) return this.cancelReason = reason ?? 'workflow cancelled' this.post(HostToWorkerType.Cancel, { reason: this.cancelReason }) - // The explicit channel is driven host-side, not left to the worker: a - // provider honoring only run.cancel() must not wait on a wedged worker's - // ChildCancel relay (the per-call cancellation gate makes those later - // RPCs no-ops without imposing idempotence on the provider). - this.cancelChildren(this.cancelReason) + this.abortChildren(this.cancelReason) this.graceTimer = setTimeout(() => { // Cancellation already owns the race through cancelReason; close the // terminal boundary explicitly before observer teardown callbacks. @@ -351,12 +333,6 @@ export class WorkerRun implements WorkflowRun { case WorkerToHostType.ChildStart: this.onChildStart(message.callId, message.request) break - case WorkerToHostType.ChildCancel: - { - const run = this.children.get(message.callId) - if (run !== undefined) this.cancelChild(message.callId, run, message.reason) - } - break case WorkerToHostType.ChildDispose: this.onChildDispose(message.callId) break @@ -369,7 +345,7 @@ export class WorkerRun implements WorkflowRun { } } - /** Why a child may no longer cross the provider readiness boundary. */ + /** Why a ready provider result may no longer be admitted to the worker. */ private childAdmissionFailure(): { reason: string; rendered: string } | undefined { if (this.cancelReason !== undefined) { return { reason: this.cancelReason, rendered: `workflow run cancelled: ${this.cancelReason}` } @@ -393,9 +369,20 @@ export class WorkerRun implements WorkflowRun { return } this.hostStarted += 1 + const task = this.startChild(callId, request) + this.pendingStarts.add(task) + void task.then( + () => { this.finishPendingStart(task) }, + /* v8 ignore next -- startChild contains provider and cleanup failures */ + () => { this.finishPendingStart(task) }, + ) + } + + /** Await one provider-owned startup transaction and publish only while admitted. */ + private async startChild(callId: number, request: ChildStartRequest): Promise { let run: SubagentRun try { - run = this.subagents.start(this.provider, { + run = await this.subagents.start(this.provider, { prompt: [{ type: 'text', text: request.prompt }], parent: this.parent, signal: this.controller.signal, @@ -403,32 +390,36 @@ export class WorkerRun implements WorkflowRun { ...request.model !== undefined ? { agentOptions: { model: request.model } } : {}, }) } catch (error: unknown) { - this.post(HostToWorkerType.ChildStartError, { callId, rendered: renderThrown(error) }) + const failure = this.childAdmissionFailure() + this.post(HostToWorkerType.ChildStartError, { + callId, + rendered: failure?.rendered ?? renderThrown(error), + }) + return + } + const failure = this.childAdmissionFailure() + if (failure !== undefined) { + this.post(HostToWorkerType.ChildStartError, { callId, rendered: failure.rendered }) + try { + await run.dispose() + } catch (error: unknown) { + this.ctx.logger.warn(`workflow-workerthread: refused child dispose failed: ${renderThrown(error)}`) + } return } - this.children.set(callId, run) - const childId = run.id - // Observe settlement IMMEDIATELY, before readiness. A provider may reject - // result and started in the same turn; delaying this handler would make the - // result transiently unhandled. Buffer a forwarding closure so the worker - // still sees ChildStarted before ChildSettled/ChildFailed. Snapshot a - // resolved result now: a provider mutating its resolved object while - // publication is pending must not change what crosses the worker boundary. + const record: ChildRecord = { run } + this.children.set(callId, record) + // Attach result forwarding before publishing the child handle. Because the + // callback itself runs in a later microtask, ChildStarted is still posted + // first even for an already-settled scripted provider. const forwardResult = run.result.then<() => void, () => void>( (result) => { try { - // Capture every provider-owned field once, then materialize the - // worker-bound value in one lossless traversal. A stateful accessor - // cannot validate one result and send another, and an exotic value is - // rejected before any prototype-erasing clone. - const output = result.output - const structured = result.structured - const stopReason = result.stopReason const snapshot = snapshotJsonValue({ - output, - ...structured !== undefined ? { structured } : {}, - stopReason, + output: result.output, + ...result.structured !== undefined ? { structured: result.structured } : {}, + stopReason: result.stopReason, }) if (snapshot === undefined) throw new TypeError('child result is not losslessly JSON-serializable') return () => { this.post(HostToWorkerType.ChildSettled, { callId, result: snapshot }) } @@ -442,67 +433,20 @@ export class WorkerRun implements WorkflowRun { return () => { this.post(HostToWorkerType.ChildFailed, { callId, rendered }) } }, ) - - // The provider owns the publication boundary. Observe both promises before - // invoking cancellation/disposal below: provider.start() itself is - // arbitrary code and may have reentered handle.cancel() before the returned - // run reached our registry. Exactly one branch answers this ChildStart. - let startReplySent = false - const refusePublication = (failure: { reason: string; rendered: string }): void => { - startReplySent = true - this.post(HostToWorkerType.ChildStartError, { callId, rendered: failure.rendered }) - // A prior dispose/death can finish and remove this run while readiness - // is still pending. In that case teardown already owned cancellation and - // disposal; touching the retired callId would repeat cancel and orphan a - // fresh gate entry after finishChild deleted it. - if (this.children.get(callId) !== run) return - this.cancelChild(callId, run, failure.reason) - void this.disposeChild(callId, run) - } - - // Only acknowledge the child after it is real, then flush any result that - // settled unusually early. Re-check admission at that exact boundary: a - // cancellation while readiness was pending is a refusal, not a late - // publication into a terminal workflow. A readiness rejection is a START - // failure, not AGENT_RESULT; the worker never receives a handle, so the - // host disposes the registered attempt. Identity guards preserve the one - // disposal memo against concurrent host teardown. - void run.started.then( - () => { - if (startReplySent) return - const failure = this.childAdmissionFailure() - if (failure !== undefined) { - refusePublication(failure) - return - } - startReplySent = true - this.post(HostToWorkerType.ChildStarted, { callId, childId }) - void forwardResult.then((forward) => { forward() }) - }, - (error: unknown) => { - if (startReplySent) return - startReplySent = true - this.post(HostToWorkerType.ChildStartError, { callId, rendered: renderThrown(error) }) - if (this.children.get(callId) === run) void this.disposeChild(callId, run) - }, - ) - - // Close the synchronous hole around provider.start(): cancel()/dispose() - // can run before the returned run is visible to their children loop. - const reentrantFailure = this.childAdmissionFailure() - if (reentrantFailure !== undefined) refusePublication(reentrantFailure) + this.post(HostToWorkerType.ChildStarted, { callId, childId: run.id }) + void forwardResult.then((forward) => { forward() }) } private onChildDispose(callId: number): void { - const run = this.children.get(callId) - if (run === undefined) { + const record = this.children.get(callId) + if (record === undefined) { // Already disposed host-side (a dispose() drive or a death reap beat // the RPC) — the ack is still owed (the worker-side wrapper awaits it). this.post(HostToWorkerType.ChildDisposed, { callId }) return } // disposeChild never rejects (containment is inside), so the ack always follows. - void this.disposeChild(callId, run).then(() => { this.post(HostToWorkerType.ChildDisposed, { callId }) }) + void this.disposeChild(callId, record).then(() => { this.post(HostToWorkerType.ChildDisposed, { callId }) }) } /** @@ -514,79 +458,55 @@ export class WorkerRun implements WorkflowRun { * not supposed to reject, but a backend that does anyway must not break * quiescence): logged, and the child still leaves the registry. * @param callId - the child's registry key. - * @param run - the registered child (the caller looked it up). + * @param record - the registered child (the caller looked it up). * @returns resolves when the disposal settled either way; never rejects. */ - private disposeChild(callId: number, run: SubagentRun): Promise { - let disposal = this.childDisposals.get(callId) - if (disposal === undefined) { - // Claim before run.dispose() invokes provider code. Reentrant holder - // disposal then joins this exact child transaction instead of entering - // the provider wrapper twice before either memo is installed. - const claimed = Promise.withResolvers() - disposal = claimed.promise - this.childDisposals.set(callId, disposal) - // The seam promises a Promise, but invoke inside an async boundary so a - // contract-violating synchronous throw is contained exactly like a - // rejected disposal and cannot break host quiescence. - void (async () => { await run.dispose() })().then( - () => { - this.finishChild(callId) - claimed.resolve(undefined) - }, - (error: unknown) => { - this.ctx.logger.warn(`workflow-workerthread: child dispose failed: ${renderThrown(error)}`) - this.finishChild(callId) - claimed.resolve(undefined) - }, - ) - } - return disposal + private disposeChild(callId: number, record: ChildRecord): Promise { + if (record.disposal !== undefined) return record.disposal + record.disposal = Promise.resolve() + .then(() => record.run.dispose()) + .catch((error: unknown) => { + this.ctx.logger.warn(`workflow-workerthread: child dispose failed: ${renderThrown(error)}`) + }) + .then(() => { this.finishChild(callId, record) }) + return record.disposal } - /** Drop a child from the registry (and its disposal memo), releasing quiescence waiters at zero. */ - private finishChild(callId: number): void { - this.children.delete(callId) - this.childDisposals.delete(callId) - this.childCancellations.delete(callId) - if (this.children.size === 0) { - for (const waiter of this.quiescenceWaiters.splice(0)) waiter() - } + /** Drop an exact child record and release quiescence waiters when all work ends. */ + private finishChild(callId: number, record: ChildRecord): void { + if (this.children.get(callId) === record) this.children.delete(callId) + this.notifyChildQuiescence() } - /** Resolves once the child registry is empty (every disposal settled). */ + /** Retire one provider startup transaction. */ + private finishPendingStart(task: Promise): void { + this.pendingStarts.delete(task) + this.notifyChildQuiescence() + } + + /** Release waiters only after both pending starts and published children end. */ + private notifyChildQuiescence(): void { + if (this.children.size !== 0 || this.pendingStarts.size !== 0) return + for (const waiter of this.quiescenceWaiters.splice(0)) waiter() + } + + /** Resolves once every pending start and published child has reached quiescence. */ private childQuiescence(): Promise { - if (this.children.size === 0) return Promise.resolve() + if (this.children.size === 0 && this.pendingStarts.size === 0) return Promise.resolve() return new Promise((resolve) => { this.quiescenceWaiters.push(resolve) }) } /** Abort + dispose every registered child (worker death / final teardown); disposal is contained, not awaited. */ private reapChildren(reason: string): void { - const cancellation = this.cancelReason ?? reason - this.cancelChildren(cancellation) - for (const [callId, run] of [...this.children]) { - void this.disposeChild(callId, run) + this.abortChildren(this.cancelReason ?? reason) + for (const [callId, record] of [...this.children]) { + void this.disposeChild(callId, record) } } - /** Drive both cancellation channels for every child already accepted by the host. */ - private cancelChildren(reason: string): void { - this.controller.abort(reason) - for (const [callId, run] of this.children) this.cancelChild(callId, run, reason) - } - - /** Invoke one provider-owned cancel callback at most once and contain its exception. */ - private cancelChild(callId: number, run: SubagentRun, reason?: string): void { - // Host fanout and the worker's FIFO-later ChildCancel relay are two paths - // to the same provider callback. The seam does not require cancel() to be - // idempotent, so claim the callId before invoking arbitrary provider code. - if (this.childCancellations.has(callId)) return - this.childCancellations.add(callId) - try { - run.cancel(reason) - } catch (error: unknown) { - this.ctx.logger.warn(`workflow-workerthread: child cancel failed: ${renderThrown(error)}`) - } + /** Abort the one canonical signal shared by pending and published children. */ + private abortChildren(reason: string): void { + if (!this.controller.signal.aborted) this.controller.abort(reason) } private onResult(result: WorkflowResult): void { @@ -599,17 +519,14 @@ export class WorkerRun implements WorkflowRun { // callbacks, but that internal post-result cleanup must not retroactively // rewrite the worker result that arrived first. const cancellationWasRequested = this.cancelReason !== undefined - // Claim before either settlement-cleanup cancellation channel invokes - // provider code. A provider callback can reenter cancel() synchronously or - // from a queued microtask; once Result won, that losing cancellation must - // have no state, message, child-fanout, or grace-timer side effects. + // Claim before settlement cleanup invokes provider disposal. Once Result + // won, a later cancellation cannot rewrite it. this.terminalClaimed = true - // The worker cancels handles it already received, but a fire-and-forget - // child may still be waiting on readiness and therefore have no worker - // handle. Drive BOTH provider-permitted channels from the host before the - // workflow becomes externally settled. + // Abort pending starts and begin disposing published children before the + // workflow becomes externally settled. Cleanup remains independently + // tracked by childQuiescence and the holder's dispose(). + this.reapChildren('workflow settled') if (!cancellationWasRequested) { - this.cancelChildren('workflow settled') this.settleResult(result) return } @@ -640,7 +557,7 @@ export class WorkerRun implements WorkflowRun { // accepted before death remains cancelled. If Result/grace already won, // preserve it while still performing prompt failure-time cleanup. if (!outcomeWasClaimed) this.terminalClaimed = true - if (this.children.size > 0) this.reapChildren('workflow worker gone') + if (this.children.size > 0 || this.pendingStarts.size > 0) this.reapChildren('workflow worker gone') this.endStrandedAgents() if (!outcomeWasClaimed) { if (cancellationWasRequested) { @@ -655,7 +572,7 @@ export class WorkerRun implements WorkflowRun { // precede `exit`. Admission is already closed, so this final sweep only // joins/starts disposal for registry survivors; it deliberately does not // repeat explicit provider cancellation. - for (const [callId, run] of [...this.children]) void this.disposeChild(callId, run) + for (const [callId, record] of [...this.children]) void this.disposeChild(callId, record) this.endStrandedAgents() } diff --git a/packages/workflow/workflow-workerthread/src/index.ts b/packages/workflow/workflow-workerthread/src/index.ts index 8a21847cd5..a1f5b47ccc 100644 --- a/packages/workflow/workflow-workerthread/src/index.ts +++ b/packages/workflow/workflow-workerthread/src/index.ts @@ -151,9 +151,7 @@ export class WorkerWorkflowEngine extends WorkflowService { const meta = validateMeta(request.meta) assertBodyParses(request.script, meta.name) const id = WorkflowRunId(randomUUID()) - // The event payloads and the run handle get SEPARATE meta clones: a - // listener mutating its snapshot must not corrupt the holder's view. - const info: WorkflowRunInfo = { id, meta: structuredClone(meta) } + const info: WorkflowRunInfo = { id, meta } const limits: WorkerLimits = { maxConcurrentAgents: this.config.maxConcurrentAgents === 0 ? Math.min(16, Math.max(1, availableParallelism() - 2)) @@ -180,7 +178,7 @@ export class WorkerWorkflowEngine extends WorkflowService { runCtx, subagents, id, - structuredClone(meta), + meta, request.parent, init, this.config.provider, diff --git a/packages/workflow/workflow-workerthread/src/protocol.ts b/packages/workflow/workflow-workerthread/src/protocol.ts index d70ad11613..70edc9a713 100644 --- a/packages/workflow/workflow-workerthread/src/protocol.ts +++ b/packages/workflow/workflow-workerthread/src/protocol.ts @@ -33,8 +33,6 @@ export enum WorkerToHostType { AgentEnd = 'agent-end', /** Child RPC: start a child on the host (answered by ChildStarted or ChildStartError). */ ChildStart = 'child-start', - /** Child RPC: cancel a started child (fire-and-forget). */ - ChildCancel = 'child-cancel', /** Child RPC: dispose a started child (answered by ChildDisposed). */ ChildDispose = 'child-dispose', /** The run's single terminal result. */ @@ -55,8 +53,6 @@ export interface WorkerToHostPayloads { [WorkerToHostType.AgentEnd]: { info: WorkflowAgentEndInfo } /** The RPC correlation id and the prompt plus validated options. */ [WorkerToHostType.ChildStart]: { callId: number; request: ChildStartRequest } - /** The RPC correlation id and the cancel reason (undefined = unspecified). */ - [WorkerToHostType.ChildCancel]: { callId: number; reason: string | undefined } /** The RPC correlation id of the child to dispose. */ [WorkerToHostType.ChildDispose]: { callId: number } /** The run's terminal outcome. */ @@ -69,9 +65,9 @@ export enum HostToWorkerType { Go = 'go', /** Cancel the run: hooks start throwing and the script dies at its next await. */ Cancel = 'cancel', - /** Child RPC reply: provider publication/readiness fulfilled (exactly one start reply per ChildStart). */ + /** Child RPC reply: the provider fulfilled with a ready run (exactly one start reply per ChildStart). */ ChildStarted = 'child-started', - /** Child RPC reply: synchronous start or asynchronous publication/readiness failed. */ + /** Child RPC reply: the provider's asynchronous start failed. */ ChildStartError = 'child-start-error', /** Child RPC: a started child's result RESOLVED (its JSON projection). */ ChildSettled = 'child-settled', diff --git a/packages/workflow/workflow-workerthread/src/runtime.ts b/packages/workflow/workflow-workerthread/src/runtime.ts index 9b324c1118..94282cb173 100644 --- a/packages/workflow/workflow-workerthread/src/runtime.ts +++ b/packages/workflow/workflow-workerthread/src/runtime.ts @@ -20,7 +20,7 @@ * * Failure discipline: fatal {@link WorkflowError}s (bad hook arguments, * unsupported options/schemas, tripped caps, synchronous start refusal, - * pre-publication readiness failure, ready-child result rejection, and + * provider-start failure, ready-child result rejection, and * cancellation) ALWAYS propagate through * `parallel`/`pipeline` — recognized by `instanceof` against this realm's * class, which a script inside the vm context cannot forge — and the per-item @@ -83,9 +83,8 @@ function defaultLabel(prompt: string): string { /** * One live script execution inside the worker. Constructed per run by the * session; `drive()` is called exactly once and NEVER rejects — every failure - * becomes a {@link WorkflowResult} with a non-`completed` stop reason. After - * the session publishes that result it calls {@link reapAfterResult} exactly - * once to cancel any dropped child work without racing terminal publication. + * becomes a {@link WorkflowResult} with a non-`completed` stop reason. The + * host owns cancellation and cleanup of any dropped child work. */ export class WorkflowExecution { /** 1-based count of `agent()` calls started (the `agentsStarted` result field). */ @@ -94,7 +93,6 @@ export class WorkflowExecution { private readonly slotWaiters: { resolve(): void; reject(error: unknown): void }[] = [] private cancelReason: string | undefined private cancelError: WorkflowError | undefined - private readonly controller = new AbortController() private currentPhase: string | undefined private readonly context: vm.Context private readonly compiled: vm.Script @@ -130,11 +128,8 @@ export class WorkflowExecution { pipeline: (items: unknown, ...stages: unknown[]) => this.contain(this.pipeline(items, stages)), phase: (title: unknown) => { this.phase(title) }, log: (message: unknown) => { this.log(message) }, - // Cloned once: a script scribbling on args must not mutate the - // session's init object (a benign-bug guard; args is plain JSON by the - // seam contract and already crossed one structured clone as workerData, - // so this clone is total). - args: args === undefined ? undefined : structuredClone(args), + // workerData already performed the real cross-thread structured clone. + args, } for (const [key, value] of Object.entries(globals)) { // Data properties on the contextified global; frozen shape not required — @@ -165,22 +160,18 @@ export class WorkflowExecution { } /** - * Cancel the run: in-flight children get a cancel RPC (the shared abort - * fanout), waiting `agent()` slots reject, and every future hook call + * Cancel the run: waiting `agent()` slots reject and every future hook call * throws `CANCELLED` — the script dies at its next await. A script that * never settles anyway (parked on a promise no hook owns) is the HOST's * problem: its grace timer force-settles the run and terminates the * worker. Idempotent; the first reason wins. - * @param reason - human-readable cause, carried on the CANCELLED error and - * into child cancel RPCs. Required: every caller (the session's cancel - * message and its post-result {@link reapAfterResult} call) has a concrete - * reason. + * @param reason - human-readable cause carried on the CANCELLED error. The + * host independently aborts the required signal shared by every child. */ cancel(reason: string): void { if (this.cancelReason !== undefined) return this.cancelReason = reason this.cancelError = new WorkflowError(`workflow run cancelled: ${this.cancelReason}`, 'CANCELLED') - this.controller.abort(this.cancelReason) for (const waiter of this.slotWaiters.splice(0)) waiter.reject(this.cancelledError()) } @@ -188,9 +179,8 @@ export class WorkflowExecution { * Run the script to settlement. Resolves — never rejects — with the run's * {@link WorkflowResult}: the materialized return value on `completed`, the * failure message on `error`, and `cancelled` when the script died of - * cancellation. This method only chooses the result; the session must publish - * it and then call {@link reapAfterResult}, so the terminal message precedes - * settlement-only child cancellation on the worker-to-host FIFO channel. + * cancellation. This method only chooses the result; the session publishes + * it and the host owns terminal child cancellation. * @returns the settled outcome — this promise NEVER rejects (the seam's * `result`-never-rejects contract); every failure maps to a variant. */ @@ -221,16 +211,6 @@ export class WorkflowExecution { } } - /** - * Reap strays only after the caller publishes the chosen terminal result. - * Aborting the controller synchronously sends child-cancel RPCs, so calling - * this before publication would let a provider callback reenter host - * cancellation and misclassify a result the script had already chosen. - */ - reapAfterResult(): void { - if (this.cancelReason === undefined) this.cancel('workflow settled') - } - /** * Attach a no-op rejection consumer WITHOUT changing what the caller * receives: if the script drops the promise (no await), cancellation cannot @@ -336,17 +316,11 @@ export class WorkflowExecution { // wind the fresh child down instead of leaving it live behind a dead // script. if (this.isCancelled()) { - run.cancel(this.cancelReason) await run.dispose() throw this.cancelledError() } const info: WorkflowAgentInfo = { seq, label, ...phase !== undefined ? { phase } : {}, childId: AgentId(run.id) } this.observer.agentStart(info) - // Cancellation reaches the child through an explicit cancel RPC per - // child (the host also aborts its own per-run signal, but the seam - // leaves a provider free to honor either channel, so both are driven). - const onAbort = (): void => { run.cancel(this.cancelReason) } - this.controller.signal.addEventListener('abort', onAbort, { once: true }) try { let result try { @@ -387,7 +361,6 @@ export class WorkflowExecution { this.observer.agentEnd({ ...info, outcome: 'failed' }) return null } finally { - this.controller.signal.removeEventListener('abort', onAbort) await run.dispose() } } finally { diff --git a/packages/workflow/workflow-workerthread/src/session.ts b/packages/workflow/workflow-workerthread/src/session.ts index f6b14f0fca..671a9429ae 100644 --- a/packages/workflow/workflow-workerthread/src/session.ts +++ b/packages/workflow/workflow-workerthread/src/session.ts @@ -14,11 +14,6 @@ * A `cancel` arriving instead of `go` still releases the gate: `drive()` * sees the cancelled state and settles without running the body. * - * Terminal ordering is Result first, settlement cleanup second. The session - * queues the Result message before asking the execution to reap stray children; - * MessagePort FIFO therefore lets the host atomically claim the result before a - * cleanup ChildCancel can invoke arbitrary provider code. - * * @module @deepseek-ai/dsh-workflow-workerthread/session */ @@ -64,10 +59,6 @@ class RpcChildHandle implements ChildHandle { this.result = entry.settled.promise } - cancel(reason?: string): void { - this.post(WorkerToHostType.ChildCancel, { callId: this.callId, reason }) - } - dispose(): Promise { this.post(WorkerToHostType.ChildDispose, { callId: this.callId }) return this.entry.disposed.promise @@ -76,7 +67,7 @@ class RpcChildHandle implements ChildHandle { /** * The worker-side child-RPC bridge ({@link ChildPort}): allocates callIds, - * posts the start/cancel/dispose RPCs, and owns the per-call pending + * posts the start/dispose RPCs, and owns the per-call pending * book-keeping the session's message handler settles via the `onChild*` * entry points. */ @@ -94,10 +85,10 @@ class ChildRpcBridge implements ChildPort { settled: Promise.withResolvers(), disposed: Promise.withResolvers(), } - // Containment: when synchronous start or asynchronous readiness fails (or + // Containment: when asynchronous provider start fails (or // the run is torn down), the settled promise may never gain a consumer — // it must not surface as an unhandled rejection and kill the worker. - entry.settled.promise.catch(() => { /* consumed: unconsumed child settlement after failed start/readiness */ }) + entry.settled.promise.catch(() => { /* consumed: unconsumed child settlement after failed start */ }) this.pending.set(callId, entry) this.post(WorkerToHostType.ChildStart, { callId, request }) const childId = await entry.started.promise @@ -109,7 +100,7 @@ class ChildRpcBridge implements ChildPort { this.pending.get(callId)?.started.resolve(childId) } - /** Synchronous start or asynchronous readiness failed; reject and retire the pending RPC. */ + /** Asynchronous provider start failed; reject and retire the pending RPC. */ onChildStartError(callId: number, rendered: string): void { const entry = this.pending.get(callId) this.pending.delete(callId) @@ -213,12 +204,5 @@ export async function runWorkerSession(port: MessagePort, init: WorkerInit): Pro post(WorkerToHostType.Ready, {}) await gate.promise const result = await execution.drive() - try { - // This post is the worker's terminal claim. Queue it BEFORE aborting stray - // children: MessagePort FIFO then guarantees the host claims Result before - // any settlement-only ChildCancel can invoke arbitrary provider callbacks. - post(WorkerToHostType.Result, { result }) - } finally { - execution.reapAfterResult() - } + post(WorkerToHostType.Result, { result }) } diff --git a/packages/workflow/workflow-workerthread/src/types.ts b/packages/workflow/workflow-workerthread/src/types.ts index ee5faccdda..2f29dd8137 100644 --- a/packages/workflow/workflow-workerthread/src/types.ts +++ b/packages/workflow/workflow-workerthread/src/types.ts @@ -77,8 +77,6 @@ export interface ChildHandle { * failed for its own reasons resolves with a non-`completed` stop reason. */ readonly result: Promise - /** Ask the host to cancel the child (fire-and-forget). */ - cancel(reason?: string): void /** Ask the host to dispose the child; resolves on the host's ack. */ dispose(): Promise } @@ -92,7 +90,7 @@ export interface ChildPort { * Start one child agent on the host (the `agent()` hook's start half). * @param request - the prompt and validated options. * @returns the ready child handle; rejects when synchronous start or the - * provider's asynchronous publication/readiness boundary fails. + * provider's asynchronous start fails. */ startAgent(request: ChildStartRequest): Promise } diff --git a/packages/workflow/workflow-workerthread/tests/integration.spec.ts b/packages/workflow/workflow-workerthread/tests/integration.spec.ts index 51ace9253b..0e1727f877 100644 --- a/packages/workflow/workflow-workerthread/tests/integration.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/integration.spec.ts @@ -49,7 +49,7 @@ describe('dsh-workflow-workerthread over the real in-process stack', () => { ]) const childIds: string[] = [] ctx.on('workflow/agent-start', (_info, agent) => { - // The workflow bridge must honor SubagentRun.started: a start observer + // The workflow bridge must await asynchronous provider start: an observer // sees the real spawn child already published, never a reserved id. expect(ctx.agents.get(agent.childId)).toBeDefined() childIds.push(agent.childId) diff --git a/packages/workflow/workflow-workerthread/tests/session.spec.ts b/packages/workflow/workflow-workerthread/tests/session.spec.ts index e2dab8948f..5fc85ce647 100644 --- a/packages/workflow/workflow-workerthread/tests/session.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/session.spec.ts @@ -212,7 +212,7 @@ describe('runWorkerSession over an in-process MessageChannel', () => { host.close() }) - it('cancel mid-run: in-flight children get cancel RPCs, hooks throw at entry, the run reports cancelled', async () => { + it('cancel mid-run: hooks throw at entry and the run reports cancelled', async () => { const host = fakeHost() void runWorkerSession(host.port, init(` phase('before') @@ -232,7 +232,6 @@ describe('runWorkerSession over an in-process MessageChannel', () => { const result = await host.result() expect(result.stopReason).toBe('cancelled') expect(result.error).toContain('stop everything') - expect(host.ofType(WorkerToHostType.ChildCancel).map(m => m.callId)).toContain(callId) expect(host.ofType(WorkerToHostType.AgentEnd)[0]!.info.outcome).toBe('cancelled') // No post-cancel narration left the runtime (the hooks threw at entry). expect(host.ofType(WorkerToHostType.Phase).map(m => m.title)).toEqual(['before']) @@ -281,36 +280,6 @@ describe('runWorkerSession over an in-process MessageChannel', () => { } }) - it('queues Result before settlement-only cancellation of a ready stray', async () => { - const host = fakeHost({ manual: true }) - const session = runWorkerSession(host.port, init(` - agent('ready stray') - return await agent('gate') - `)) - await vi.waitFor(() => { expect(host.ofType(WorkerToHostType.ChildStart)).toHaveLength(2) }) - const starts = host.ofType(WorkerToHostType.ChildStart) - const stray = starts.find(message => message.request.prompt === 'ready stray')! - const gate = starts.find(message => message.request.prompt === 'gate')! - host.send({ type: HostToWorkerType.ChildStarted, callId: stray.callId, childId: 'stray-child' }) - host.send({ type: HostToWorkerType.ChildStarted, callId: gate.callId, childId: 'gate-child' }) - host.send({ type: HostToWorkerType.ChildSettled, callId: gate.callId, result: text('gate completed') }) - - const result = await host.result() - await session - await vi.waitFor(() => { - expect(host.ofType(WorkerToHostType.ChildCancel).map(message => message.callId)).toContain(stray.callId) - }) - - expect(result).toMatchObject({ value: 'gate completed', stopReason: 'completed', agentsStarted: 2 }) - const resultIndex = host.messages.findIndex(message => message.type === WorkerToHostType.Result) - const strayCancelIndex = host.messages.findIndex(message => - message.type === WorkerToHostType.ChildCancel && message.callId === stray.callId) - expect(resultIndex).toBeGreaterThanOrEqual(0) - expect(strayCancelIndex).toBeGreaterThan(resultIndex) - host.send({ type: HostToWorkerType.ChildSettled, callId: stray.callId, result: { output: [], stopReason: 'aborted' } }) - host.close() - }) - it('an unparseable body settles an error result instead of dying without one (host pre-parse skew guard)', async () => { const host = fakeHost() await runWorkerSession(host.port, init('return (((')) @@ -463,7 +432,7 @@ describe('runWorkerSession over an in-process MessageChannel', () => { host.close() }) - it('a cancel landing DURING the start round-trip winds the fresh child down (cancel + dispose) and dies cancelled', async () => { + it('a cancel landing DURING the start round-trip disposes the fresh child and dies cancelled', async () => { const host = fakeHost({ manual: true }) void runWorkerSession(host.port, init("return await agent('p')")) await vi.waitFor(() => { expect(host.ofType(WorkerToHostType.ChildStart).length).toBe(1) }) @@ -477,7 +446,6 @@ describe('runWorkerSession over an in-process MessageChannel', () => { const result = await host.result() expect(result.stopReason).toBe('cancelled') await vi.waitFor(() => { - expect(host.ofType(WorkerToHostType.ChildCancel).map(m => m.callId)).toContain(callId) expect(host.ofType(WorkerToHostType.ChildDispose).map(m => m.callId)).toContain(callId) }) // The child never became an agent-start: it was wound down pre-lifecycle. diff --git a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts index ec2f458d45..623da10f72 100644 --- a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts @@ -48,9 +48,9 @@ const ESCAPE = "globalThis.constructor.constructor('return process')()" /** One controllable child run: the test (or auto mode) settles it. */ interface ControlledRun { request: SubagentStartRequest - /** Fulfill the provider publication/readiness boundary. */ + /** Fulfill the provider's async start with a ready child. */ publish(): void - /** Reject the provider publication/readiness boundary. */ + /** Reject the provider's async start before ownership transfer. */ rejectStart(error: unknown): void settle(result: SubagentResult): void rejectResult(error: unknown): void @@ -75,17 +75,19 @@ class StubProvider implements SubagentProvider { private readonly reply?: (request: SubagentStartRequest, index: number) => SubagentResult, private readonly disposeDelayMs = 0, private readonly deferStart = false, - private readonly onCancel?: (reason: string | undefined, index: number) => void, + private readonly onAbortString?: (reason: string | undefined, index: number) => void, private readonly onSignalAbort?: (reason: unknown, index: number) => void, ) {} - start(request: SubagentStartRequest): SubagentRun { - const readiness = Promise.withResolvers() + async start(request: SubagentStartRequest): Promise { + const startGate = Promise.withResolvers() const terminal = Promise.withResolvers() + terminal.promise.catch(() => { /* provider owns early settlement until publication */ }) + let published = false const controlled: ControlledRun = { request, - publish: () => { readiness.resolve(undefined) }, - rejectStart: (error) => { readiness.reject(error) }, + publish: () => { published = true; startGate.resolve(undefined) }, + rejectStart: (error) => { startGate.reject(error) }, settle: (result) => { terminal.resolve(result) }, rejectResult: (error) => { terminal.reject(error) }, cancelled: undefined, @@ -94,24 +96,29 @@ class StubProvider implements SubagentProvider { } this.runs.push(controlled) const index = this.runs.length - 1 - request.signal?.addEventListener('abort', () => { - this.onSignalAbort?.(request.signal?.reason, index) - terminal.resolve({ output: [], stopReason: 'aborted' }) + request.signal.addEventListener('abort', () => { + controlled.cancelled = String(request.signal.reason ?? 'cancelled') + this.onAbortString?.(String(request.signal.reason ?? 'cancelled'), index) + this.onSignalAbort?.(request.signal.reason, index) + if (published) terminal.resolve({ output: [], stopReason: 'aborted' }) + else startGate.reject(new Error('child start aborted before publication')) }, { once: true }) - if (!this.deferStart) readiness.resolve(undefined) + if (!this.deferStart) controlled.publish() if (this.reply) { const reply = this.reply queueMicrotask(() => { terminal.resolve(reply(request, index)) }) } + try { + await startGate.promise + } catch (error: unknown) { + controlled.disposeCalls += 1 + controlled.disposed = true + throw error + } + if (request.signal.aborted) throw new Error('child start aborted before publication') return { id: AgentId(`stub-child-${index}`), - started: readiness.promise, result: terminal.promise, - cancel: (reason?: string) => { - controlled.cancelled = reason ?? 'cancelled' - this.onCancel?.(reason, index) - terminal.resolve({ output: [], stopReason: 'aborted' }) - }, dispose: () => { controlled.disposeCalls += 1 if (this.disposeDelayMs === 0) { @@ -140,7 +147,7 @@ interface SetupOptions { manual?: boolean disposeDelayMs?: number deferStart?: boolean - onChildCancel?: (reason: string | undefined, index: number) => void + onChildAbortString?: (reason: string | undefined, index: number) => void onChildSignalAbort?: (reason: unknown, index: number) => void } @@ -152,7 +159,7 @@ async function setup(options?: SetupOptions) { options?.manual ? undefined : options?.reply ?? (() => text('stub reply')), options?.disposeDelayMs ?? 0, options?.deferStart ?? false, - options?.onChildCancel, + options?.onChildAbortString, options?.onChildSignalAbort, ) ctx.subagents.registerProvider(provider) @@ -243,7 +250,7 @@ describe('dsh-workflow-workerthread', () => { expect(result.error).toContain('agent() could not start a child') }) - it('waits for child readiness before announcing it and snapshots a result that settled early', async () => { + it('waits for async provider start before announcing a result that settled early', async () => { const { ctx, parent, provider } = await setup({ manual: true, deferStart: true }) const order: string[] = [] ctx.on('workflow/agent-start', (_info, agent) => { order.push(`start:${agent.seq}`) }) @@ -254,10 +261,8 @@ describe('dsh-workflow-workerthread', () => { await waitFor(() => { expect(provider.runs.length).toBe(1) }) const early = text('accepted value') provider.runs[0]!.settle(early) - // Let the host observe + snapshot result while readiness remains pending. + // The provider still owns this early result while start is pending. await new Promise(resolve => setTimeout(resolve, 0)) - const earlyText = early.output[0] as { type: 'text'; text: string } - earlyText.text = 'mutated after settlement' expect(order).toEqual([]) provider.runs[0]!.publish() @@ -268,7 +273,7 @@ describe('dsh-workflow-workerthread', () => { expect(provider.runs[0]!.disposeCalls).toBe(1) }) - it('observes an early result rejection but sends ChildStarted before ChildFailed after readiness', async () => { + it('observes an early result rejection but sends ChildStarted before ChildFailed after start fulfills', async () => { const { ctx, parent, provider } = await setup({ manual: true, deferStart: true }) const lifecycle: string[] = [] ctx.on('workflow/agent-start', () => { lifecycle.push('start') }) @@ -299,7 +304,7 @@ describe('dsh-workflow-workerthread', () => { await handle.dispose() }) - it('classifies readiness rejection as AGENT_START, drops an early result, and emits no false lifecycle pair', async () => { + it('classifies provider start rejection as AGENT_START, drops an early result, and emits no false lifecycle pair', async () => { const { ctx, parent, provider } = await setup({ manual: true, deferStart: true }) const lifecycle: string[] = [] ctx.on('workflow/agent-start', () => { lifecycle.push('start') }) @@ -311,7 +316,7 @@ describe('dsh-workflow-workerthread', () => { }) await waitFor(() => { expect(provider.runs.length).toBe(1) }) // ACP-style failure can settle result(error) before its session/publication - // boundary rejects. Readiness must dominate that buffered child outcome. + // boundary rejects. Start rejection must dominate that buffered child outcome. provider.runs[0]!.settle({ output: [], stopReason: 'error' }) await new Promise(resolve => setTimeout(resolve, 0)) provider.runs[0]!.rejectStart(new Error('publication rolled back')) @@ -328,7 +333,7 @@ describe('dsh-workflow-workerthread', () => { expect(provider.runs[0]!.disposeCalls).toBe(1) }) - it('cancels and disposes a readiness-pending child once without publishing workflow lifecycle', async () => { + it('aborts a pending provider start once without publishing workflow lifecycle', async () => { const { ctx, parent, provider } = await setup({ manual: true, deferStart: true, config: { disposeGraceMs: 500 } }) const lifecycle: string[] = [] ctx.on('workflow/agent-start', () => { lifecycle.push('start') }) @@ -342,7 +347,7 @@ describe('dsh-workflow-workerthread', () => { expect(provider.runs[0]!.disposed).toBe(true) }) // Ensure the host-driven disposal removed the registry entry before the - // late readiness rejection; its callback must not invoke dispose again. + // late start rejection; its callback must not invoke dispose again. await new Promise(resolve => setTimeout(resolve, 0)) provider.runs[0]!.rejectStart(new Error('cancelled before publication')) @@ -360,11 +365,9 @@ describe('dsh-workflow-workerthread', () => { name: 'rejecting', capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: false }, inheritsParentContext: false, - start: () => ({ + start: async () => ({ id: AgentId('reject-child'), - started: Promise.resolve(), result: Promise.reject(new Error('backend exploded')), - cancel: () => { /* nothing in flight */ }, dispose: () => Promise.resolve(), }), } @@ -385,24 +388,20 @@ describe('dsh-workflow-workerthread', () => { try { await agent('p'); return 'unreachable' } catch (e) { return { code: e.code, message: e.message } } `)) expect(result.value).toMatchObject({ code: 'AGENT_RESULT' }) - expect((result.value as { message: string }).message).toContain('subagent result must be losslessly JSON-serializable') + expect((result.value as { message: string }).message).toContain('workflow child result could not cross the worker boundary') }) it('contains a non-JSON result even if the injected subagent service violates its normalization contract', async () => { - // SubagentService normally rejects this before the workflow sees it. Stub - // the injected seam itself so the host's defensive worker-boundary guard - // remains independently covered rather than becoming dead, untested code. + // The real worker boundary must reject a non-JSON same-process result. const { ctx, parent } = await setup() const invalid = { output: [], structured: () => { /* deliberately outside lossless JSON */ }, stopReason: 'completed', } as unknown as SubagentResult - const start = vi.spyOn(ctx.subagents, 'start').mockReturnValue({ + const start = vi.spyOn(ctx.subagents, 'start').mockResolvedValue({ id: AgentId('raw-invalid-child'), - started: Promise.resolve(), result: Promise.resolve(invalid), - cancel: () => { /* already settled */ }, dispose: () => Promise.resolve(), }) @@ -416,31 +415,6 @@ describe('dsh-workflow-workerthread', () => { .toContain('workflow child result could not cross the worker boundary') }) - it('reads each resolved child-result field once before crossing the worker boundary', async () => { - let structuredReads = 0 - class DriftedStructured { readonly value = 'drifted' } - const { ctx, parent } = await setup({ - reply: () => ({ - output: [], - get structured() { - structuredReads += 1 - return structuredReads === 1 ? { value: 'accepted' } : new DriftedStructured() - }, - stopReason: 'completed', - }), - }) - - const result = await run(ctx, parent, scripted(` - const found = await agent('p', { - schema: { type: 'object', properties: { value: { type: 'string' } }, required: ['value'] } - }) - return found.value - `)) - - expect(result.value).toBe('accepted') - expect(structuredReads).toBe(1) - }) - it('a child whose dispose() throws synchronously cannot wedge the script (the host acks anyway)', async () => { const ctx = new Context() await ctx.plugin(SubagentService) @@ -448,9 +422,8 @@ describe('dsh-workflow-workerthread', () => { name: 'bad-dispose', capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: false }, inheritsParentContext: false, - start: () => ({ + start: async () => ({ id: AgentId('bad-dispose-child'), - started: Promise.resolve(), result: Promise.resolve({ output: [{ type: 'text', text: 'fine' }], stopReason: 'completed' }), cancel: () => { /* settled already */ }, dispose: () => { throw new Error('dispose exploded') }, @@ -470,9 +443,8 @@ describe('dsh-workflow-workerthread', () => { name: 'coercion-trap-dispose', capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: false }, inheritsParentContext: false, - start: () => ({ + start: async () => ({ id: AgentId('trap-child'), - started: Promise.resolve(), result: Promise.resolve({ output: [{ type: 'text', text: 'fine' }], stopReason: 'completed' }), cancel: () => { /* settled already */ }, // The rejection VALUE's own coercion throws: a warn built with bare @@ -755,7 +727,7 @@ describe('dsh-workflow-workerthread', () => { expect(provider.runs[0]!.disposed).toBe(true) }) - it('dispose() reaps a registered stray after result settlement even when the worker cannot relay disposal', async () => { + it('result settlement reaps a registered stray even when the worker cannot relay disposal', async () => { const { ctx, parent, provider } = await setup({ manual: true, config: { provider: 'stub', disposeGraceMs: 30_000 }, @@ -775,13 +747,9 @@ describe('dsh-workflow-workerthread', () => { result: { value: 'synthetic completion', stopReason: 'completed', agentsStarted: 1 }, }) await expect(handle.result).resolves.toMatchObject({ stopReason: 'completed' }) - expect(provider.runs[0]!.disposed).toBe(false) + await waitFor(() => { expect(provider.runs[0]!.disposed).toBe(true) }, 1000) const disposal = handle.dispose() - // A 30-second grace makes this assertion mutation-sensitive: without the - // settled-path host reap, no worker message can start child disposal and - // this bounded wait fails long before the grace fallback. - await waitFor(() => { expect(provider.runs[0]!.disposed).toBe(true) }, 1000) await disposal expect(provider.runs[0]!.disposeCalls).toBe(1) await ctx.fiber.dispose() @@ -795,21 +763,16 @@ describe('dsh-workflow-workerthread', () => { name: 'signal-only', capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: false }, inheritsParentContext: false, - start: (request) => { + start: async (request) => { let settle!: (result: SubagentResult) => void const result = new Promise((resolve) => { settle = resolve }) - request.signal?.addEventListener('abort', () => { - aborted.push(String(request.signal?.reason)) + request.signal.addEventListener('abort', () => { + aborted.push(String(request.signal.reason)) settle({ output: [], stopReason: 'aborted' }) }, { once: true }) return { id: AgentId('signal-only-child'), - started: Promise.resolve(), result, - // The seam leaves a provider free to honor EITHER cancel channel; - // this one deliberately ignores run.cancel() — only the request - // signal can wind it down. - cancel: () => { /* signal-only by design */ }, dispose: () => Promise.resolve(), } }, @@ -834,7 +797,7 @@ describe('dsh-workflow-workerthread', () => { await handle.dispose() }) - it('the settle-reap explicitly cancels a readiness-pending stray before workflow/end', async () => { + it('the settle-reap aborts a pending provider start before workflow/end', async () => { const { ctx, parent, provider } = await setup({ manual: true, deferStart: true }) const childLifecycle: string[] = [] let cancellationAtWorkflowEnd: string | undefined @@ -845,7 +808,7 @@ describe('dsh-workflow-workerthread', () => { }) const handle = ctx.workflows.start({ ...scripted(` - agent('readiness-pending stray') + agent('start-pending stray') return 'done' `), parent, @@ -864,129 +827,11 @@ describe('dsh-workflow-workerthread', () => { expect(provider.runs[0]!.disposeCalls).toBe(1) }) - it('post-result child cleanup cannot reentrantly rewrite a completed workflow as cancelled', async () => { - let cancelCallbacks = 0 - let signalCallbacks = 0 - const { ctx, parent, provider } = await setup({ - manual: true, - deferStart: true, - onChildCancel: () => { - cancelCallbacks += 1 - // The first callback is host cleanup for the already-arrived Result. - // Reentering cancel() here is later than that message and must not - // retroactively win the result race. Its nested child cancel is - // intentionally ignored to keep the adversarial callback finite. - if (cancelCallbacks === 1) handle.cancel('reentrant child cleanup') - }, - onChildSignalAbort: () => { - signalCallbacks += 1 - handle.cancel('reentrant signal cleanup') - }, - }) - const handle = ctx.workflows.start({ - ...scripted(` - agent('readiness-pending stray') - return 'completed first' - `), - parent, - }) - - const result = await handle.result - - expect(result).toMatchObject({ value: 'completed first', stopReason: 'completed', agentsStarted: 1 }) - expect(signalCallbacks).toBe(1) - expect(cancelCallbacks).toBe(1) - // Readiness crossing after Result is a terminal-admission refusal: no - // ChildStarted/lifecycle publication, and host-owned disposal begins. - provider.runs[0]!.publish() - await waitFor(() => { expect(provider.runs[0]!.disposed).toBe(true) }, 1000) - expect(cancelCallbacks).toBe(1) - await handle.dispose() - await ctx.fiber.dispose() - }) - - it('late readiness after completed disposal cannot cancel or dispose the retired child twice', async () => { - let explicitCancels = 0 - const lifecycle: string[] = [] - const { ctx, parent, provider } = await setup({ - manual: true, - deferStart: true, - onChildCancel: () => { explicitCancels += 1 }, - }) - ctx.on('workflow/agent-start', () => { lifecycle.push('start') }) - ctx.on('workflow/agent-end', () => { lifecycle.push('end') }) - const handle = ctx.workflows.start({ - ...scripted("agent('retired readiness')\nreturn 'done'"), - parent, - }) - - await expect(handle.result).resolves.toMatchObject({ stopReason: 'completed' }) - expect(explicitCancels).toBe(1) - await handle.dispose() - expect(provider.runs[0]!.disposed).toBe(true) - expect(provider.runs[0]!.disposeCalls).toBe(1) - - // The Promise may still fulfill after its run left every host ledger. - // Refusal replies once but must not recreate the deleted cancel gate. - provider.runs[0]!.publish() - await Promise.resolve() - await Promise.resolve() - expect(explicitCancels).toBe(1) - expect(provider.runs[0]!.disposeCalls).toBe(1) - expect(lifecycle).toEqual([]) - await ctx.fiber.dispose() - }) - - it.each([ - ['synchronous', (cancel: () => void) => { cancel() }], - ['microtask', (cancel: () => void) => { queueMicrotask(cancel) }], - ])('a ready stray %s cleanup callback cannot beat the earlier worker result claim', async (_mode, reenter) => { - let reentered = false - const explicitCancels = new Map() - const { ctx, parent, provider } = await setup({ - manual: true, - onChildCancel: (_reason, index) => { - explicitCancels.set(index, (explicitCancels.get(index) ?? 0) + 1) - if (index !== 0 || reentered) return - reentered = true - reenter(() => { handle.cancel('reentered from child cleanup') }) - }, - }) - const handle = ctx.workflows.start({ - ...scripted(` - agent('ready stray') - return await agent('gate') - `), - parent, - }) - const cancelChildSpy = vi.spyOn(handle as unknown as { - cancelChild(callId: number, run: SubagentRun, reason?: string): void - }, 'cancelChild') - await waitFor(() => { expect(provider.runs).toHaveLength(2) }) - provider.runs[1]!.settle(text('gate completed')) - - const result = await handle.result - await Promise.resolve() - - expect(result).toMatchObject({ value: 'gate completed', stopReason: 'completed', agentsStarted: 2 }) - expect(reentered).toBe(true) - // The host claim and worker's FIFO-later ChildCancel both reach the - // routing gate, but the provider callback is not an idempotent seam: - // invoke it exactly once for this callId. - await waitFor(() => { - expect(cancelChildSpy.mock.calls.filter(([callId]) => callId === 1)).toHaveLength(2) - }, 1000) - expect(explicitCancels.get(0)).toBe(1) - cancelChildSpy.mockRestore() - await handle.dispose() - await ctx.fiber.dispose() - }) - it('a duplicate Result after the terminal claim cannot repeat cleanup or rewrite the outcome', async () => { - let explicitCancels = 0 + let signalAborts = 0 const { ctx, parent, provider } = await setup({ manual: true, - onChildCancel: (_reason, index) => { if (index === 0) explicitCancels += 1 }, + onChildAbortString: (_reason, index) => { if (index === 0) signalAborts += 1 }, }) const handle = ctx.workflows.start({ ...scripted("agent('stray')\nawait new Promise(() => {})"), @@ -1005,266 +850,9 @@ describe('dsh-workflow-workerthread', () => { }) await expect(handle.result).resolves.toMatchObject({ value: 'first', stopReason: 'completed' }) - expect(explicitCancels).toBe(1) + expect(signalAborts).toBe(1) await handle.dispose() - expect(explicitCancels).toBe(1) - await ctx.fiber.dispose() - }) - - it('contains a throwing child cancel and still settles after cancelling peer strays', async () => { - const ctx = new Context() - await ctx.plugin(SubagentService) - let starts = 0 - const cancelled: string[] = [] - const warnings: string[] = [] - ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn - const provider: SubagentProvider = { - name: 'throwing-cancel', - capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: false }, - inheritsParentContext: false, - start: () => { - const index = starts++ - return { - id: AgentId(`throwing-cancel-${index}`), - started: new Promise(() => { /* readiness stays pending */ }), - result: new Promise(() => { /* cancellation callback owns settlement */ }), - cancel: (reason?: string) => { - if (index === 0) throw new Error('cancel callback broke') - cancelled.push(`${index}:${reason ?? 'cancelled'}`) - }, - dispose: () => Promise.resolve(), - } - }, - } - ctx.subagents.registerProvider(provider) - await ctx.plugin(WorkerWorkflowEngine, { provider: 'throwing-cancel', maxConcurrentAgents: 2 }) - const handle = ctx.workflows.start({ - ...scripted(` - agent('first stray') - agent('second stray') - return 'done' - `), - parent: fakeParent(), - }) - - const result = await handle.result - - expect(result.stopReason).toBe('completed') - expect(starts).toBe(2) - expect(cancelled).toContain('1:workflow settled') - expect(warnings.some(message => message.includes('cancel callback broke'))).toBe(true) - await handle.dispose() - }) - - it("cancel() drives each child's explicit cancel() host-side: a wedged worker cannot delay it", async () => { - const ctx = new Context() - await ctx.plugin(SubagentService) - let starts = 0 - const cancelled: string[] = [] - const provider: SubagentProvider = { - name: 'cancel-only', - capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: false }, - inheritsParentContext: false, - start: () => { - starts += 1 - return { - id: AgentId('cancel-only-child'), - started: Promise.resolve(), - result: new Promise(() => { /* only cancel() ends this child */ }), - // Deliberately ignores the request signal — the seam leaves a - // provider free to honor ONLY the explicit cancel() channel. - cancel: (reason?: string) => { cancelled.push(reason ?? 'cancelled') }, - dispose: () => Promise.resolve(), - } - }, - } - ctx.subagents.registerProvider(provider) - // A deliberately huge grace: if only the grace/terminate reap could - // reach this child, the assertion below would time out first. - await ctx.plugin(WorkerWorkflowEngine, { provider: 'cancel-only', maxConcurrentAgents: 2, disposeGraceMs: 30_000 }) - const handle = ctx.workflows.start({ - // The stray child's start RPC reaches the host, then the script wedges - // its own worker in a synchronous spin: the worker cannot process the - // Cancel message, so it can relay NO ChildCancel RPC — only the host's - // own children loop can deliver the explicit cancel in time. The - // microtask yields let the agent() continuation POST its child-start - // before the spin seizes the worker's loop (the posted message needs - // no further worker-loop turns to reach the host). - ...scripted(` - agent('wedged child') - for (let i = 0; i < 20; i++) await null - const end = Date.now() + 1500 - while (Date.now() < end) {} - return 'raced' - `), - parent: fakeParent(), - }) - await waitFor(() => { expect(starts).toBe(1) }) - handle.cancel('stop now') - await waitFor(() => { expect(cancelled).toEqual(['stop now']) }, 800) - // The wedged worker's own completion loses to the in-flight cancel. - const result = await handle.result - expect(result.stopReason).toBe('cancelled') - await handle.dispose() - }, 15_000) - - it.each(['fulfills', 'rejects'] as const)('provider.start() reentrant cancellation refuses the run when readiness later %s', async (readinessOutcome) => { - const ctx = new Context() - await ctx.plugin(SubagentService) - const readiness = Promise.withResolvers() - let starts = 0 - let explicitCancels = 0 - let disposals = 0 - let sawAbortedSignal = false - const lifecycle: string[] = [] - const provider: SubagentProvider = { - name: 'start-reentry', - capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: false }, - inheritsParentContext: false, - start: (request) => { - starts += 1 - // This arbitrary provider callback runs before onChildStart can put - // the returned run in its registry. Cancellation must be rechecked - // after return instead of trusting the pre-start admission check. - handle.cancel('provider start reentered cancellation') - sawAbortedSignal = request.signal?.aborted === true - return { - id: AgentId('start-reentry-child'), - started: readiness.promise, - result: new Promise(() => { /* refusal owns teardown */ }), - // Deliberately honors only the explicit channel. It must still be - // reached promptly even though the first host fanout saw no run. - cancel: () => { explicitCancels += 1 }, - dispose: () => { - disposals += 1 - return Promise.resolve() - }, - } - }, - } - ctx.subagents.registerProvider(provider) - await ctx.plugin(WorkerWorkflowEngine, { - provider: 'start-reentry', - maxConcurrentAgents: 2, - disposeGraceMs: 30_000, - }) - ctx.on('workflow/agent-start', () => { lifecycle.push('start') }) - ctx.on('workflow/agent-end', () => { lifecycle.push('end') }) - const handle = ctx.workflows.start({ - ...scripted("await agent('reentrant provider')\nreturn 'unreachable'"), - parent: fakeParent(), - }) - - await waitFor(() => { expect(starts).toBe(1) }) - // Either later readiness settlement must not answer the already-refused - // start again or emit a workflow lifecycle pair. - if (readinessOutcome === 'fulfills') readiness.resolve(undefined) - else readiness.reject(new Error('late readiness rejection after refusal')) - let result: WorkflowResult | undefined - void handle.result.then((value) => { result = value }) - await waitFor(() => { - expect(explicitCancels).toBe(1) - expect(disposals).toBe(1) - expect(result?.stopReason).toBe('cancelled') - }, 1000) - expect(sawAbortedSignal).toBe(true) - expect(lifecycle).toEqual([]) - await handle.dispose() - expect(explicitCancels).toBe(1) - expect(disposals).toBe(1) - await ctx.fiber.dispose() - }) - - it('claims workflow and child disposal before a raw provider disposer reenters handle.dispose()', async () => { - const ctx = new Context() - await ctx.plugin(SubagentService) - const terminal = Promise.withResolvers() - const observed: { reentrant?: Promise } = {} - let starts = 0 - let rawDisposeCalls = 0 - const provider: SubagentProvider = { - name: 'dispose-reentry', - capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: false }, - inheritsParentContext: false, - start: () => { - starts += 1 - return { - id: AgentId('dispose-reentry-child'), - started: Promise.resolve(), - result: terminal.promise, - cancel: () => { terminal.resolve({ output: [], stopReason: 'aborted' }) }, - dispose: () => { - rawDisposeCalls += 1 - observed.reentrant = handle.dispose() - return Promise.resolve() - }, - } - }, - } - ctx.subagents.registerProvider(provider) - await ctx.plugin(WorkerWorkflowEngine, { provider: 'dispose-reentry', maxConcurrentAgents: 2 }) - const handle = ctx.workflows.start({ - ...scripted("await agent('live child')\nreturn 'unreachable'"), - parent: fakeParent(), - }) - await waitFor(() => { expect(starts).toBe(1) }) - - const disposal = handle.dispose() - - expect(observed.reentrant).toBe(disposal) - await disposal - expect(rawDisposeCalls).toBe(1) - await expect(handle.result).resolves.toMatchObject({ stopReason: 'cancelled' }) - await ctx.fiber.dispose() - }) - - it('claims worker-originated child disposal before its raw disposer reenters holder disposal', async () => { - const ctx = new Context() - await ctx.plugin(SubagentService) - const terminal = Promise.withResolvers() - const observed: { reentrant?: Promise } = {} - let starts = 0 - let rawDisposeCalls = 0 - const provider: SubagentProvider = { - name: 'child-dispose-reentry', - capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: false }, - inheritsParentContext: false, - start: () => { - starts += 1 - return { - id: AgentId('child-dispose-reentry-child'), - started: Promise.resolve(), - result: terminal.promise, - cancel: () => { terminal.resolve({ output: [], stopReason: 'aborted' }) }, - dispose: () => { - rawDisposeCalls += 1 - // This begins holder disposal from the worker's ChildDispose - // callback, before any public handle.dispose() call exists. - observed.reentrant = handle.dispose() - return Promise.resolve() - }, - } - }, - } - ctx.subagents.registerProvider(provider) - await ctx.plugin(WorkerWorkflowEngine, { provider: 'child-dispose-reentry', maxConcurrentAgents: 2 }) - const handle = ctx.workflows.start({ - ...scripted("return await agent('settling child')"), - parent: fakeParent(), - }) - const finishChildSpy = vi.spyOn(handle as unknown as { - finishChild(callId: number): void - }, 'finishChild') - await waitFor(() => { expect(starts).toBe(1) }) - - terminal.resolve({ output: [{ type: 'text', text: 'done' }], stopReason: 'completed' }) - - await waitFor(() => { expect(observed.reentrant).toBeDefined() }, 1000) - await observed.reentrant - expect(rawDisposeCalls).toBe(1) - expect(finishChildSpy.mock.calls.filter(([callId]) => callId === 1)).toHaveLength(1) - finishChildSpy.mockRestore() - await expect(handle.result).resolves.toMatchObject({ stopReason: 'cancelled' }) + expect(signalAborts).toBe(1) await ctx.fiber.dispose() }) @@ -1466,29 +1054,21 @@ describe('dsh-workflow-workerthread', () => { await ctx.plugin(SubagentService) // The child's dispose() REJECTS on top of the worker death: the reap // must contain it (warn, not crash) while still emptying the registry. - const cancelled: string[] = [] const signalAborts: unknown[] = [] const provider: SubagentProvider = { name: 'doomed', capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: false }, inheritsParentContext: false, - start: (request) => { - request.signal?.addEventListener('abort', () => { - signalAborts.push(request.signal?.reason) + start: async (request) => { + request.signal.addEventListener('abort', () => { + signalAborts.push(request.signal.reason) // The death claim precedes the shared-signal fanout. This // synchronous callback cannot turn death into cancellation. handle.cancel('reentered from worker-death signal cleanup') }, { once: true }) return { id: AgentId('doomed-child'), - started: Promise.resolve(), result: new Promise(() => { /* never settles; the reap is the teardown */ }), - cancel: (reason?: string) => { - cancelled.push(reason ?? 'cancelled') - // Exercise the later microtask case too: terminal ownership - // remains closed after the death callback returns. - queueMicrotask(() => { handle.cancel('reentered from worker-death child cleanup') }) - }, dispose: () => Promise.reject(new Error('dispose exploded during reap')), } }, @@ -1521,7 +1101,6 @@ describe('dsh-workflow-workerthread', () => { // cold-start race; tight explicit bound (see the helper's doc comment). await waitFor(() => { expect(signalAborts).toEqual(['workflow worker gone']) - expect(cancelled).toEqual(['workflow worker gone']) }, 1000) await Promise.resolve() expect(result.stopReason).toBe('error') @@ -1647,14 +1226,14 @@ describe('dsh-workflow-workerthread', () => { }) describe('service surface', () => { - it('run ids are unique per start; the run handle and event payloads hold SEPARATE meta clones', async () => { + it('run ids are unique and lifecycle meta is the run\'s borrowed immutable value', async () => { const { ctx, parent } = await setup() let eventMeta: WorkflowRunInfo | undefined ctx.on('workflow/start', (info) => { eventMeta = info }) const first = ctx.workflows.start({ ...scripted('return 1'), parent }) const second = ctx.workflows.start({ ...scripted('return 2'), parent }) expect(first.id).not.toBe(second.id) - eventMeta!.meta.name = 'corrupted' + expect(eventMeta!.meta).toBe(second.meta) expect(second.meta.name).toBe('test-flow') await Promise.all([first.result, second.result]) await first.dispose() diff --git a/packages/workflow/workflow/README.md b/packages/workflow/workflow/README.md index 5332243a85..7caf169260 100644 --- a/packages/workflow/workflow/README.md +++ b/packages/workflow/workflow/README.md @@ -1,29 +1,43 @@ # @deepseek-ai/dsh-workflow -The **workflow seam** (`ctx.workflows`): an abstract service defining WHAT a workflow engine does — execute a model-written orchestration script that fans out subagents — without saying HOW. The bash-shaped third of the [workflow family](../README.md): implementations subclass `WorkflowService` and register as the `workflows` service (one per context); [`dsh-workflow-workerthread`](../workflow-workerthread/README.md) (one worker thread per run) is the implementation, and [`dsh-tool-workflow`](../tool-workflow/README.md) is the model-facing consumer. +The workflow seam (`ctx.workflows`) executes a model-written orchestration script that can fan out subagents. The seam defines the script, run, result, error, and event contracts; an engine decides how to isolate and execute the script. -## Service: `WorkflowService` (abstract) +`@deepseek-ai/dsh-workflow-workerthread` is the current engine and `@deepseek-ai/dsh-tool-workflow` is the model-facing consumer. A future process or sandbox engine can replace the implementation without changing the tool. -`start(request: WorkflowStartRequest): WorkflowRun` — parse and execute a script. Throws synchronously (`SCRIPT_PARSE`/`META_INVALID`) for a script that cannot begin; once a run is returned, its `result` NEVER rejects — every failure resolves with `stopReason: 'error'` (or `'cancelled'`) — and once the run is cancelled, `result` settles within the implementation's bounded grace even if the script itself never settles (a consumer awaiting `result` must never be wedged past a cancellation). `dispose()` must reach quiescence within a bounded grace (cancel → wait for the script to settle and its children to finish disposing → abandon), never hanging its caller. Runs are HOLDER-owned: the engine does not track its live runs, so disposing the engine's fiber mid-run leaves each run to its holder's teardown. +## Service and run contract -The protected `emitWorkflowEvent` helper dispatches the `workflow/*` events with PER-LISTENER containment and PER-LISTENER payload snapshots (a throwing subscriber is logged, never propagated, and cannot starve later listeners; each subscriber gets its own clone of the payload, so mutating it corrupts neither the engine nor other listeners) — the same containment guarantee as the subagent seam's lifecycle emits. +`WorkflowService.start(request): WorkflowRun` validates enough synchronously to reject a malformed meta block or unparseable script before a run exists. Once returned, `WorkflowRun.result` never rejects: execution failures resolve with `stopReason: 'error'`, and cancellation resolves with `cancelled` within the engine's bounded grace. -## Vocabulary +A run is holder-owned. Engine-plugin unload prevents new starts but does not revoke accepted runs. The holder must call `dispose()` on every path; disposal cancels remaining work and reaches or abandons quiescence within the documented bound. -- `WorkflowStartRequest` — `{ script, args?, parent: Agent, signal? }`. `parent` is REQUIRED: every child the script spawns is attributed to it. `args` must be plain host-realm JSON data. -- `WorkflowMeta` / `WorkflowPhase` — the workflow's identity block, carried as plain JSON data on the start request (Claude Code meta vocabulary: required `name`/`description`, optional `whenToUse`/`phases`) and shape-validated by the engine. -- `WorkflowRun` — `{ id, meta, result, cancel(reason?), dispose() }`; the consumer awaits `result` and MUST `dispose` on every path. -- `WorkflowResult` — `{ value, stopReason: 'completed'|'cancelled'|'error', error?, agentsStarted }`; `value` is the script's materialized return (plain JSON data; `null` for no return). -- `WorkflowError` — `HarnessError` with a `WorkflowErrorCode` and a `fatal` flag driving the combinator discipline: a fatal error (bad hook arguments, unsupported options/schemas, tripped caps, seam start failures, cancellation) always propagates through `parallel()`/`pipeline()` instead of dissolving into a per-item `null`. `isFatalWorkflowError(error)` is the catch-site predicate. +`WorkflowStartRequest` contains `{ meta, script, args?, parent, signal? }`. `parent` attributes every child agent to the invoking agent. `meta` and `args` are plain data, not script fragments. + +`WorkflowRun` exposes `{ id, meta, result, cancel(reason?), dispose() }`. `WorkflowResult` contains `{ value, stopReason, error?, agentsStarted }`; `value` is plain JSON data or `null`. ## Events -All observe-only emits carrying DATA SNAPSHOTS (`WorkflowRunInfo` = id + meta) — never the live `WorkflowRun`, so a listener cannot gain `cancel`/`dispose`; control stays with the `start()` caller: +Workflow events are observe-only. They carry `WorkflowRunInfo` (`id` plus `meta`) rather than the live run, so listeners cannot acquire cancellation or disposal authority. -- `workflow/start`(info) / `workflow/end`(info, resultInfo) — run lifecycle; `resultInfo` deliberately omits the value. -- `workflow/phase`(info, title) / `workflow/log`(info, message) — script narration. -- `workflow/agent-start`(info, agent) / `workflow/agent-end`(info, agent + outcome) — ready-child lifecycle correlated by `seq`; the [generated event contract](../../../docs/cordis-catalog/events.md#workflowagent-start--emit) defines publication and pairing. +- `workflow/start` / `workflow/end` pair the run. +- `workflow/phase` and `workflow/log` expose script narration. +- `workflow/agent-start` / `workflow/agent-end` pair each child call by `seq`; a child whose async provider start rejects emits neither. -## Non-goals (this cut) +Same-process event payloads are borrowed immutable values. Every listener is independently contained: a synchronous throw or rejected returned promise is logged without starving peers or changing execution. -Background collection, journaling/resume, saved workflows, nested `workflow()`, token budgets — see the [RFC's deferred section](../../../docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md). +## Failure discipline + +`WorkflowError` carries a code and a `fatal` flag. Fatal errors always escape `parallel()` and `pipeline()` instead of becoming an ordinary per-item `null`: + +- `SCRIPT_PARSE` / `META_INVALID` — the workflow cannot start. +- `INVALID_ARGUMENT` / `UNSUPPORTED_OPTION` / `UNSUPPORTED_SCHEMA` — a hook call violates the engine contract. +- `AGENT_CAP` / `ITEM_CAP` — configured safety limits were exceeded. +- `AGENT_START` — the provider's async start rejected. +- `AGENT_RESULT` — a ready child's result rejected with an infrastructure fault. +- `RESULT_UNSERIALIZABLE` — a script/worker value is not plain JSON data. +- `CANCELLED` — cancellation owns the run and pending/future hooks reject. + +A child that resolves normally with a non-completed stop reason is not an infrastructure exception: `agent()` returns `null`, allowing the script to handle an ordinary child failure. + +## Non-goals + +Background collection, journaling/resume, saved workflows, nested `workflow()`, and token budgets are deferred. See the [dynamic-workflows RFC](../../../docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md). diff --git a/packages/workflow/workflow/src/index.ts b/packages/workflow/workflow/src/index.ts index 19f52b5d88..0fa0289b41 100644 --- a/packages/workflow/workflow/src/index.ts +++ b/packages/workflow/workflow/src/index.ts @@ -9,14 +9,12 @@ * separate-process sandbox) swap in without touching the model-facing tool * that consumes them (`@deepseek-ai/dsh-tool-workflow`). * - * The `workflow/*` lifecycle events are OBSERVE-ONLY data snapshots: they + * The `workflow/*` lifecycle events are OBSERVE-ONLY data: they * carry {@link WorkflowRunInfo} (id + meta), never the live {@link WorkflowRun} * — a listener must not gain `cancel`/`dispose`; control stays with the - * `start()` caller holding the run. Every emit is per-listener contained (a - * throwing subscriber is logged, never propagated) and every listener gets its - * own payload clone (mutating it corrupts nothing), so one bad observer can - * neither strand a live run, starve later listeners, nor poison another - * listener's view. + * `start()` caller holding the run. Same-process payloads are borrowed + * immutable values. Every listener is independently contained, so a throw or + * rejected promise can neither strand a run nor starve peers. * * @module @deepseek-ai/dsh-workflow */ @@ -78,7 +76,7 @@ declare module 'cordis' { /** * One `agent()` call established a ready child run. Paired with * {@link Events['workflow/agent-end']} by `agent.seq`. A call that never - * crosses the provider's publication/readiness boundary emits neither + * receives a ready run from the provider emits neither * event in this pair. * @param info - the run's identity snapshot. * @param agent - the call's sequence number, label, phase, and child id. @@ -131,11 +129,10 @@ export type WorkflowEventName = * - `UNSUPPORTED_SCHEMA` — an `agent()` schema outside the structured-output * subset (see dsh-tools). * - `AGENT_CAP` / `ITEM_CAP` — the run/agent caps tripped. - * - `AGENT_START` — synchronous subagent start or the provider's asynchronous - * publication/readiness boundary failed before cancellation took precedence. - * - `AGENT_RESULT` — a run whose readiness FULFILLED had its `result` REJECT: an - * infrastructure fault at the subagent seam, even if the rejection settled - * before readiness. This is distinct from a child that failed and resolved + * - `AGENT_START` — the provider's asynchronous start rejected before + * cancellation took precedence. + * - `AGENT_RESULT` — a ready run had its `result` REJECT: an infrastructure + * fault at the subagent seam. This is distinct from a child that failed and resolved * (which is the per-item `null`, never an error). * - `RESULT_UNSERIALIZABLE` — a value crossing the script/host value boundary * is not plain JSON data. @@ -198,8 +195,8 @@ export function isFatalWorkflowError(error: unknown): boolean { * `result` SETTLES within the implementation's bounded grace even if the * script itself never settles (a consumer awaiting `result` must never be * wedged past a cancellation). - * - The `workflow/*` events fire through {@link emitWorkflowEvent} (data - * snapshots, per-listener containment); `workflow/end` fires exactly once + * - The `workflow/*` events fire through {@link emitWorkflowEvent} (borrowed + * immutable data, per-listener containment); `workflow/end` fires exactly once * per started run, after `result` is settled or as it settles. * - `dispose()` reaches quiescence within a bounded grace: it cancels, waits * for the script to settle AND its started children to finish disposing, @@ -225,12 +222,9 @@ export abstract class WorkflowService extends Service { abstract start(request: WorkflowStartRequest): WorkflowRun /** - * Emit one `workflow/*` lifecycle event with PER-LISTENER containment and - * PER-LISTENER payload snapshots: each subscriber is dispatched individually - * with its OWN structural clone of the payload (the payloads are plain JSON - * data by the seam contract), so a listener mutating what it received can - * corrupt neither the engine's live state nor any other listener's or later - * event's view; a thrown listener is logged (never propagated — the logging + * Emit one `workflow/*` lifecycle event with per-listener containment. Each + * subscriber receives the same borrowed immutable payload; a throw or + * asynchronously rejected listener is logged (never propagated — the logging * itself is total, even for a thrown value whose own string coercion * throws), so one bad subscriber can neither fail the engine mid-run, * surface as an unhandled rejection on a detached settle hook, nor starve @@ -242,9 +236,10 @@ export abstract class WorkflowService extends Service { protected emitWorkflowEvent(name: WorkflowEventName, ...args: unknown[]): void { for (const callback of this.ctx.events.dispatch('emit', [name, ...args])) { try { - // The declared workflow/* signatures are all void-returning emits; the - // dispatch callback applies the payload tuple. - ;(callback as (...payload: unknown[]) => void)(...structuredClone(args)) + const returned: unknown = (callback as (...payload: unknown[]) => unknown)(...args) + void Promise.resolve(returned).catch((error: unknown) => { + this.ctx.logger.warn(`workflow: ${name} listener rejected: ${renderListenerError(error)}`) + }) } catch (error: unknown) { this.ctx.logger.warn(`workflow: ${name} listener threw: ${renderListenerError(error)}`) } diff --git a/packages/workflow/workflow/src/types.ts b/packages/workflow/workflow/src/types.ts index 66da4f1268..981a2da172 100644 --- a/packages/workflow/workflow/src/types.ts +++ b/packages/workflow/workflow/src/types.ts @@ -128,7 +128,7 @@ export interface WorkflowRun { dispose(): Promise } -/** Identifying detail for a run, carried by every `workflow/*` event (a data snapshot, never the live run). */ +/** Identifying detail for a run, carried by every `workflow/*` event as borrowed immutable data, never the live run. */ export interface WorkflowRunInfo { /** The run's id. */ id: WorkflowRunId diff --git a/packages/workflow/workflow/tests/workflow.spec.ts b/packages/workflow/workflow/tests/workflow.spec.ts index a983e5a2b3..0df3824071 100644 --- a/packages/workflow/workflow/tests/workflow.spec.ts +++ b/packages/workflow/workflow/tests/workflow.spec.ts @@ -66,26 +66,21 @@ describe('dsh-workflow (interface)', () => { ]) }) - it('gives each listener its OWN payload snapshot: mutation corrupts neither peers nor the caller', async () => { + it('contains an asynchronously rejected listener without starving peers', async () => { const ctx = new Context() await ctx.plugin(StubEngine) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => ctx.logger) const seen: string[] = [] - ctx.on('workflow/agent-start', (info, agent) => { - agent.label = 'HACKED' - info.meta.name = 'HACKED' - seen.push('mutator') - }) - ctx.on('workflow/agent-start', (info, agent) => { - seen.push(`${info.meta.name}/${agent.label}`) - }) + // 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('workflow/agent-start', async () => { throw new Error('async observer failed') }) + ctx.on('workflow/agent-start', (_info, agent) => { seen.push(agent.label) }) const engine = ctx.workflows as StubEngine - const info: WorkflowRunInfo = { id: WorkflowRunId('run-2'), meta: { name: 'w', description: 'd' } } const payload = { seq: 1, label: 'original', childId: 'c' } - engine.emit('workflow/agent-start', info, payload) - expect(seen).toEqual(['mutator', 'w/original']) - // The caller's own objects are pristine too — no listener ever saw them. - expect(info.meta.name).toBe('w') - expect(payload.label).toBe('original') + engine.emit('workflow/agent-start', INFO, payload) + await Promise.resolve() + expect(seen).toEqual(['original']) + expect(String(warn.mock.calls[0]![0])).toContain('listener rejected') }) it('contains a throwing listener PER LISTENER: later listeners still run, nothing propagates', async () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 575f2bbf9f..649a986385 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -408,9 +408,6 @@ importers: '@deepseek-ai/dsh-scope': specifier: workspace:^ version: link:../scope - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../session cordis: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) @@ -824,9 +821,6 @@ importers: '@deepseek-ai/dsh-scope': specifier: workspace:^ version: link:../../core/scope - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools @@ -1054,12 +1048,6 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session - '@deepseek-ai/dsh-system-prompt': - specifier: workspace:^ - version: link:../../core/system-prompt - '@deepseek-ai/dsh-tools': - specifier: workspace:^ - version: link:../../core/tools cordis: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)