fix(core): enforce agent-scoped ownership boundaries

This commit is contained in:
Tianyi Cui
2026-07-11 22:55:26 +08:00
parent 850796bb35
commit 3263dab822
62 changed files with 3982 additions and 857 deletions
+153 -74
View File
@@ -7,16 +7,18 @@
* a prefix of the parent's log); everything downstream — drive the child, read
* its final output, map the stop reason, dispose — is identical and lives here.
*
* This package owns no provider and registers nothing; it is a pure library the
* backend packages depend on, so neither backend needs to know about the other.
* This package declares no provider and performs no import-time registration;
* it is a library the backend packages depend on, so neither backend needs to
* know about the other. Each accepted run does install one provider-owned
* effect for structured-concurrency cleanup.
*
* @module @deepseek-ai/dsh-subagent-inprocess
*/
import { randomUUID } from 'node:crypto'
import type { Context } from 'cordis'
import type { Context, Fiber } from 'cordis'
import { AgentId, type Agent, type AgentHandle, type AgentOptions } from '@deepseek-ai/dsh-agent'
import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
import { SessionId, isJsonValue, 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 type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent'
@@ -86,8 +88,6 @@ function toStopReason(reason: TurnEndReason | undefined): SubagentStopReason {
/** Extra inputs the spawn/fork backends supply to {@link startInProcessRun}. */
export interface InProcessRunOptions {
/** The provider name (`spawn`/`fork`), for error context only. */
readonly providerName: string
/**
* The child session's seed: a balanced, contiguous-from-0 prefix of the
* parent's log (FORK), or `undefined` for a fresh child (SPAWN).
@@ -95,6 +95,12 @@ export interface InProcessRunOptions {
readonly seed?: SessionEvent[]
}
/** Dispose a run-owner fiber and follow an already-started unload to quiescence. */
async function quiesceFiber(fiber: Fiber): Promise<void> {
await Promise.resolve(fiber.dispose())
while (fiber.inertia !== undefined) await fiber.inertia
}
/**
* Start an in-process child agent for `request` and return a {@link SubagentRun}.
*
@@ -108,9 +114,10 @@ export interface InProcessRunOptions {
*
* Throws {@link SubagentDepthError} before creating anything when the child's
* depth (parent depth + 1) would exceed `request.maxDepth`.
* @param ctx - the context whose `agents` factory creates and owns the child.
* @param ctx - the provider context that owns the live run as a second
* structured-concurrency boundary alongside the parent agent.
* @param request - the start request (prompt, parent, signal, per-child options).
* @param options - the backend's inputs: provider name plus the optional seed.
* @param options - the backend's optional child-session seed.
* @returns the live run handle for the child agent.
*/
export function startInProcessRun(
@@ -118,7 +125,15 @@ export function startInProcessRun(
request: SubagentStartRequest,
options: InProcessRunOptions,
): SubagentRun {
const childDepth = depthOf(request.parent) + 1
// 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.
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 childDepth = depthOf(parent) + 1
if (request.maxDepth !== undefined && childDepth > request.maxDepth) {
throw new SubagentDepthError(childDepth, request.maxDepth)
}
@@ -134,6 +149,17 @@ export function startInProcessRun(
// validateStructuredValue to one isolation-immutable value.
if (request.outputSchema !== undefined) assertSupportedOutputSchema(request.outputSchema)
const schema = request.outputSchema === undefined ? undefined : structuredClone(request.outputSchema)
// 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)) {
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
@@ -141,76 +167,43 @@ export function startInProcessRun(
// 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 parentHeader = request.parent.session.header
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 = {
...request.parent.options.model !== undefined ? { model: request.parent.options.model } : {},
const agentOptions: AgentOptions = structuredClone({
...parent.options.model !== undefined ? { model: parent.options.model } : {},
...request.agentOptions,
subagentDepth: childDepth,
}
})
// The child's scoped world, composed in the factory's setup window (after
// the child's scope exists and it is registered, before agent/session-start
// and the first prompt assembly; a throw here unwinds the half-created
// child inside the factory's rollback boundary):
// The child's scoped world, composed in the factory's unpublished setup
// window. The factory awaits it before inserting or announcing the child, so
// a throw/rejection exposes neither id and every first assembly sees it:
// - persona: a scoped `deployment:persona` section shadowing the global one;
// - toolFilter: a scoped restrict() masking the global tool surface
// (loud unknown-name validation lives in the registry);
// - outputSchema: the structured runtime, attached as scoped registrations.
let structured: StructuredAttachment | undefined
const setup = (childCtx: Context): void => {
if (request.persona !== undefined) {
childCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: request.persona })
if (persona !== undefined) {
childCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: persona })
}
if (request.toolFilter !== undefined) {
childCtx.tools.restrict(request.toolFilter)
if (toolFilter !== undefined) {
childCtx.tools.restrict(toolFilter)
}
if (schema !== undefined) {
structured = attachStructuredRuntime(childCtx, schema)
}
}
const handle: AgentHandle = ctx.agents.create({
agentId: childId,
sessionId: SessionId(randomUUID()),
meta: {
...parentHeader.cwd !== undefined ? { cwd: parentHeader.cwd } : {},
parentSession: parentHeader.id,
// Record the seed boundary so a reload (and a replay harness) can tell the
// inherited prefix from the child's OWN events. 0 for a fresh spawn.
...seedLength > 0 ? { seedLength } : {},
},
...options.seed !== undefined ? { seed: options.seed } : {},
agentOptions,
setup,
})
const child = handle.agent
// Structured-concurrency link: the child's teardown rides the PARENT's
// scope, so a disposed parent reaches its whole subtree even if the
// delegating tool's `finally` never runs — through the MEMOIZED handle, so
// every path (tool finally, parent teardown, owner unload) observes the
// same quiescence boundary. Registered AFTER the child exists; if the
// parent began disposing in between, the registration throws
// INACTIVE_EFFECT — dispose the fresh child before rethrowing (no orphan).
// Definite assignment: the catch rethrows, so past this block the unlink
// disposer always exists.
let unlink!: () => Promise<void> | void
try {
unlink = request.parent.ctx.effect(() => () => handle.dispose())
} catch (error: unknown) {
// Fire-and-forget: start() must rethrow synchronously; the child's
// teardown (stop → unregister → detach) reaches quiescence on its own.
void handle.dispose()
throw error
}
// Bridge the request's abort signal to the child (the consumer also bridges
// its own exec.signal, but a backend-level bridge keeps the contract local).
// Install it after provider ownership succeeds but BEFORE awaiting creation,
// so an inactive provider cannot leave an orphaned listener and abort/dispose
// during async setup is still recorded and applied the moment a child exists.
// `cancelled` records that a cancel was requested at all, so the pre-turn
// cancel window — where the child clears the queued prompt before any
// `turn/end` is logged — settles as `aborted` (honoring the cancel contract)
@@ -220,31 +213,104 @@ export function startInProcessRun(
// abort listener, run.cancel), which control-flow narrowing cannot see — an
// inline read at the result mapping would narrow to the initializer.
const isCancelled = (): boolean => cancelled
let child: Agent | undefined
let handle: AgentHandle | undefined
let disposeRequested = false
const isDisposeRequested = (): boolean => disposeRequested
const requestCancel = (reason: string): void => {
cancelled = true
child.cancel(reason)
child?.cancel(reason)
}
const onAbort = (): void => { requestCancel('subagent cancelled') }
request.signal?.addEventListener('abort', onAbort, { once: true })
// One run-owned Cordis fiber is the common ownership node. Install the
// provider effect FIRST: a start racing an already-unloading provider fails
// before it can mint anything under the parent. The owner fiber is then
// nested under the parent scope, and the provider/run handle both dispose
// this exact fiber. AgentFactory binds its lifecycle to `ownerCtx`, so any of
// the three owners moves the fiber out of ACTIVE synchronously and setup
// cannot publish afterward.
let ownerCtx: Context | undefined
function subagentRunOwner(inner: Context): void { ownerCtx = inner }
let ownerFiber: (Fiber & PromiseLike<Fiber>) | undefined
let ownerSetupError: unknown
let ownerDisposing: Promise<void> | undefined
const disposeOwner = (): Promise<void> => (ownerDisposing ??= ownerFiber === undefined
? Promise.resolve()
: quiesceFiber(ownerFiber))
let manualDisposeRequested = false
const isManualDisposeRequested = (): boolean => manualDisposeRequested
const unlinkProvider = ctx.effect(() => () => {
requestCancel('subagent provider disposed')
return disposeOwner()
}, 'subagent-inprocess.run()')
signal?.addEventListener('abort', onAbort, { once: true })
if (signal?.aborted) requestCancel('subagent cancelled')
try {
ownerFiber = parent.ctx.plugin(Object.assign(subagentRunOwner, {
inject: ['agents', 'sessions', 'llm', 'tools', 'systemPrompt'],
}))
} catch (error: unknown) {
ownerSetupError = error
}
const creation: Promise<Agent> = (async () => {
if (ownerSetupError !== undefined) {
throw ownerSetupError instanceof Error
? ownerSetupError
: new Error('subagent run owner setup failed with a non-Error value', { cause: ownerSetupError })
}
await ownerFiber
if (ownerCtx === undefined) {
throw new Error('subagent run owner became inactive before child creation')
}
// Invoke the factory THROUGH the parent scope. Cordis binds the factory's
// lifecycle effect to the accessing context, so parent ownership exists
// before persistence/setup and publication—not as a fallible link added
// after the child is already visible. A disposed parent therefore rejects
// before any session/agent notification, and disposal during async setup
// wins the unpublished transaction.
const created = await ownerCtx.agents.create({
agentId: childId,
sessionId: SessionId(randomUUID()),
meta: {
...parentHeader.cwd !== undefined ? { cwd: parentHeader.cwd } : {},
parentSession: parentHeader.id,
...seedLength > 0 ? { seedLength } : {},
},
...seed !== undefined ? { seed } : {},
agentOptions,
setup,
})
handle = created
child = created.agent
if (isCancelled()) created.agent.cancel('subagent cancelled')
return created.agent
})()
const result: Promise<SubagentResult> = (async () => {
try {
// A signal already aborted BEFORE the run starts never fires an `abort`
// event (`addEventListener` only fires on the transition), so the listener
// above won't catch it — settle `aborted` without running the child rather
// than completing an already-cancelled request.
if (request.signal?.aborted) return { output: [], stopReason: 'aborted' }
child.send(request.prompt)
await child.whenIdle()
let liveChild: Agent
try {
liveChild = await creation
} catch (error: unknown) {
if (isManualDisposeRequested()) return { output: [], stopReason: 'aborted' }
throw error instanceof Error ? error : new Error('subagent child creation failed with a non-Error value', { cause: error })
}
if (isCancelled() || isDisposeRequested()) return { output: [], stopReason: 'aborted' }
liveChild.send(prompt)
await liveChild.whenIdle()
// Deliberately NO re-prompt when a structured child finishes cleanly
// without calling structured_output: readResult maps that to `error` —
// the shortfall goes to the parent instead of buying extra model turns.
return readResult(child, seedLength, isCancelled(), structured ? { captured: structured.captured() } : undefined)
return readResult(liveChild, seedLength, isCancelled(), structured ? { captured: structured.captured() } : undefined)
} finally {
request.signal?.removeEventListener('abort', onAbort)
signal?.removeEventListener('abort', onAbort)
}
})()
let disposing: Promise<void> | undefined
return {
id: childId,
result,
@@ -252,13 +318,26 @@ export function startInProcessRun(
requestCancel(reason ?? 'subagent cancelled')
},
async dispose(): Promise<void> {
request.signal?.removeEventListener('abort', onAbort)
// Through the parent-scope unlink when the parent is still live (one
// disposal path, and the dead effect leaves the parent's list); the
// memoized handle keeps a direct dispose equivalent if the parent's
// teardown already ran the unlink.
await unlink()
await handle.dispose()
return (disposing ??= (async () => {
signal?.removeEventListener('abort', onAbort)
disposeRequested = true
manualDisposeRequested = true
requestCancel('subagent disposed during creation')
// Removing provider ownership and disposing the common run-owner fiber
// are the same quiescence transaction; parent disposal may already have
// claimed it, in which case disposeOwner follows fiber inertia.
await unlinkProvider()
try {
await creation
} catch {
// Creation rollback already reached quiescence; there is no handle
// left to dispose, and dispose must not mask result's infrastructure
// rejection with the same error from a finally block.
return
}
await disposeOwner()
await handle?.dispose()
})())
},
}
}
@@ -14,44 +14,39 @@
* a disposed child leaves no residue — no placeholder schema,
* strip-for-everyone-else pass, or refcounted global runtime.
*
* Four listeners enforce the contract:
* The child scope's registrations enforce the contract:
*
* - `system-prompt/assemble` (prepend, scoped): assembly re-assert — the
* listener post-processes its downstream chain so a listener inside that
* chain cannot leave the child's capture tool or instruction stripped or
* replaced. Tools are replaced in place and the section is re-inserted at
* its ascending-order position, so the untampered path keeps the registry's
* ordering (up to intra-band section order, which carries no contract). A
* listener prepended later can still wrap and transform this result; this is
* an ordinary waterfall listener, not a service-level finalizer. The loop
* logs the rendered assembly as the request header, so the demand is
* reconstructable log state, never a wire-only mutation.
* - `agent/turn-continuation` (prepend, scoped): stop the child's turn once
* its output is captured — the loop's default "had tool calls ⇒ continue"
* would buy a wasted extra model step per structured child.
* - `tools/pre-execute` (prepend, scoped): terminal means terminal WITHIN the
* step — deny every call arriving after the capture, so a response that
* lists `structured_output` before further tool calls cannot run side
* effects after the final answer was accepted.
* - `tools/post-execute` (prepend, scoped): the capture COMMIT. The tool body
* only STAGES the validated value, KEYED BY THE EXECUTION OBJECT in a
* WeakMap; it becomes the run's captured result when this listener's
* downstream post-execute decision accepts THAT SAME pipeline trip. A
* later-prepended wrapper remains outside that decision. Execution-keyed
* staging makes the stale-stage class structurally impossible: a value
* orphaned by an outer short-circuiting listener (a post-execute block, or
* a pre-execute deny whose call never dispatched) can never match another
* execution's lookup — whatever call id that execution carries — and is
* reclaimed with the execution object itself.
* - `systemPrompt.protect()` declaratively protects the capture tool and its
* instruction. The service restores their canonical pre-waterfall state
* after EVERY assembly listener. Canonical absence is protected too: pure
* Code Mode keeps `structured_output` in the SDK only and never grows a
* second native wire tool. Code Mode's owner independently protects its SDK
* and `run_code` transport. The loop logs the finalized assembly as the
* request header, so the demand is reconstructable log state, never a
* wire-only mutation.
* - `agent/turn-stop` (serial, scoped): stop the child's turn once its output
* is captured. This terminal checkpoint runs after the ordinary continuation
* waterfall and steering folding, so listener order cannot resurrect a
* completed structured run or carry terminal steering into another turn.
* - `tools.guard()` is the monotonic terminal gate after the extensible
* pre-execute waterfall: once capture commits, no later listener can turn
* the denial back into a dispatched side effect.
* - `tools/result` is the capture COMMIT point. The tool body only STAGES the
* validated value in a WeakMap keyed by the execution object; the awaited,
* non-transforming notification promotes it only when the authoritative
* result after the whole pre/execute/post pipeline succeeds. For a Code Mode
* sub-dispatch, promotion waits again for the enclosing `run_code` result, so
* a runtime failure or outer post-policy block cannot report structured
* success. Execution identity makes call-id reuse and orphaned stages
* irrelevant.
*
* @module @deepseek-ai/dsh-subagent-inprocess/structured
*/
import type { Context } from 'cordis'
import type { Agent, ContinuationDecision } from '@deepseek-ai/dsh-agent'
import type { ContinuationStop } from '@deepseek-ai/dsh-agent'
import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
import type { AssembleContext, PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
import { ToolArgsError, validateStructuredValue, type StructuredOutputSchema } from '@deepseek-ai/dsh-tools'
/** The model-facing tool name a structured child must call to finish. */
@@ -71,7 +66,7 @@ export const STRUCTURED_OUTPUT_INSTRUCTION
export interface StructuredAttachment {
/**
* The captured value, once the child called the tool with valid arguments
* and the final post-execute decision accepted that call.
* and the authoritative final tool result accepted that call.
* @returns the committed value, or undefined while none was accepted.
*/
captured(): { value: unknown } | undefined
@@ -80,7 +75,7 @@ export interface StructuredAttachment {
/**
* Attach the structured-output runtime to a child for `schema`: register the
* scoped capture tool (real schema), the scoped instruction section, and the
* four scoped enforcement listeners (see the module doc). Call from the
* scoped enforcement registrations (see the module doc). Call from the
* 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).
@@ -91,19 +86,16 @@ export interface StructuredAttachment {
export function attachStructuredRuntime(childCtx: Context, schema: StructuredOutputSchema): StructuredAttachment {
/**
* Validated values staged by the capture tool body, awaiting THEIR OWN
* call's post-execute verdict — keyed by the {@link ToolExecution} OBJECT,
* the one token that provably ties a stage to one trip through the
* pipeline. A call id cannot key this: ids are adapter-minted and may
* repeat across steps. Keying by execution makes the stale-stage class
* structurally impossible — an entry orphaned by an outer short-circuiting
* listener can never match a different execution's lookup, needs no drop
* bookkeeping (the WeakMap reclaims it with the execution object), and two
* in-flight captures can never cross-clobber each other's STAGE should
* tool execution ever go parallel (the loop's documented TODO). Staging is
* the only layer this future-proofs: a parallel-execution cut would still
* owe its own single-accept rule for `captured` itself.
* authoritative `tools/result` notification. The execution object's identity
* uniquely identifies a trip through the pipeline: adapter call ids may
* repeat across steps, but another execution can never reach this WeakMap
* entry. This is distinct from the opaque `ToolExecutionToken` used to
* correlate nested transports. The final notification always deletes its own
* stage, whether the result succeeded or failed.
*/
const staged = new WeakMap<ToolExecution, { value: unknown }>()
/** Successful nested capture waiting for its enclosing transport to commit. */
let pending: { parent: ToolExecution['token']; value: unknown } | undefined
let captured: { value: unknown } | undefined
const schemaEntry: ToolSchema = {
@@ -123,10 +115,10 @@ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOut
// ToolArgsError → isError result with INVALID_ARGS: the model retries
// within the same turn, exactly like a schema-validated defineTool call.
if (violations.length > 0) throw new ToolArgsError(violations)
// Two-phase commit, KEYED BY THIS EXECUTION: the body only stages; the
// post-execute listener promotes exactly this pipeline trip's entry
// when its downstream decision accepts it.
staged.set(exec, { value: args })
// Two-phase commit, keyed by THIS execution: later transformable
// waterfalls may still turn the success into an error. Snapshot the
// validated value independently of the already-frozen pipeline arguments.
staged.set(exec, { value: structuredClone(args) })
return Promise.resolve([{ type: 'text', text: 'Structured output recorded.' }])
},
})
@@ -137,105 +129,58 @@ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOut
text: STRUCTURED_OUTPUT_INSTRUCTION,
})
// PREPENDED assembly re-assert: scoped dispatch means this fires only for the
// child's assemblies; `await next()` returns whatever this listener's
// downstream chain produced, and the capture tool + instruction are
// re-asserted onto it if anything stripped them. A listener prepended later
// can still wrap and transform the returned assembly; this is not a
// service-level finalizer.
childCtx.on('system-prompt/assemble', async function (
this: unknown, _assembly: PromptAssembly, _context: AssembleContext, next: () => Promise<PromptAssembly>,
): Promise<PromptAssembly> {
const final = await next()
// REPLACE, not merely ensure-present: a downstream listener may have
// mutated or injected a same-named entry with the WRONG schema/text, and
// the model-visible demand must be exactly this run's own — the same
// schema validateStructuredValue enforces. Placement-preserving on both
// arrays: the untampered path keeps the registry's ordering (tool order
// is the `toolOrder`/lexicographic contract, section order the ascending
// contract `renderPrompt` trusts), so this never reorders what it only
// re-asserts — up to intra-band section order, which carries no contract
// (a 190-order section registered AFTER this runtime sorts before the
// instruction in the registry but after it here).
const freshTool: ToolSchema = { ...schemaEntry, parameters: structuredClone(schemaEntry.parameters) }
// Tools: replace the first same-named entry IN PLACE (its position is the
// chain's product; a tool's list position carries no semantic band to
// restore), drop any duplicates, append only when stripped entirely.
const tools: ToolSchema[] = []
let toolReplaced = false
for (const tool of final.tools) {
if (tool.name !== STRUCTURED_OUTPUT_TOOL) {
tools.push(tool)
} else if (!toolReplaced) {
tools.push(freshTool)
toolReplaced = true
// Service-owned finalization, not waterfall ordering. The canonical
// assembly determines both presence and absence: native/both modes restore
// the capture schema on the wire, while pure Code Mode removes any injected
// native entry. ToolRegistry's own protection independently restores the SDK
// section and run_code transport that carry the same schema.
childCtx.systemPrompt.protect({
sections: [`tool:${STRUCTURED_OUTPUT_TOOL}`],
tools: [STRUCTURED_OUTPUT_TOOL],
})
// Stop the child's turn once its output is captured. This monotonic serial
// checkpoint runs after the ordinary continuation waterfall, its reason,
// and late-steering folding, so no ordering trick can resume a finished run.
childCtx.on('agent/turn-stop', function (this: unknown): ContinuationStop | undefined {
return captured === undefined ? undefined : { action: 'stop' }
})
// Terminal WITHIN the step. Guards run after the whole pre-execute
// waterfall and compose monotonically (deny or abstain, never allow), so a
// later prepended listener cannot resurrect dispatch. Calls that precede
// capture in the same response remain untouched.
childCtx.tools.guard(exec => captured === undefined && pending === undefined
? undefined
: `structured output already recorded: the run is complete, so \`${exec.name}\` is not executed`)
// The capture COMMIT observes the immutable, authoritative result after the
// complete pipeline and outer error normalization. This notification cannot
// transform the outcome, so there is no wrapper outside the commit verdict.
childCtx.on('tools/result', function (this: unknown, exec, result): void {
if (exec.name === STRUCTURED_OUTPUT_TOOL) {
const entry = staged.get(exec)
if (entry === undefined) return
staged.delete(exec)
if (result.isError) return
if (exec.parent === undefined) {
/* v8 ignore else -- sequential agent-loop dispatch lets the guard block every later supported call */
if (captured === undefined) captured = { value: entry.value }
} else {
/* v8 ignore else -- Code Mode serializes sub-dispatches, so the guard blocks every later supported call */
if (captured === undefined && pending === undefined) {
pending = { parent: exec.parent, value: entry.value }
}
}
return
}
if (!toolReplaced) tools.push(freshTool)
final.tools = tools
// Sections: remove every same-named entry and re-insert at the
// ascending-correct position (the first entry above order 190) — sections
// DO carry an order contract, and the renderer reads array order, so a
// stripped-or-moved instruction is restored to its band, not appended
// after unrelated higher-order sections. On the untampered path this
// lands at the end of the 190 band — where the registry's stable sort
// put it too, unless another 190-order section registered later.
const sectionName = `tool:${STRUCTURED_OUTPUT_TOOL}`
const sections = final.sections.filter(section => section.name !== sectionName)
const insertAt = sections.findIndex(section => section.order > 190)
sections.splice(insertAt === -1 ? sections.length : insertAt, 0, { name: sectionName, order: 190, text: STRUCTURED_OUTPUT_INSTRUCTION })
final.sections = sections
return final
}, { prepend: true })
// Stop the child's turn once its output is captured. `prepend: true` puts
// the veto OUTERMOST — an earlier-registered listener that short-circuits
// the chain (a goal-style force-continue returning without `next()`) would
// otherwise decide the turn before this listener ever ran, and no
// downstream decision may resurrect a structured turn that is finished.
childCtx.on('agent/turn-continuation', function (
this: unknown, _agent: Agent, _turn: number, _decision: ContinuationDecision, next: () => Promise<ContinuationDecision>,
): Promise<ContinuationDecision> {
if (captured) return Promise.resolve({ action: 'stop' })
return next()
}, { prepend: true })
// Terminal WITHIN the step: deny every call after the capture. Calls that
// PRECEDE the capture in the same response ran before `captured` was set
// and are untouched; a second `structured_output` is denied like any other.
childCtx.on('tools/pre-execute', function (
this: unknown, exec: ToolExecution, next: () => Promise<PreToolDecision>,
): Promise<PreToolDecision> {
if (captured) {
return Promise.resolve({
kind: 'deny',
reason: `structured output already recorded: the run is complete, so \`${exec.name}\` is not executed`,
})
}
return next()
}, { prepend: true })
// The capture COMMIT: promote a staged value only when the final
// post-execute decision accepts THE SAME EXECUTION that staged it — the
// lookup key IS the execution, so a stale entry from a different pipeline
// trip (its own chain short-circuited past this commit by an outer
// post-execute block, or an outer pre-execute deny whose call never
// dispatched) is unreachable here by construction, whatever the current
// call's id.
childCtx.on('tools/post-execute', async function (
this: unknown, exec: ToolExecution, _result: ToolExecutionResult, next: () => Promise<PostToolDecision>,
): Promise<PostToolDecision> {
if (exec.name !== STRUCTURED_OUTPUT_TOOL) return next()
const entry = staged.get(exec)
if (entry === undefined) return next()
// Single-shot per execution: this trip's verdict is decided by the chain
// below, never revisited (the WeakMap would reclaim the entry either way;
// deleting states the intent).
staged.delete(exec)
const decision = await next()
if (decision.kind === 'accept') captured = { value: entry.value }
return decision
}, { prepend: true })
if (pending?.parent !== exec.token) return
const entry = pending
pending = undefined
if (result.isError) return
/* v8 ignore else -- Code Mode serializes outer executions, so the guard blocks every later supported call */
if (captured === undefined) captured = { value: entry.value }
})
return { captured: () => captured }
}