fix(scope): close remaining ownership boundaries

This commit is contained in:
Tianyi Cui
2026-07-12 03:51:55 +08:00
parent 3dca90261c
commit 36b8370027
79 changed files with 3957 additions and 817 deletions

View File

@@ -23,10 +23,9 @@ const emptyStop: StreamChunk[] = [{ type: 'finish', reason: { kind: 'stop' } }]
/**
* Drives the REAL fork backend with a real loop + scripted mock MODEL + the
* real dsh-invariants plugin. The invariants plugin re-replays a seeded child
* log on `session/created` (its freeze-check), so a malformed (unbalanced) fork
* seed makes these tests THROW — that is the regression guard for the
* completed-turn-prefix boundary.
* real dsh-invariants plugin. The plugin replays a seeded child log on
* `session/created`, so a malformed (unbalanced) fork seed makes these tests
* THROW — that is the regression guard for the completed-turn-prefix boundary.
*/
async function setup(script: Script) {
const ctx = new Context()

View File

@@ -8,7 +8,7 @@ The shared **in-process subagent run driver**. A library with no provider or imp
Runs a child as a child [`Agent`](../../core/agent) on the same cordis context (`ctx.agents`):
1. snapshots the accepted request before asynchronous owner setup: the parent and signal remain identity capabilities but are never reread from the caller-owned record; tool filter, seed, agent options, output schema, and prompt are detached. It computes child depth = `depthOf(parent) + 1` and rejects `request.maxDepth` overflow with `SubagentDepthError`; `outputSchema` is asserted before cloning so a hostile value fails as `OutputSchemaError`, while the prompt passes the session log's lossless-JSON check before and after cloning;
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 computes child depth = `depthOf(parent) + 1`, rejects `request.maxDepth` overflow 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, and manual `run.dispose()` all dispose this exact node, preventing publication after it becomes inactive and awaiting the same quiescence boundary. `startInProcessRun` still returns its `SubagentRun` immediately: `run.started` resolves only after `ctx.agents.create()` has published the child (and rejects if publication never happens), while cancellation during creation is recorded and applied when a child exists;
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).

View File

