fix(scope): close remaining ownership boundaries
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user