Structured output on the subagent seam: schema subset, capture runtime, spawn/fork support

Carved out of #170 per review feedback — the foundation the workflow tool
builds on, now standing alone on master:

- dsh-tools: the structured-output JSON Schema subset (StructuredOutputSchema,
  assertSupportedOutputSchema, validateStructuredValue) — rejects loud outside
  the enforced subset, listing every violation
- dsh-subagent: SubagentStartRequest.outputSchema / SubagentResult.structured
  become a real capability; the service rejects a schema'd request whose
  provider lacks it
- dsh-subagent-inprocess: the shared structured runtime — one global
  structured_output capture tool, a prepend final-assembly listener that
  strips the placeholder for plain agents and swaps in the run's own schema
  (plus the calling instruction as a trailing section) for structured
  children, an agent/turn-continuation veto once captured, and the
  capture/nudge loop in the run driver (structuredNudgeRetries, cancellation
  honored mid-nudge); lifetime refcounted by backends and live runs
- subagent-spawn / subagent-fork flip outputSchema: true

One deliberate divergence from the #170 revision: the backends do NOT add
'tools' to their plugin inject. Doing so deferred their apply past the todo
plugin, and the delegation tool mirrors provider lifecycle — so the
model-visible tool order of every existing prompt changed, invalidating every
recorded snapshot fixture. The runtime now gates its capture-tool registration
on tools availability itself (sync when live, a scoped inject fiber when the
Loader starts the backend first), keeping this PR byte-invisible to existing
transcripts: all 35 snapshot scenarios pass against master's fixtures
unchanged.
This commit is contained in:
Tianyi Cui
2026-07-06 23:29:08 +08:00
parent 0606cd559c
commit 74502fa8c2
28 changed files with 1570 additions and 62 deletions
@@ -18,7 +18,21 @@ import type { Context } 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 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'
import {
acquireStructuredRuntime,
STRUCTURED_OUTPUT_NUDGE,
type StructuredAcquisition,
} from './structured.ts'
export {
acquireStructuredRuntime,
STRUCTURED_OUTPUT_TOOL,
STRUCTURED_OUTPUT_INSTRUCTION,
STRUCTURED_OUTPUT_NUDGE,
type StructuredAcquisition,
} from './structured.ts'
declare module '@deepseek-ai/dsh-agent' {
interface AgentOptions {
@@ -76,6 +90,13 @@ export interface InProcessRunOptions {
* parent's log (FORK), or `undefined` for a fresh child (SPAWN).
*/
readonly seed?: SessionEvent[]
/**
* How many times a structured run re-prompts a child that finished a turn
* cleanly WITHOUT calling `structured_output` (see the structured module).
* REQUIRED, resolved from the backend's validated Config — per the explicit-
* defaulting rule, the driver never fills it with a hidden fallback.
*/
readonly structuredNudgeRetries: number
}
/**
@@ -98,6 +119,10 @@ export function startInProcessRun(
if (request.maxDepth !== undefined && childDepth > request.maxDepth) {
throw new SubagentDepthError(childDepth, request.maxDepth)
}
// Assert the schema subset BEFORE any child exists (the service has already
// capability-gated; this rejects a schema outside the enforced subset loud).
const schema = request.outputSchema
if (schema !== undefined) assertSupportedOutputSchema(schema)
const childId = AgentId(randomUUID())
// The child's OWN events begin after the seed (fork seeds the parent's
@@ -109,13 +134,20 @@ export function startInProcessRun(
// Inherit the parent's model by default (a child with no model cannot run);
// an explicit `request.agentOptions.model` overrides it. The persona needs
// no inheritance: the deployment persona is a context-wide prompt section,
// so parent and child render the same one.
// so parent and child render the same one. A structured run's
// structured_output instruction is NOT prompt state either — the structured
// runtime's final-request listener appends it per request (see structured.ts).
const agentOptions: AgentOptions = {
...request.parent.options.model !== undefined ? { model: request.parent.options.model } : {},
...request.agentOptions,
subagentDepth: childDepth,
}
// The structured runtime is held for the WHOLE run (acquired before the child
// exists, released when the result settles), so a backend hot-reload mid-run
// cannot unregister the capture tool out from under this live child.
const structured: StructuredAcquisition | undefined = schema !== undefined ? acquireStructuredRuntime(ctx) : undefined
const handle: AgentHandle = ctx.agents.create({
agentId: childId,
sessionId: SessionId(randomUUID()),
@@ -130,6 +162,7 @@ export function startInProcessRun(
agentOptions,
})
const child = handle.agent
if (structured && schema !== undefined) structured.attach(child, schema)
// 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).
@@ -138,6 +171,10 @@ export function startInProcessRun(
// `turn/end` is logged — settles as `aborted` (honoring the cancel contract)
// rather than falling through to the no-turn `error` mapping.
let cancelled = false
// An accessor, not an inline read: `cancelled` mutates from closures (the
// abort listener, run.cancel), which control-flow narrowing cannot see — an
// inline `!cancelled` in the nudge condition reads as always-true.
const isCancelled = (): boolean => cancelled
const requestCancel = (reason: string): void => {
cancelled = true
child.cancel(reason)
@@ -154,9 +191,35 @@ export function startInProcessRun(
if (request.signal?.aborted) return { output: [], stopReason: 'aborted' }
child.send(request.prompt)
await child.whenIdle()
return readResult(child, seedLength, cancelled)
if (structured) {
// Nudge loop: a child that finished a turn CLEANLY without calling
// structured_output gets re-prompted, up to the backend-configured
// retry count. An errored/aborted turn is not nudged — its failure is
// the honest result (a cancelled turn ends `aborted`, and a pre-turn
// cancel leaves no `turn/end` at all, so neither reads `completed`).
// `!cancelled` closes the remaining window: a cancel landing AFTER a
// clean turn end clears nothing — `child.cancel()` only kills
// queued/running work — so without it the next `send` would spend a
// fresh post-cancellation turn; the condition re-evaluates after
// every `whenIdle()`, so a mid-nudge cancel stops the loop at the
// next boundary too.
let nudges = options.structuredNudgeRetries
while (
!isCancelled() && structured.captured(child) === undefined && nudges > 0
&& lastOwnTurnEnd(child, seedLength)?.data.reason.kind === 'completed'
) {
nudges -= 1
child.send([{ type: 'text', text: STRUCTURED_OUTPUT_NUDGE }])
await child.whenIdle()
}
}
return readResult(child, seedLength, isCancelled(), structured ? { captured: structured.captured(child) } : undefined)
} finally {
request.signal?.removeEventListener('abort', onAbort)
if (structured) {
structured.detach(child)
structured.release()
}
}
})()
@@ -173,6 +236,12 @@ export function startInProcessRun(
}
}
/** The child's OWN last `turn/end` event (events at or after `seedLength`), if any. */
function lastOwnTurnEnd(child: Agent, seedLength: number): SessionEvent<'turn/end'> | undefined {
return child.session.events.slice(seedLength)
.findLast((e): e is SessionEvent<'turn/end'> => e.type === 'turn/end')
}
/**
* Read a settled child's terminal result from its session log, scoped to the
* child's OWN events (everything at or after `seedLength` — fork seeds the
@@ -184,12 +253,32 @@ export function startInProcessRun(
* logged (a cancel landed in the pre-turn window, before any turn ran), the
* run settles `aborted` per the {@link SubagentRun.cancel} contract rather than
* the generic no-turn `error`.
*
* A structured run (`structured` present) additionally reports the captured
* value on {@link SubagentResult.structured}. A structured child that finished
* CLEANLY without ever capturing (the nudges ran out) settles `error` — a clean
* finish without the demanded structured result is a failure, not a success
* with a missing field; a non-`completed` reason keeps its own honest mapping.
*/
function readResult(child: Agent, seedLength: number, cancelled: boolean): SubagentResult {
function readResult(
child: Agent,
seedLength: number,
cancelled: boolean,
structured?: { captured?: { value: unknown } | undefined },
): SubagentResult {
const own = child.session.events.slice(seedLength)
const lastMessage = own.findLast((e): e is SessionEvent<'assistant/message'> => e.type === 'assistant/message')
const lastEnd = own.findLast((e): e is SessionEvent<'turn/end'> => e.type === 'turn/end')
const output: ContentBlock[] = lastMessage ? structuredClone(lastMessage.data.content) : []
if (lastEnd === undefined && cancelled) return { output, stopReason: 'aborted' }
return { output, stopReason: toStopReason(lastEnd?.data.reason) }
const stopReason: SubagentStopReason = lastEnd === undefined && cancelled
? 'aborted'
: toStopReason(lastEnd?.data.reason)
if (structured) {
if (structured.captured) return { output, structured: structured.captured.value, stopReason }
// No capture on a cleanly-completed turn: an ERROR when the run was left
// to finish (the nudges ran out), but ABORTED when a cancel is why the
// nudging stopped — the cancel contract outranks the schema shortfall.
if (stopReason === 'completed') return { output, stopReason: cancelled ? 'aborted' : 'error' }
}
return { output, stopReason }
}