@@ -18,9 +18,9 @@
import { randomUUID } from 'node:crypto'
import type { Context, Fiber } from 'cordis'
import { AgentId, type Agent, type AgentHandle, type AgentOptions } from '@deepseek-ai/dsh-agent'
import { SessionId, isJsonValue, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
import { SessionId, snapshotJsonValue, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { assertSupportedOutputSchema } from '@deepseek-ai/dsh-tools'
import { assertSupportedOutputSchema, OutputSchemaError } from '@deepseek-ai/dsh-tools'
import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent'
import {
attachStructuredRuntime,
@@ -125,59 +125,74 @@ export function startInProcessRun(
request: SubagentStartRequest,
options: InProcessRunOptions,
): SubagentRun {
// Snapshot the accepted request synchronously. The parent and signal are
// identity capabilities (kept live but never reread from the mutable request
// record); every data field is detached before asynchronous owner setup.
// Capture every top-level field once. Parent/signal are identity capabilities;
// every data value is materialized below before asynchronous owner setup.
const parent = request.parent
const signal = request.signal
const persona = request.persona
const toolFilter = request.toolFilter === undefined ? undefined : structuredClone(request.toolFilter)
const seed = options.seed === undefined ? undefined : structuredClone(options.seed)
const inputToolFilter = request.toolFilter
const inputMaxDepth = request.maxDepth
const inputSchema = request.outputSchema
const inputPrompt = request.prompt
const inputAgentOptions = request.agentOptions
const inputSeed = options.seed
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 (request.maxDepth !== undefined && childDepth > request.maxDepth) {
throw new SubagentDepthError(childDepth, request.maxDepth)
if (inputMaxDepth !== undefined && childDepth > inputMaxDepth) {
throw new SubagentDepthError(childDepth, inputMaxDepth)
}
// Assert, then snapshot, the schema subset BEFORE any child exists (the
// service has already capability-gated; this rejects a schema outside the
// enforced subset loud). Assertion comes FIRST so a hostile value fails as
// OutputSchemaError, never as structuredClone's raw DataCloneError — the
// asserted subset is plain JSON data, which always clones. The snapshot is
// load-bearing: the caller keeps its reference, so attaching the ORIGINAL
// would let a post-start() mutation drift the enforced schema away from the
// asserted one — the clone (taken synchronously with the assertion, no
// interleaving possible) pins assertion, the model-visible parameters, and
// validateStructuredValue to one isolation-immutable value.
if (request.outputSchema !== undefined) assertSupportedOutputSchema(request.outputSchema)
const schema = request.outputSchema === undefined ? undefined : structuredClone(request.outputSchema)
const 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. Validate the same lossless-JSON contract Session.append
// enforces before any child exists, then detach it synchronously so mutation
// during async creation cannot change what is logged or sent to the model.
if (!isJsonValue(request.prompt)) {
// 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')
}
const prompt = structuredClone(request.prompt)
if (!isJsonValue(prompt)) {
throw new TypeError('subagent prompt must be stable losslessly JSON-serializable data')
}
const childId = AgentId(randomUUID())
// The child's OWN events begin after the seed (fork seeds the parent's
// completed-turn prefix; spawn seeds nothing). `readResult` scopes to this
// boundary so a child that produces no message of its own never returns the
// SEEDED parent's last assistant message as its result.
const seedLength = options.seed?.length ?? 0
const seedLength = 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 agentOptions: AgentOptions = structuredClone({
...parent.options.model !== undefined ? { model: parent.options.model } : {},
...request.agentOptions,
const parentModel = parent.options.model
const agentOptions = snapshotJsonValue<AgentOptions>({
...parentModel !== undefined ? { model: parentModel } : {},
...requestedAgentOptions,
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

View File

@@ -79,8 +79,8 @@ 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 isolation-cloned, already-asserted schema subset to
* enforce (see `assertSupportedOutputSchema` in dsh-tools).
* @param schema - the detached, already-asserted schema subset to enforce (see
* `assertSupportedOutputSchema` in dsh-tools).
* @returns the attachment handle (read `captured()` after the child settles).
*/
export function attachStructuredRuntime(childCtx: Context, schema: StructuredOutputSchema): StructuredAttachment {

View File

@@ -1,15 +1,15 @@
import { describe, expect, it, vi } from 'vitest'
import { Context, type Fiber } from 'cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import SessionStore, { type SessionEvent } 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 from '@deepseek-ai/dsh-subagent'
import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import { depthOf, SubagentDepthError, startInProcessRun } from '../src/index.ts'
import { depthOf, type InProcessRunOptions, SubagentDepthError, startInProcessRun } from '../src/index.ts'
type Script = ConstructorParameters<typeof MockAdapter>[0]
@@ -57,7 +57,7 @@ describe('startInProcessRun', () => {
}, {})).toThrow('subagent prompt must be losslessly JSON-serializable')
})
it('rejects a prompt whose getter becomes non-JSON while it is snapshotted', async () => {
it('reads each prompt value once before asynchronous child creation', async () => {
const { ctx, parent } = await setup([])
let reads = 0
const prompt = [{
@@ -68,9 +68,91 @@ describe('startInProcessRun', () => {
},
}]
expect(() => startInProcessRun(ctx, { prompt, parent }, {}))
.toThrow('subagent prompt must be stable losslessly JSON-serializable data')
expect(reads).toBe(2)
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<SubagentStartRequest['outputSchema']>,
},
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 () => {

View File

@@ -21,7 +21,7 @@ Unlike the bash seam (one executor per context, second load throws), **multiple
| `registerProvider(provider)` | Register a frozen acceptance snapshot under `provider.name`; later caller mutation cannot change registry behavior or HMR cleanup, while `start` stays bound to the original provider receiver. Throws `SubagentError('DUPLICATE_PROVIDER')` on a name clash. Effect-scoped (HMR-safe); returns the disposer. |
| `getProvider(name)` | Look up the frozen registry snapshot (`undefined` if absent). |
| `list()` | Registered provider names (insertion order). |
| `start(name, request)` | Resolve the provider (`NO_PROVIDER` if absent), validate every requested START-TIME capability (`UNSUPPORTED_CAPABILITY` for the first unmet one — before any child is created), then delegate to `provider.start`. Emit `subagent/start` only after `run.started` fulfills and the paired `subagent/end` after that started run settles; a pre-publication readiness rejection emits neither. |
| `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`. Return a frozen service-owned run wrapper whose provider 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. 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. |
## Capabilities: two kinds, discovered two ways
@@ -32,12 +32,12 @@ Beside `capabilities` sits one DESCRIPTIVE fact, not validated by the service: `
## Run lifecycle
`provider.start(request)` returns a `SubagentRun`: a handle with `started` (the publication/readiness promise), `result` (the terminal outcome), `cancel()`, `dispose()`, and the optional runtime methods. `started` resolves only after the provider has established a real child and rejects if the attempt fails or is cancelled first. `result` resolves with a `SubagentResult` (`output`, optional `structured`, `stopReason`) — it does **not** reject on a child-level failure (a model/transport failure resolves with `stopReason: 'error'`), so the consumer maps a non-`completed` reason to an `isError` tool result. The consumer MUST `dispose()` on every path (success, error, abort) to reach child quiescence and avoid leaking an idle child / session.
`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.
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: `subagent/start` (payload `SubagentRunInfo`) fires only after `run.started` fulfills, and `subagent/end` (payload `SubagentRunEndInfo`) fires only for that announced run; 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. The service observes `result` immediately even while readiness is pending, clones its output before the caller can mutate it, and buffers that end payload until start has fired; a rejecting result cannot become an unhandled detached promise, start always precedes end, and a listener cannot corrupt the caller's result. `subagent/end` carries the cloned output as `lastAssistantMessage` on the settle path and omits it on infrastructure rejection. Any run-affecting decision is out of scope for this observe-only surface.
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. 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.
## Scope (first cut)
The consumer collects **synchronously**: it starts a run and awaits `result`. Steering (`sendMessage`) is part of the contract but intentionally unused. Background / poll / spill semantics are deferred to a future redesign unifying long-running-tool handling across subagents and bash. See the RFC: [docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md](../../../docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md).
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).
See `src/types.ts` for the full contracts.

View File

@@ -25,6 +25,7 @@
"@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"
},
@@ -32,6 +33,7 @@
"@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"
}

View File

@@ -34,10 +34,11 @@
import { Context, Service } from 'cordis'
import { scopeTarget } from '@deepseek-ai/dsh-scope'
import { assertSupportedOutputSchema } from '@deepseek-ai/dsh-tools'
import { assertSupportedOutputSchema, OutputSchemaError } from '@deepseek-ai/dsh-tools'
import type { Scoped } from '@deepseek-ai/dsh-scope'
import { HarnessError } from '@deepseek-ai/dsh-llm'
import { deepFreeze, 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,
@@ -115,7 +116,7 @@ declare module 'cordis' {
}
}
/** Identifying detail for a started subagent run (the `subagent/start` payload). */
/** Deep-frozen, observe-only identifying detail for a started subagent run. */
export interface SubagentRunInfo {
/** The provider that started the run. */
provider: string
@@ -123,7 +124,7 @@ export interface SubagentRunInfo {
id: AgentId
}
/** Outcome detail for a settled subagent run (the `subagent/end` payload). */
/** Deep-frozen, observe-only outcome detail for a settled subagent run. */
export interface SubagentRunEndInfo {
/** The provider that ran it. */
provider: string
@@ -186,11 +187,12 @@ export class SubagentService extends Service {
// 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 inputCapabilities = provider.capabilities
const capabilities: SubagentCapabilities = Object.freeze({
outputSchema: provider.capabilities.outputSchema,
depthLimit: provider.capabilities.depthLimit,
toolFilter: provider.capabilities.toolFilter,
persona: provider.capabilities.persona,
outputSchema: inputCapabilities.outputSchema,
depthLimit: inputCapabilities.depthLimit,
toolFilter: inputCapabilities.toolFilter,
persona: inputCapabilities.persona,
})
const snapshot: SubagentProvider = Object.freeze({
name: provider.name,
@@ -244,10 +246,16 @@ export class SubagentService extends Service {
/**
* Start a subagent run on the named provider. Resolves the provider (throws
* `NO_PROVIDER` if absent), validates every requested START-TIME capability
* `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
* delegates to {@link SubagentProvider.start}, then emits `subagent/start` /
* 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. 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.
@@ -255,32 +263,94 @@ export class SubagentService extends Service {
* @returns the live run (its `result` resolves when the child settles).
*/
start(name: string, request: SubagentStartRequest): SubagentRun {
// Parent is the lifecycle scope identity accepted at start. Never reread it
// from the caller-owned request after the provider/result async boundary,
// or start/end could be dispatched into different agent scopes.
const parent = request.parent
const provider = this.providers.get(name)
if (!provider) {
throw new SubagentError(`no subagent provider registered for "${name}"`, 'NO_PROVIDER')
}
this.assertCapabilities(provider, request)
if (request.outputSchema !== undefined) assertSupportedOutputSchema(request.outputSchema)
// 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)
if (input.maxDepth !== undefined && (
!Number.isSafeInteger(input.maxDepth)
|| input.maxDepth < 0
|| Object.is(input.maxDepth, -0)
)) {
throw new TypeError('subagent maxDepth must be a non-negative safe integer')
}
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')
}
// 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: structuredClone(request.prompt),
prompt,
parent,
...request.signal !== undefined ? { signal: request.signal } : {},
...request.agentOptions !== undefined ? { agentOptions: structuredClone(request.agentOptions) } : {},
...request.outputSchema !== undefined ? { outputSchema: structuredClone(request.outputSchema) } : {},
...request.maxDepth !== undefined ? { maxDepth: request.maxDepth } : {},
...request.toolFilter !== undefined ? { toolFilter: structuredClone(request.toolFilter) } : {},
...request.persona !== undefined ? { persona: request.persona } : {},
...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 run = provider.start(accepted)
const providerRun = provider.start(accepted)
// 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.
const id = providerRun.id
const started = providerRun.started
const providerResult = providerRun.result
const cancel = providerRun.cancel.bind(providerRun)
const sendMessage = providerRun.sendMessage?.bind(providerRun)
const dispose = providerRun.dispose.bind(providerRun)
const resume = providerRun.resume?.bind(providerRun)
const result = providerResult.then(value => this.snapshotRunResult(value))
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
@@ -296,26 +366,16 @@ export class SubagentService extends Service {
// remains observable by the run's consumer, but telemetry must not claim
// that a child started.
}
void run.result.then(
(result) => {
// Snapshot before the caller's own `await run.result` continuation. Even
// when readiness is still pending, buffering the clone rather than the
// caller-owned result keeps the eventual observe-only event immutable
// with respect to consumer mutation.
let lastAssistantMessage: SubagentResult['output'] | undefined
try {
lastAssistantMessage = structuredClone(result.output)
} catch (error: unknown) {
this.ctx.logger.warn(`subagent: could not clone ${name} output for subagent/end: ${String(error)}`)
}
void result.then(
(value) => {
deliverEnd({
provider: name,
id: run.id,
stopReason: result.stopReason,
...lastAssistantMessage !== undefined ? { lastAssistantMessage } : {},
id,
stopReason: value.stopReason,
lastAssistantMessage: value.output,
})
},
() => { deliverEnd({ provider: name, id: run.id, stopReason: 'error' }) },
() => { deliverEnd({ provider: name, id, stopReason: 'error' }) },
)
// Readiness is the publication boundary owned by the provider. For
@@ -324,10 +384,10 @@ export class SubagentService extends Service {
// 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 run.started.then(
void started.then(
() => {
readiness = 'started'
this.emitLifecycle('subagent/start', { provider: name, id: run.id }, parent)
this.emitLifecycle('subagent/start', { provider: name, id }, parent)
if (pendingEnd !== undefined) {
const info = pendingEnd
pendingEnd = undefined
@@ -342,6 +402,54 @@ export class SubagentService extends Service {
return run
}
/** 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<SubagentStartRequest> {
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 } : {},
})
}
/**
* Emit a `subagent/*` lifecycle event with PER-LISTENER containment: dispatch
* each subscriber individually and log (never propagate) a thrown one, so one
@@ -374,14 +482,15 @@ export class SubagentService extends Service {
// 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, info]
: [scopeTarget(this, parent), name, info]
? [name, acceptedInfo]
: [scopeTarget(this, parent), name, acceptedInfo]
for (const callback of this.ctx.events.dispatch('emit', dispatchArgs)) {
try {
callback(info)
callback(acceptedInfo)
} catch (error: unknown) {
this.ctx.logger.warn(`subagent: ${name} listener threw: ${String(error)}`)
this.ctx.logger.warn(`subagent: ${name} listener threw: ${renderThrown(error)}`)
}
}
}
@@ -409,4 +518,13 @@ export class SubagentService extends Service {
}
}
/** Render an arbitrary thrown value without allowing coercion to throw again. */
function renderThrown(value: unknown): string {
try {
return value instanceof Error ? `${value.name}: ${value.message}` : String(value)
} catch {
return '<unrenderable thrown value>'
}
}
export default SubagentService

View File

@@ -173,6 +173,16 @@ describe('SubagentService', () => {
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))
@@ -184,6 +194,7 @@ describe('SubagentService', () => {
pluginCtx.subagents.registerProvider(provider)
},
})
expect(capabilityReads).toBe(1)
const accepted = ctx.subagents.getProvider('stable')
const mutable = provider as unknown as {
@@ -216,7 +227,10 @@ describe('SubagentService', () => {
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'] },
@@ -277,6 +291,142 @@ describe('SubagentService', () => {
ctx.subagents.start('strong', baseRequest({ outputSchema: { type: 'object', properties: { x: { type: 'string' } } }, maxDepth: 1 }))
expect(provider.startCount).toBe(1)
})
it.each([
{ label: 'NaN', value: Number.NaN },
{ label: 'a fraction', value: 1.5 },
{ label: 'a negative integer', value: -1 },
{ label: 'negative zero', value: -0 },
])('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('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'
}
expect(() => ctx.subagents.start('prompt-boundary', baseRequest({
prompt: [new ExoticTextBlock()] as unknown as SubagentStartRequest['prompt'],
}))).toThrow('subagent prompt must be losslessly JSON-serializable')
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<SubagentStartRequest['outputSchema']>,
},
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)
expect(provider.startCount).toBe(0)
})
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 () => {
@@ -299,6 +449,165 @@ describe('SubagentService', () => {
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<void> {
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('waits for provider readiness and observes an early result rejection without reordering lifecycle', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
@@ -428,12 +737,13 @@ describe('SubagentService', () => {
}))
})
it('observe-only: a subagent/end listener mutating lastAssistantMessage cannot corrupt the caller\'s result', async () => {
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. The service
// deep-clones output onto the event, so the listener mutates only its copy.
// 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(
@@ -448,12 +758,22 @@ describe('SubagentService', () => {
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's result.output is untouched by the listener's mutation.
// 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<string, unknown>
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 () => {
@@ -483,17 +803,14 @@ describe('SubagentService', () => {
expect('lastAssistantMessage' in endInfo).toBe(false) // no output exists on reject
})
it('contains a structuredClone failure: emits subagent/end without lastAssistantMessage (no unhandled rejection)', async () => {
// The clone runs inside onFulfilled, OUTSIDE emitLifecycle's per-listener
// containment. An uncloneable output (here a content block carrying a
// function) would otherwise throw and become an unhandled rejection on the
// detached `.then`. The handler must instead log and emit the event WITHOUT
// lastAssistantMessage, still carrying the real stopReason.
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 warn = vi.fn(); ctx.logger.warn = warn as never
// An output value structuredClone cannot handle (a function is uncloneable).
const uncloneable = [{ type: 'text', text: 'x', evil: () => 0 }] as unknown as SubagentResult['output']
const nonJsonOutput = [{ type: 'text', text: 'x', evil: () => 0 }] as unknown as SubagentResult['output']
ctx.subagents.registerProvider({
name: 'unclone',
capabilities: NO_CAPS,
@@ -501,7 +818,7 @@ describe('SubagentService', () => {
start: () => ({
id: AgentId('unclone-child'),
started: Promise.resolve(),
result: Promise.resolve({ output: uncloneable, stopReason: 'completed' } as SubagentResult),
result: Promise.resolve({ output: nonJsonOutput, stopReason: 'completed' } as SubagentResult),
cancel() {},
dispose: async () => {},
}),
@@ -510,13 +827,52 @@ describe('SubagentService', () => {
const ended = vi.fn()
ctx.on('subagent/end', ended)
const run = ctx.subagents.start('unclone', baseRequest())
await run.result
await expect(run.result).rejects.toThrow('subagent result must be losslessly JSON-serializable')
await Promise.resolve()
const endInfo = ended.mock.calls[0]![0] as Record<string, unknown>
expect(endInfo.stopReason).toBe('completed') // the real outcome is preserved
expect('lastAssistantMessage' in endInfo).toBe(false) // clone failed → omitted, not crashed
expect(warn).toHaveBeenCalledWith(expect.stringContaining('could not clone'))
expect(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 () => {
@@ -566,6 +922,60 @@ describe('SubagentService', () => {
await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' })
})
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()
expect(warnings.some(message => message.includes('<unrenderable thrown value>'))).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)

View File

@@ -17,6 +17,9 @@
{
"path": "../../core/agent"
},
{
"path": "../../core/session"
},
{
"path": "../../llm/llm"
},