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

@@ -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 () => {