fix(scope): close remaining ownership boundaries
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user