Files
deepseek-harness/packages/subagent/subagent-inprocess/src/structured.ts
Tianyi Cui db6aed0459 fix(subagent): key the structured stage by execution identity, not call id
Codex confirmation-round finding: an OUTERMOST prepend pre-execute deny
skips the runtime's own pre-execute clear, and the denied call still
reaches post-execute — so a reused adapter-minted call id could promote an
orphaned stage on the default accept path. The stage is now keyed by the
ToolExecution OBJECT identity, the one token that provably ties a stage to
one pipeline trip: only the execution whose own body staged can commit,
whatever any call id says. The pre-execute clear is gone (one mechanism);
the commit's mismatch drop is now the reachable primary guard. Repro test:
orphaned stage + outer pre-execute deny with the same call id never
promotes; a fresh valid call still captures.
2026-07-09 05:03:44 +08:00

207 lines
11 KiB
TypeScript

/**
* Structured-output support for the in-process subagent backends: the
* mechanism behind `SubagentStartRequest.outputSchema` for children that run
* as agents on the same context.
*
* Everything is a SCOPED registration on the child agent's context
* (`child.ctx`, the dsh-scope seam): the `structured_output` capture tool
* carries the run's REAL schema as its registered parameters (each child sees
* exactly its own schema — two concurrent structured runs never interact), the
* demand instruction is an ordinary order-190 scoped section, and the
* enforcement listeners fire only for this child (scope-filtered dispatch).
* Registration lifetime rides the child's fiber, so a backend hot-reload
* mid-run cannot unregister the capture tool out from under a live child, and
* a disposed child leaves no residue — no placeholder schema, no
* strip-for-everyone-else, no refcounted global runtime, no `WeakMap` state.
*
* Four listeners enforce the contract:
*
* - `system-prompt/assemble` (prepend, scoped): FINAL-ASSEMBLY re-assert —
* whatever downstream listeners mutated or replaced, the child's assembly
* always carries its capture tool and the trailing instruction section. The
* registry already contributes both; this outermost wrapper preserves the
* guarantee against a (global) listener that strips or replaces the
* assembly. 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'S
* IDENTITY; it becomes the run's captured result only when the final
* post-execute decision accepts THAT SAME pipeline trip. Execution-keyed
* staging closes the stale-stage hole unconditionally: an outer
* short-circuiting listener (post-execute block, or a pre-execute deny
* whose call never dispatched) can orphan a staged value, and neither a
* later call nor one REUSING the same adapter-minted call id can ever
* promote it — only the execution whose own body staged can commit.
*
* @module @deepseek-ai/dsh-subagent-inprocess/structured
*/
import type { Context } from 'cordis'
import type { Agent, ContinuationDecision } 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 { ToolArgsError, validateStructuredValue, type StructuredOutputSchema } from '@deepseek-ai/dsh-tools'
/** The model-facing tool name a structured child must call to finish. */
export const STRUCTURED_OUTPUT_TOOL = 'structured_output'
/**
* The instruction registered as the child's trailing (order-190, the end of
* the tool-guidance band) scoped prompt section: the demand travels with the
* tool, as ordinary prompt state of exactly one agent.
*/
export const STRUCTURED_OUTPUT_INSTRUCTION
= 'When you have your final answer, you MUST report it by calling the '
+ `\`${STRUCTURED_OUTPUT_TOOL}\` tool with arguments matching its parameter schema exactly. `
+ 'Do not finish with a plain text answer: only the tool call counts as your result.'
/** One structured run's live handle: read the captured value once the child settles. */
export interface StructuredAttachment {
/**
* The captured value, once the child called the tool with valid arguments
* and the final post-execute decision accepted that call.
* @returns the committed value, or undefined while none was accepted.
*/
captured(): { value: unknown } | undefined
}
/**
* 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
* 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).
* @returns the attachment handle (read `captured()` after the child settles).
*/
export function attachStructuredRuntime(childCtx: Context, schema: StructuredOutputSchema): StructuredAttachment {
/**
* A validated value staged by the capture tool body, awaiting ITS OWN
* call's post-execute verdict — keyed by the {@link ToolExecution} OBJECT
* identity, 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, and a denied/failed later call REUSING an orphaned
* stage's id must never promote it.
*/
let pending: { exec: ToolExecution; value: unknown } | undefined
let captured: { value: unknown } | undefined
const schemaEntry: ToolSchema = {
name: STRUCTURED_OUTPUT_TOOL,
description:
'Report your final structured result. Call this exactly once, when your answer is complete; '
+ 'the arguments must match this tool\'s parameter schema exactly.',
// ToolSchema.parameters is the wire-level JSON Schema object; the
// asserted subset type is structurally exactly that.
parameters: schema as unknown as Record<string, unknown>,
}
childCtx.tools.register({
...schemaEntry,
execute(args: unknown, exec: ToolExecution): Promise<ContentBlock[]> {
const violations = validateStructuredValue(schema, args)
// 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 the final decision accepts it.
pending = { exec, value: args }
return Promise.resolve([{ type: 'text', text: 'Structured output recorded.' }])
},
})
childCtx.systemPrompt.section({
name: `tool:${STRUCTURED_OUTPUT_TOOL}`,
order: 190,
text: STRUCTURED_OUTPUT_INSTRUCTION,
})
// FINAL-ASSEMBLY re-assert (prepend = outermost): scoped dispatch means this
// fires only for the child's assemblies; `await next()` returns whatever the
// downstream chain (and any replacement assembly) produced, and the capture
// tool + instruction are re-asserted onto it if anything stripped them.
childCtx.on('system-prompt/assemble', async function (
this: unknown, _assembly: PromptAssembly, _context: AssembleContext, next: () => Promise<PromptAssembly>,
): Promise<PromptAssembly> {
const final = await next()
if (!final.tools.some(tool => tool.name === STRUCTURED_OUTPUT_TOOL)) {
final.tools = [...final.tools, { ...schemaEntry, parameters: structuredClone(schemaEntry.parameters) }]
}
if (!final.sections.some(section => section.name === `tool:${STRUCTURED_OUTPUT_TOOL}`)) {
final.sections = [...final.sections, { name: `tool:${STRUCTURED_OUTPUT_TOOL}`, order: 190, text: STRUCTURED_OUTPUT_INSTRUCTION }]
}
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 the staged value only when the final
// post-execute decision accepts THE SAME CALL that staged it. The staging
// slot clears on every path for that call; a stale entry from an outer
// short-circuited chain (its verdict never reached us) is dropped when any
// later call reaches the commit, never promoted.
childCtx.on('tools/post-execute', async function (
this: unknown, exec: ToolExecution, _result: ToolExecutionResult, next: () => Promise<PostToolDecision>,
): Promise<PostToolDecision> {
if (exec.name !== STRUCTURED_OUTPUT_TOOL || pending === undefined) return next()
if (pending.exec !== exec) {
// A stale stage from a DIFFERENT pipeline trip: its own chain was
// short-circuited past this commit (an outer post-execute block, or an
// outer pre-execute deny whose call never dispatched), so its verdict
// never reached us. Whatever the current call's id, the orphan must
// never ride its acceptance — drop it.
pending = undefined
return next()
}
const staged = pending
try {
const decision = await next()
if (decision.kind === 'accept') captured = { value: staged.value }
return decision
} finally {
/* v8 ignore next -- defensive false branch: a concurrent re-stage
* would need a second capture call INSIDE the first's post-execute
* chain */
if (pending === staged) pending = undefined
}
}, { prepend: true })
return { captured: () => captured }
}