Pārlūkot izejas kodu

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.
Tianyi Cui 2 mēneši atpakaļ
vecāks
revīzija
74502fa8c2
28 mainītis faili ar 1570 papildinājumiem un 62 dzēšanām
  1. 3 3
      docs/cordis-catalog/events.md
  2. 1 1
      docs/cordis-catalog/services.md
  3. 2 2
      docs/core-data-structures/subagent.md
  4. 3 3
      docs/event-producer-consumer.md
  5. 3 1
      docs/module-graph.md
  6. 6 0
      packages/core/tools/README.md
  7. 10 0
      packages/core/tools/src/index.ts
  8. 322 0
      packages/core/tools/src/json-schema.ts
  9. 254 0
      packages/core/tools/tests/json-schema.spec.ts
  10. 2 1
      packages/subagent/subagent-fork/README.md
  11. 30 7
      packages/subagent/subagent-fork/src/index.ts
  12. 2 2
      packages/subagent/subagent-fork/tests/multi-subagent.spec.ts
  13. 10 4
      packages/subagent/subagent-fork/tests/subagent-fork.spec.ts
  14. 16 5
      packages/subagent/subagent-inprocess/README.md
  15. 4 0
      packages/subagent/subagent-inprocess/package.json
  16. 94 5
      packages/subagent/subagent-inprocess/src/index.ts
  17. 239 0
      packages/subagent/subagent-inprocess/src/structured.ts
  18. 485 0
      packages/subagent/subagent-inprocess/tests/structured.spec.ts
  19. 3 3
      packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts
  20. 6 0
      packages/subagent/subagent-inprocess/tsconfig.json
  21. 3 2
      packages/subagent/subagent-spawn/README.md
  22. 42 9
      packages/subagent/subagent-spawn/src/index.ts
  23. 1 1
      packages/subagent/subagent-spawn/tests/harness.ts
  24. 10 4
      packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts
  25. 9 5
      packages/subagent/subagent/src/types.ts
  26. 2 2
      packages/subagent/subagent/tests/service.spec.ts
  27. 2 2
      packages/support/subagent-mock/tests/subagent-mock.spec.ts
  28. 6 0
      pnpm-lock.yaml

+ 3 - 3
docs/cordis-catalog/events.md

@@ -307,7 +307,7 @@ A tool was registered or unregistered (the available tool set changed).
 'tools/change'(): void
 ```
 
-Source: [`packages/core/tools/src/index.ts:87`](../../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:97`](../../packages/core/tools/src/index.ts)
 
 ### `tools/post-execute` — waterfall
 
@@ -319,7 +319,7 @@ Waterfall AFTER a tool runs — where hook plugins inspect the result and accept
 
 Types: [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md)
 
-Source: [`packages/core/tools/src/index.ts:82`](../../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:92`](../../packages/core/tools/src/index.ts)
 
 ### `tools/pre-execute` — waterfall
 
@@ -331,7 +331,7 @@ Waterfall BEFORE a tool runs — the gate where sandbox, permission, and hook pl
 
 Types: [ToolExecution](../core-data-structures/tools.md)
 
-Source: [`packages/core/tools/src/index.ts:66`](../../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:76`](../../packages/core/tools/src/index.ts)
 
 ## Inherited events (cordis core + loader/hmr/timer)
 

+ 1 - 1
docs/cordis-catalog/services.md

@@ -204,7 +204,7 @@ async execute(exec: ToolExecution): Promise<ToolExecutionResult>
 
 Types: [ToolDefinition](../core-data-structures/tools.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md)
 
-Source: [`packages/core/tools/src/index.ts:268`](../../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:278`](../../packages/core/tools/src/index.ts)
 
 ## `ctx.web` — `WebService`
 

+ 2 - 2
docs/core-data-structures/subagent.md

@@ -20,7 +20,7 @@ interface SubagentCapabilities {
 
 ## The start request
 
-What a caller asks for when starting a subagent. The tool layer builds this from the model's `{ description, prompt }` plus its own config; the service validates the start-time capabilities against the named provider, then passes it to `provider.start`. `parent` is REQUIRED — in-process backends read `parent.session.header` for the working directory, the `parentSession` lineage, and the delegation depth. The three optional fields (`outputSchema`, `maxDepth`, `toolFilter`) each gate on the matching `SubagentCapabilities` flag.
+What a caller asks for when starting a subagent. The tool layer builds this from the model's `{ description, prompt }` plus its own config; the service validates the start-time capabilities against the named provider, then passes it to `provider.start`. `parent` is REQUIRED — in-process backends read `parent.session.header` for the working directory, the `parentSession` lineage, and the delegation depth. The three optional fields (`outputSchema`, `maxDepth`, `toolFilter`) each gate on the matching `SubagentCapabilities` flag. `outputSchema` is an object-rooted JSON Schema within the subset `assertSupportedOutputSchema` (dsh-tools) enforces — a schema outside it is rejected loud at start; the in-process backends realize it with a forced `structured_output` capture tool (see the [driver README](../../packages/subagent/subagent-inprocess/README.md)).
 
 ```ts type-equiv
 interface SubagentStartRequest {
@@ -28,7 +28,7 @@ interface SubagentStartRequest {
   parent: Agent
   signal?: AbortSignal
   agentOptions?: AgentOptions
-  outputSchema?: SchemaSpec
+  outputSchema?: StructuredOutputSchema
   maxDepth?: number
   toolFilter?: { allow?: string[]; deny?: string[] }
 }

+ 3 - 3
docs/event-producer-consumer.md

@@ -31,8 +31,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:91`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) |
 | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:38`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | - |
 | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:44`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
-| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:87`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
-| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:82`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
-| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:66`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
+| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:97`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
+| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:92`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
+| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:76`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
 
 Maintenance mode: hybrid generated: Cordis event declarations and most producer/listener edges are AST-scanned; dynamic dispatch sites are classified in `scripts/gen-doc-graphs.ts`.

+ 3 - 1
docs/module-graph.md

@@ -171,6 +171,8 @@ flowchart TD
   pkg_subagent_inprocess --> pkg_llm
   pkg_subagent_inprocess --> pkg_session
   pkg_subagent_inprocess --> pkg_subagent
+  pkg_subagent_inprocess --> pkg_system_prompt
+  pkg_subagent_inprocess --> pkg_tools
   pkg_tool_subagent --> pkg_agent
   pkg_tool_subagent --> pkg_llm
   pkg_tool_subagent --> pkg_subagent
@@ -241,7 +243,7 @@ flowchart TD
 | [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools) |
 | [`agent-core`](../packages/core/agent-core) | `core` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/bash/tool-bash), [`tools`](../packages/core/tools) |
 | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent) |
-| [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
+| [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
 | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
 | [`subagent-mock`](../packages/support/subagent-mock) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent) |

+ 6 - 0
packages/core/tools/README.md

@@ -71,6 +71,12 @@ A `defineTool` tool also **validates the model-generated arguments against its `
 
 See `defineTool`, `validateArgs`, `ToolArgsError`, `SchemaSpec`, `InferArgs`, and `schemaSpecToJsonSchema` in the public API for details.
 
+### Structured-output schema subset
+
+A separate vocabulary for callers that DEMAND a machine-readable value from an agent — the subagent seam's `SubagentStartRequest.outputSchema` (and, by extension, a workflow's `agent({ schema })`). Unlike `SchemaSpec` (the author-facing DSL for tool parameters), a `StructuredOutputSchema` is an object-rooted **raw JSON Schema subset** as data: it travels verbatim to the model as a forced tool's `parameters`, and the produced value is validated against it.
+
+The subset is deliberately narrow and REJECTS LOUD outside it — accepting a keyword the validator doesn't enforce would validate less than the schema promises (accepted-then-ignored). Supported: single-string `type` (`object`/`array`/`string`/`number`/`integer`/`boolean`/`null`; type arrays rejected), `properties`/`required`/`additionalProperties` (boolean; every `required` key must be declared), `items`, scalar-only `enum`/`const`; annotations (`description`/`title`/`default`/`examples`) are ignored but must still be JSON data. `assertSupportedOutputSchema(schema)` throws `OutputSchemaError` (`code: 'UNSUPPORTED_SCHEMA'`, listing every violation) for anything else; `validateStructuredValue(schema, value)` returns path-qualified violations (empty = valid, total — never throws).
+
 ### Tool-owned UI presentation
 
 A tool owns how ITS calls render in a UI (an editor's tool-call card, a CLI log line) — a UI plugin must NOT special-case tool names. A `ToolDefinition` may declare two optional, pure, display-only methods that return a **`card`-tagged render intent** (a discriminated union — a tool declares its card kind once and a UI bridge switches on `card`):

+ 10 - 0
packages/core/tools/src/index.ts

@@ -28,6 +28,16 @@ export {
   type JsonSchemaObject,
 } from './schema.ts'
 
+export {
+  assertSupportedOutputSchema,
+  validateStructuredValue,
+  OutputSchemaError,
+  type StructuredOutputSchema,
+  type StructuredSchemaNode,
+  type StructuredSchemaType,
+  type StructuredScalar,
+} from './json-schema.ts'
+
 // The render-intent vocabulary a tool declares via `presentCall`/`presentResult`
 // lives in its own UI-facing module; re-export it so `@deepseek-ai/dsh-tools`
 // stays the single public surface for consumers (producers + the ACP bridge).

+ 322 - 0
packages/core/tools/src/json-schema.ts

@@ -0,0 +1,322 @@
+/**
+ * Structured-output JSON Schema subset: the vocabulary a caller uses to demand
+ * a machine-readable result from a subagent (`SubagentStartRequest.outputSchema`)
+ * or a workflow `agent()` call.
+ *
+ * This is deliberately NOT full JSON Schema. The schema travels verbatim to the
+ * model as a forced tool's `parameters`, and the value the model produces is
+ * validated here — so every accepted keyword must be one this module actually
+ * enforces. Accepting a keyword we don't enforce would validate less than the
+ * schema promises (accepted-then-ignored), so anything outside the subset is
+ * REJECTED LOUD by {@link assertSupportedOutputSchema} instead. The subset:
+ *
+ * - `type` — a single string (`object`/`array`/`string`/`number`/`integer`/
+ *   `boolean`/`null`); type ARRAYS (`["string","null"]`) are rejected.
+ * - `properties`/`required`/`additionalProperties` (boolean) on objects; every
+ *   `required` key must be declared in `properties`. `additionalProperties`
+ *   absent keeps standard JSON Schema semantics (extra keys allowed).
+ * - `items` on arrays (absent ⇒ any JSON items).
+ * - `enum` (non-empty, scalars only) and `const` (scalar) on scalar types.
+ * - Annotations `description`/`title`/`default`/`examples` are allowed and
+ *   ignored (they constrain nothing), except that they must still be JSON data
+ *   — the schema is serialized onto the wire, so a non-JSON annotation would be
+ *   silently mangled.
+ *
+ * Values checked by {@link validateStructuredValue} are expected to be plain
+ * host-realm JSON data (model tool-call arguments are parsed wire JSON; a
+ * caller holding foreign-realm data materializes it first).
+ *
+ * @module dsh-tools/json-schema
+ */
+
+import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm'
+
+/** The scalar values `enum`/`const` may carry (finite numbers only). */
+export type StructuredScalar = string | number | boolean | null
+
+/** The `type` keywords the subset accepts. */
+export type StructuredSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null'
+
+/**
+ * One node of the structured-output schema subset. Recursive via `properties`
+ * and `items`; see the module doc for the exact keyword semantics.
+ */
+export interface StructuredSchemaNode {
+  type: StructuredSchemaType
+  /** Nested property schemas (`type: 'object'` only). */
+  properties?: Record<string, StructuredSchemaNode>
+  /** Required property names; each must appear in `properties`. */
+  required?: string[]
+  /** `false` rejects undeclared keys; absent/`true` allows them (JSON Schema default). */
+  additionalProperties?: boolean
+  /** Item schema (`type: 'array'` only); absent ⇒ any JSON items. */
+  items?: StructuredSchemaNode
+  /** Allowed values (scalar types only). */
+  enum?: StructuredScalar[]
+  /** The single allowed value (scalar types only). */
+  const?: StructuredScalar
+  /** Annotation, ignored for validation. */
+  description?: string
+  /** Annotation, ignored for validation. */
+  title?: string
+  /** Annotation, ignored for validation (must still be JSON data). */
+  default?: unknown
+  /** Annotation, ignored for validation (must still be JSON data). */
+  examples?: unknown
+}
+
+/** A structured-output schema: an OBJECT-rooted {@link StructuredSchemaNode}. */
+export type StructuredOutputSchema = StructuredSchemaNode & { type: 'object' }
+
+/**
+ * Thrown by {@link assertSupportedOutputSchema} when a schema falls outside the
+ * supported subset. Extends {@link HarnessError} (`code: 'UNSUPPORTED_SCHEMA'`)
+ * so seam code and tool results can route on it; `violations` lists every
+ * offending path, not just the first.
+ */
+export class OutputSchemaError extends HarnessError {
+  /** The individual violation messages, in walk order. */
+  readonly violations: string[]
+
+  constructor(violations: string[]) {
+    super(`unsupported output schema: ${violations.join('; ')}`, 'UNSUPPORTED_SCHEMA')
+    this.name = 'OutputSchemaError'
+    this.violations = violations
+  }
+}
+
+/** The keywords the subset accepts, checked (`constraint`) or ignored (`annotation`). */
+const CONSTRAINT_KEYWORDS = new Set(['type', 'properties', 'required', 'additionalProperties', 'items', 'enum', 'const'])
+const ANNOTATION_KEYWORDS = new Set(['description', 'title', 'default', 'examples'])
+
+const SCHEMA_TYPES: readonly StructuredSchemaType[] = ['object', 'array', 'string', 'number', 'integer', 'boolean', 'null']
+
+/** Whether a value is a non-null, non-array object (structural, realm-agnostic). */
+function isObjectLike(value: unknown): value is Record<string, unknown> {
+  return typeof value === 'object' && value !== null && !Array.isArray(value)
+}
+
+/** Whether a value is a supported scalar (`enum`/`const` member): string, finite number, boolean, or null. */
+function isStructuredScalar(value: unknown): value is StructuredScalar {
+  return value === null || typeof value === 'string' || typeof value === 'boolean'
+    || (typeof value === 'number' && Number.isFinite(value))
+}
+
+/**
+ * Whether a value is JSON data (annotation payloads only): scalars, arrays, and
+ * object-likes of such values. Realm-agnostic on purpose (no prototype check) —
+ * the schema may have been materialized from another realm; structural JSON-ness
+ * is what the wire needs. Cycles are rejected via `seen`.
+ */
+function isJsonData(value: unknown, seen: Set<object>): boolean {
+  if (isStructuredScalar(value)) return true
+  // The scalar check above already returned for null, so `object` here is a real object.
+  if (typeof value !== 'object') return false
+  if (seen.has(value)) return false
+  seen.add(value)
+  try {
+    if (Array.isArray(value)) return value.every(entry => isJsonData(entry, seen))
+    return Object.values(value).every(entry => isJsonData(entry, seen))
+  } finally {
+    seen.delete(value)
+  }
+}
+
+/** Collect subset violations for one schema node (recursive walk). */
+function checkSchemaNode(node: unknown, path: string, violations: string[], seen: Set<object>): void {
+  if (!isObjectLike(node)) {
+    violations.push(`${path} must be a schema object`)
+    return
+  }
+  if (seen.has(node)) {
+    violations.push(`${path} is circular`)
+    return
+  }
+  seen.add(node)
+
+  for (const key of Object.keys(node)) {
+    if (CONSTRAINT_KEYWORDS.has(key)) continue
+    if (ANNOTATION_KEYWORDS.has(key)) {
+      if (!isJsonData(node[key], new Set())) violations.push(`${path}.${key} annotation must be JSON data`)
+      continue
+    }
+    violations.push(`${path}.${key} is not a supported keyword (subset: type/properties/required/additionalProperties/items/enum/const + annotations)`)
+  }
+  if (typeof node.description !== 'undefined' && typeof node.description !== 'string') {
+    violations.push(`${path}.description must be a string`)
+  }
+  if (typeof node.title !== 'undefined' && typeof node.title !== 'string') {
+    violations.push(`${path}.title must be a string`)
+  }
+
+  const type = node.type
+  if (typeof type !== 'string' || !(SCHEMA_TYPES as readonly unknown[]).includes(type)) {
+    violations.push(Array.isArray(type)
+      ? `${path}.type must be a single type string (type arrays are not supported)`
+      : `${path}.type must be one of ${SCHEMA_TYPES.join('/')}`)
+    seen.delete(node)
+    return
+  }
+  const schemaType = type as StructuredSchemaType
+
+  // Keywords that only make sense on one type are rejected elsewhere — an
+  // `items` on an object (or `properties` on a string) is a schema-author bug
+  // the subset surfaces rather than ignores.
+  const allowedFor: Record<string, StructuredSchemaType[]> = {
+    properties: ['object'],
+    required: ['object'],
+    additionalProperties: ['object'],
+    items: ['array'],
+    enum: ['string', 'number', 'integer', 'boolean', 'null'],
+    const: ['string', 'number', 'integer', 'boolean', 'null'],
+  }
+  for (const [key, types] of Object.entries(allowedFor)) {
+    if (key in node && !types.includes(schemaType)) {
+      violations.push(`${path}.${key} is not supported on type "${schemaType}"`)
+    }
+  }
+
+  switch (schemaType) {
+    case 'object': {
+      const properties = node.properties
+      if (properties !== undefined) {
+        if (!isObjectLike(properties)) {
+          violations.push(`${path}.properties must be an object of schemas`)
+        } else {
+          for (const [key, child] of Object.entries(properties)) {
+            checkSchemaNode(child, `${path}.properties.${key}`, violations, seen)
+          }
+        }
+      }
+      const required = node.required
+      if (required !== undefined) {
+        if (!Array.isArray(required) || required.some(entry => typeof entry !== 'string')) {
+          violations.push(`${path}.required must be an array of strings`)
+        } else {
+          const declared = isObjectLike(properties) ? properties : {}
+          for (const key of required) {
+            if (!(key in declared)) violations.push(`${path}.required names "${key}" which is not in properties`)
+          }
+        }
+      }
+      if (node.additionalProperties !== undefined && typeof node.additionalProperties !== 'boolean') {
+        violations.push(`${path}.additionalProperties must be a boolean`)
+      }
+      break
+    }
+    case 'array': {
+      if (node.items !== undefined) checkSchemaNode(node.items, `${path}.items`, violations, seen)
+      break
+    }
+    case 'string':
+    case 'number':
+    case 'integer':
+    case 'boolean':
+    case 'null': {
+      const allowed = node.enum
+      if (allowed !== undefined) {
+        if (!Array.isArray(allowed) || allowed.length === 0 || !allowed.every(entry => isStructuredScalar(entry))) {
+          violations.push(`${path}.enum must be a non-empty array of scalars`)
+        }
+      }
+      if ('const' in node && !isStructuredScalar(node.const)) {
+        violations.push(`${path}.const must be a scalar`)
+      }
+      break
+    }
+    /* v8 ignore start -- defensive: schemaType was membership-checked against SCHEMA_TYPES above, so no runtime value reaches here */
+    default:
+      assertNever(schemaType, 'assertSupportedOutputSchema')
+    /* v8 ignore stop */
+  }
+
+  seen.delete(node)
+}
+
+/**
+ * Assert `schema` is a supported {@link StructuredOutputSchema} — object-rooted
+ * and entirely within the enforced subset. Throws {@link OutputSchemaError}
+ * (`UNSUPPORTED_SCHEMA`) listing EVERY violation; returns (and narrows) on
+ * success. Call this at the seam boundary, before any child is created.
+ * @param schema - the caller-supplied schema (unknown until asserted).
+ */
+export function assertSupportedOutputSchema(schema: unknown): asserts schema is StructuredOutputSchema {
+  const violations: string[] = []
+  checkSchemaNode(schema, 'schema', violations, new Set())
+  if (violations.length === 0 && (schema as StructuredSchemaNode).type !== 'object') {
+    violations.push('schema.type must be "object" (structured output is object-rooted)')
+  }
+  if (violations.length > 0) throw new OutputSchemaError(violations)
+}
+
+/** Collect violations for one value against an (already asserted) schema node. */
+function checkValue(node: StructuredSchemaNode, value: unknown, path: string): string[] {
+  switch (node.type) {
+    case 'object': {
+      if (!isObjectLike(value)) return [`"${path}" must be an object`]
+      const violations: string[] = []
+      const properties = node.properties ?? {}
+      for (const key of node.required ?? []) {
+        if (value[key] === undefined) violations.push(`missing required property "${path}.${key}"`)
+      }
+      for (const [key, child] of Object.entries(properties)) {
+        if (value[key] === undefined) continue
+        violations.push(...checkValue(child, value[key], `${path}.${key}`))
+      }
+      if (node.additionalProperties === false) {
+        for (const key of Object.keys(value)) {
+          if (!(key in properties)) violations.push(`"${path}.${key}" is not a declared property (additionalProperties: false)`)
+        }
+      }
+      return violations
+    }
+    case 'array': {
+      if (!Array.isArray(value)) return [`"${path}" must be an array`]
+      if (!node.items) return []
+      const items = node.items
+      return value.flatMap((entry, index) => checkValue(items, entry, `${path}[${index}]`))
+    }
+    case 'string': {
+      if (typeof value !== 'string') return [`"${path}" must be a string`]
+      break
+    }
+    case 'number': {
+      if (typeof value !== 'number' || !Number.isFinite(value)) return [`"${path}" must be a finite number`]
+      break
+    }
+    case 'integer': {
+      if (typeof value !== 'number' || !Number.isInteger(value)) return [`"${path}" must be an integer`]
+      break
+    }
+    case 'boolean': {
+      if (typeof value !== 'boolean') return [`"${path}" must be a boolean`]
+      break
+    }
+    case 'null': {
+      if (value !== null) return [`"${path}" must be null`]
+      break
+    }
+    default:
+      return assertNever(node.type, 'validateStructuredValue')
+  }
+  // Scalar constraint checks, shared by every scalar branch above.
+  if (node.enum && !node.enum.includes(value)) {
+    return [`"${path}" must be one of ${JSON.stringify(node.enum)}`]
+  }
+  if ('const' in node && value !== node.const) {
+    return [`"${path}" must be ${JSON.stringify(node.const)}`]
+  }
+  return []
+}
+
+/**
+ * Validate a value against an (already {@link assertSupportedOutputSchema}-
+ * asserted) schema. Returns human-readable, path-qualified violation messages
+ * — empty means valid. Total: never throws, however malformed the value.
+ * @param schema - the asserted schema to check against.
+ * @param value - the candidate value (e.g. parsed tool-call arguments).
+ * @returns every violation found, in walk order (empty = valid).
+ */
+export function validateStructuredValue(schema: StructuredOutputSchema, value: unknown): string[] {
+  return checkValue(schema, value, 'value')
+}

+ 254 - 0
packages/core/tools/tests/json-schema.spec.ts

@@ -0,0 +1,254 @@
+import { describe, expect, it } from 'vitest'
+import {
+  assertSupportedOutputSchema,
+  OutputSchemaError,
+  validateStructuredValue,
+  type StructuredOutputSchema,
+} from '../src/json-schema.ts'
+
+/** Assert-and-narrow helper: the asserted schema, typed. */
+function asserted(schema: unknown): StructuredOutputSchema {
+  assertSupportedOutputSchema(schema)
+  return schema
+}
+
+/** The violations OutputSchemaError carries for a bad schema (throws if it passes). */
+function violationsOf(schema: unknown): string[] {
+  try {
+    assertSupportedOutputSchema(schema)
+  } catch (error: unknown) {
+    if (error instanceof OutputSchemaError) return error.violations
+    throw error
+  }
+  throw new Error('expected the schema to be rejected')
+}
+
+describe('assertSupportedOutputSchema', () => {
+  it('accepts a representative subset schema (all supported keywords)', () => {
+    const schema = asserted({
+      type: 'object',
+      description: 'a finding',
+      title: 'Finding',
+      properties: {
+        file: { type: 'string', description: 'path' },
+        line: { type: 'integer' },
+        severity: { type: 'string', enum: ['low', 'high'] },
+        kind: { type: 'string', const: 'bug' },
+        score: { type: 'number' },
+        confirmed: { type: 'boolean' },
+        parent: { type: 'null' },
+        tags: { type: 'array', items: { type: 'string' } },
+        nested: {
+          type: 'object',
+          properties: { x: { type: 'number', default: 3, examples: [1, 2] } },
+          additionalProperties: false,
+        },
+        anything: { type: 'array' },
+      },
+      required: ['file', 'line'],
+      additionalProperties: true,
+    })
+    expect(schema.type).toBe('object')
+  })
+
+  it('rejects a non-object root (scalar/array-rooted schemas)', () => {
+    expect(violationsOf({ type: 'string' })).toEqual(['schema.type must be "object" (structured output is object-rooted)'])
+    expect(violationsOf({ type: 'array', items: { type: 'string' } }))
+      .toContain('schema.type must be "object" (structured output is object-rooted)')
+  })
+
+  it('rejects non-object schema nodes and missing/unknown type', () => {
+    expect(violationsOf('nope')).toEqual(['schema must be a schema object'])
+    expect(violationsOf(null)).toEqual(['schema must be a schema object'])
+    expect(violationsOf([])).toEqual(['schema must be a schema object'])
+    expect(violationsOf({})).toEqual(['schema.type must be one of object/array/string/number/integer/boolean/null'])
+    expect(violationsOf({ type: 'tuple' })[0]).toMatch(/type must be one of/)
+    expect(violationsOf({ type: 'object', properties: { a: 'str' } })).toEqual(['schema.properties.a must be a schema object'])
+  })
+
+  it('rejects type ARRAYS with a dedicated message', () => {
+    expect(violationsOf({ type: ['string', 'null'] }))
+      .toEqual(['schema.type must be a single type string (type arrays are not supported)'])
+  })
+
+  it('rejects unsupported constraint keywords loudly (never accepted-then-ignored)', () => {
+    for (const keyword of ['oneOf', 'anyOf', 'allOf', 'not', 'pattern', 'minimum', 'maxLength', '$ref']) {
+      const bad = violationsOf({ type: 'object', [keyword]: [] })
+      expect(bad.some(v => v.includes(`schema.${keyword} is not a supported keyword`))).toBe(true)
+    }
+  })
+
+  it('reports EVERY violation, not just the first', () => {
+    const bad = violationsOf({
+      type: 'object',
+      pattern: 'x',
+      properties: { a: { type: 'weird' }, b: { type: 'string', minimum: 1 } },
+    })
+    expect(bad.length).toBe(3)
+  })
+
+  it('rejects keywords on the wrong type (items on object, properties on string, enum on object)', () => {
+    expect(violationsOf({ type: 'object', items: { type: 'string' } }))
+      .toEqual(['schema.items is not supported on type "object"'])
+    expect(violationsOf({ type: 'object', properties: { a: { type: 'string', properties: {} } } }))
+      .toEqual(['schema.properties.a.properties is not supported on type "string"'])
+    expect(violationsOf({ type: 'object', enum: [1] }))
+      .toEqual(['schema.enum is not supported on type "object"'])
+    expect(violationsOf({ type: 'object', properties: { a: { type: 'array', const: 1 } } }))
+      .toEqual(['schema.properties.a.const is not supported on type "array"'])
+  })
+
+  it('validates required: must be string[] naming declared properties', () => {
+    expect(violationsOf({ type: 'object', required: 'file' }))
+      .toEqual(['schema.required must be an array of strings'])
+    expect(violationsOf({ type: 'object', required: [1] }))
+      .toEqual(['schema.required must be an array of strings'])
+    expect(violationsOf({ type: 'object', properties: { a: { type: 'string' } }, required: ['b'] }))
+      .toEqual(['schema.required names "b" which is not in properties'])
+    expect(violationsOf({ type: 'object', required: ['a'] }))
+      .toEqual(['schema.required names "a" which is not in properties'])
+  })
+
+  it('validates additionalProperties must be boolean and enum/const must be scalars', () => {
+    expect(violationsOf({ type: 'object', additionalProperties: {} }))
+      .toEqual(['schema.additionalProperties must be a boolean'])
+    expect(violationsOf({ type: 'object', properties: { a: { type: 'string', enum: [] } } }))
+      .toEqual(['schema.properties.a.enum must be a non-empty array of scalars'])
+    expect(violationsOf({ type: 'object', properties: { a: { type: 'string', enum: [{}] } } }))
+      .toEqual(['schema.properties.a.enum must be a non-empty array of scalars'])
+    expect(violationsOf({ type: 'object', properties: { a: { type: 'string', enum: 'x' } } }))
+      .toEqual(['schema.properties.a.enum must be a non-empty array of scalars'])
+    expect(violationsOf({ type: 'object', properties: { a: { type: 'number', enum: [Number.NaN] } } }))
+      .toEqual(['schema.properties.a.enum must be a non-empty array of scalars'])
+    expect(violationsOf({ type: 'object', properties: { a: { type: 'string', const: {} } } }))
+      .toEqual(['schema.properties.a.const must be a scalar'])
+  })
+
+  it('rejects non-string description/title and non-JSON annotation payloads', () => {
+    expect(violationsOf({ type: 'object', description: 7 }))
+      .toEqual(['schema.description must be a string'])
+    expect(violationsOf({ type: 'object', title: 7 }))
+      .toEqual(['schema.title must be a string'])
+    expect(violationsOf({ type: 'object', default: () => 1 }))
+      .toEqual(['schema.default annotation must be JSON data'])
+    expect(violationsOf({ type: 'object', examples: [undefined] }))
+      .toEqual(['schema.examples annotation must be JSON data'])
+    expect(violationsOf({ type: 'object', examples: [Number.POSITIVE_INFINITY] }))
+      .toEqual(['schema.examples annotation must be JSON data'])
+    // A cyclic annotation payload is caught by the JSON-data walk.
+    const cyclicAnnotation: Record<string, unknown> = {}
+    cyclicAnnotation.self = cyclicAnnotation
+    expect(violationsOf({ type: 'object', default: cyclicAnnotation }))
+      .toEqual(['schema.default annotation must be JSON data'])
+    // Object/array annotations that ARE JSON data pass.
+    asserted({ type: 'object', default: { a: [1, 'x', null, true] } })
+  })
+
+  it('rejects a circular schema instead of recursing forever', () => {
+    const node: Record<string, unknown> = { type: 'object' }
+    node.properties = { self: node }
+    expect(violationsOf(node)).toEqual(['schema.properties.self is circular'])
+  })
+
+  it('accepts the same subschema object reused in two SIBLING positions (a DAG, not a cycle)', () => {
+    const leaf = { type: 'string' }
+    asserted({ type: 'object', properties: { a: leaf, b: leaf } })
+  })
+})
+
+describe('validateStructuredValue', () => {
+  const schema = asserted({
+    type: 'object',
+    properties: {
+      file: { type: 'string' },
+      line: { type: 'integer' },
+      score: { type: 'number' },
+      confirmed: { type: 'boolean' },
+      parent: { type: 'null' },
+      severity: { type: 'string', enum: ['low', 'high'] },
+      kind: { type: 'string', const: 'bug' },
+      tags: { type: 'array', items: { type: 'string' } },
+      free: { type: 'array' },
+      nested: { type: 'object', properties: { x: { type: 'number' } }, required: ['x'], additionalProperties: false },
+    },
+    required: ['file'],
+  })
+
+  it('accepts a fully valid value (empty violations)', () => {
+    expect(validateStructuredValue(schema, {
+      file: 'a.ts', line: 3, score: 0.5, confirmed: true, parent: null,
+      severity: 'high', kind: 'bug', tags: ['x'], free: [1, { any: true }], nested: { x: 1 },
+    })).toEqual([])
+  })
+
+  it('reports missing required and wrong root type', () => {
+    expect(validateStructuredValue(schema, {})).toEqual(['missing required property "value.file"'])
+    expect(validateStructuredValue(schema, 'nope')).toEqual(['"value" must be an object'])
+    expect(validateStructuredValue(schema, [])).toEqual(['"value" must be an object'])
+  })
+
+  it('type-checks every scalar branch with path-qualified messages', () => {
+    expect(validateStructuredValue(schema, { file: 1 })).toEqual(['"value.file" must be a string'])
+    expect(validateStructuredValue(schema, { file: 'a', line: 1.5 })).toEqual(['"value.line" must be an integer'])
+    expect(validateStructuredValue(schema, { file: 'a', line: 'x' })).toEqual(['"value.line" must be an integer'])
+    expect(validateStructuredValue(schema, { file: 'a', score: 'x' })).toEqual(['"value.score" must be a finite number'])
+    expect(validateStructuredValue(schema, { file: 'a', score: Number.NaN })).toEqual(['"value.score" must be a finite number'])
+    expect(validateStructuredValue(schema, { file: 'a', confirmed: 'yes' })).toEqual(['"value.confirmed" must be a boolean'])
+    expect(validateStructuredValue(schema, { file: 'a', parent: 0 })).toEqual(['"value.parent" must be null'])
+  })
+
+  it('enforces enum membership and const equality', () => {
+    expect(validateStructuredValue(schema, { file: 'a', severity: 'mid' }))
+      .toEqual(['"value.severity" must be one of ["low","high"]'])
+    expect(validateStructuredValue(schema, { file: 'a', kind: 'feature' }))
+      .toEqual(['"value.kind" must be "bug"'])
+  })
+
+  it('checks arrays per index; an items-less array accepts anything', () => {
+    expect(validateStructuredValue(schema, { file: 'a', tags: 'x' })).toEqual(['"value.tags" must be an array'])
+    expect(validateStructuredValue(schema, { file: 'a', tags: ['ok', 2] })).toEqual(['"value.tags[1]" must be a string'])
+    expect(validateStructuredValue(schema, { file: 'a', free: [{ deep: [1] }, null] })).toEqual([])
+  })
+
+  it('recurses into nested objects: required + additionalProperties: false', () => {
+    expect(validateStructuredValue(schema, { file: 'a', nested: {} }))
+      .toEqual(['missing required property "value.nested.x"'])
+    expect(validateStructuredValue(schema, { file: 'a', nested: { x: 1, y: 2 } }))
+      .toEqual(['"value.nested.y" is not a declared property (additionalProperties: false)'])
+    expect(validateStructuredValue(schema, { file: 'a', nested: 3 }))
+      .toEqual(['"value.nested" must be an object'])
+  })
+
+  it('a required key present-but-undefined counts as missing', () => {
+    expect(validateStructuredValue(schema, { file: undefined })).toEqual(['missing required property "value.file"'])
+  })
+
+  it('collects multiple violations across branches in one pass', () => {
+    expect(validateStructuredValue(schema, { line: 'x', severity: 'mid' })).toEqual([
+      'missing required property "value.file"',
+      '"value.line" must be an integer',
+      '"value.severity" must be one of ["low","high"]',
+    ])
+  })
+
+  it('null-typed const/enum work through the scalar path', () => {
+    const nullish = asserted({ type: 'object', properties: { a: { type: 'null', const: null } } })
+    expect(validateStructuredValue(nullish, { a: null })).toEqual([])
+  })
+
+  it('rejects a non-object properties value in the schema walk', () => {
+    expect(violationsOf({ type: 'object', properties: [] }))
+      .toEqual(['schema.properties must be an object of schemas'])
+  })
+
+  it('an object schema without properties/required only type-checks its value', () => {
+    const bare = asserted({ type: 'object' })
+    expect(validateStructuredValue(bare, { any: ['thing'] })).toEqual([])
+    expect(validateStructuredValue(bare, 7)).toEqual(['"value" must be an object'])
+  })
+
+  it('validateStructuredValue throws on a type the assert would never let through (assertNever backstop)', () => {
+    const forged = { type: 'tuple' } as unknown as StructuredOutputSchema
+    expect(() => validateStructuredValue(forged, 1)).toThrow(/tuple/)
+  })
+})

+ 2 - 1
packages/subagent/subagent-fork/README.md

@@ -12,12 +12,13 @@ The seam this rides on: `CreateAgentOptions.seed` (added on `dsh-agent`, threade
 
 ## Capabilities
 
-`{ outputSchema: false, depthLimit: true, toolFilter: false }` — identical to spawn (the depth/model/output behavior is the shared driver's).
+`{ outputSchema: true, depthLimit: true, toolFilter: false }` — identical to spawn (the depth/model/structured-output behavior is the shared driver's).
 
 ## Config
 
 | Key | Meaning |
 |---|---|
 | `providerName` | Registry name on `ctx.subagents` (default `fork`). |
+| `structuredNudgeRetries` | How many times a structured run re-prompts a child that finished cleanly without calling `structured_output` (default 1). |
 
 See [`dsh-subagent-spawn`](../subagent-spawn/README.md) for the run lifecycle, model inheritance, and depth tracking — all shared.

+ 30 - 7
packages/subagent/subagent-fork/src/index.ts

@@ -25,19 +25,29 @@ import z from 'schemastery'
 import type { SessionEvent } from '@deepseek-ai/dsh-session'
 import type { Agent } from '@deepseek-ai/dsh-agent'
 import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
-import { startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess'
+import { acquireStructuredRuntime, startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess'
 
 export const name = 'subagent-fork'
+// `tools` is deliberately NOT injected — same rationale as subagent-spawn: the
+// structured runtime gates its capture-tool registration on `tools` itself, so
+// this backend's apply timing (and the delegation tool's position in the
+// model-visible tool list) is unchanged by structured output.
 export const inject = ['subagents', 'agents']
 
-/** Config: the registry name to register the provider under. */
+/** Config: the registry name to register the provider under, plus structured-run tuning. */
 export interface Config {
   /** Provider name on `ctx.subagents` (default `fork`). */
   providerName: string
+  /**
+   * How many times a structured run re-prompts a child that finished cleanly
+   * without calling `structured_output` before giving up (default 1).
+   */
+  structuredNudgeRetries: number
 }
 
 export const Config: z<Config> = z.object({
   providerName: z.string().default('fork'),
+  structuredNudgeRetries: z.natural().default(1),
 })
 
 /**
@@ -57,20 +67,26 @@ export function completedTurnPrefix(parent: Agent): SessionEvent[] {
 }
 
 /**
- * The fork provider. Supports `depthLimit`; NOT `outputSchema`/`toolFilter` this
- * cut (the service rejects a request needing either before `start` runs).
+ * The fork provider. Supports `depthLimit` and `outputSchema` (via the shared
+ * in-process structured runtime); NOT `toolFilter` this cut (the service
+ * rejects a request needing it before `start` runs).
  */
 class ForkProvider implements SubagentProvider {
-  readonly capabilities: SubagentCapabilities = { outputSchema: false, depthLimit: true, toolFilter: false }
+  readonly capabilities: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: false }
   // Context contract: a forked child IS seeded with the parent's completed-turn prefix.
   readonly inheritsParentContext = true
 
-  constructor(readonly name: string, private readonly ctx: Context) {}
+  constructor(
+    readonly name: string,
+    private readonly ctx: Context,
+    private readonly structuredNudgeRetries: number,
+  ) {}
 
   start(request: SubagentStartRequest) {
     const seed = completedTurnPrefix(request.parent)
     return startInProcessRun(this.ctx, request, {
       providerName: this.name,
+      structuredNudgeRetries: this.structuredNudgeRetries,
       // Only pass a seed when there's a completed turn to inherit; an empty seed
       // is equivalent to a fresh child, so omit it to keep the session unseeded.
       ...seed.length > 0 ? { seed } : {},
@@ -79,5 +95,12 @@ class ForkProvider implements SubagentProvider {
 }
 
 export function apply(ctx: Context, config: Config): void {
-  ctx.subagents.registerProvider(new ForkProvider(config.providerName, ctx))
+  // Hold the structured runtime for the plugin's lifetime (see the spawn
+  // backend — same two-level lifetime: backends for availability, runs for
+  // mid-run survival across a backend unload).
+  ctx.effect(() => {
+    const acquisition = acquireStructuredRuntime(ctx)
+    return () => { acquisition.release() }
+  }, 'subagent-fork structured runtime')
+  ctx.subagents.registerProvider(new ForkProvider(config.providerName, ctx, config.structuredNudgeRetries))
 }

+ 2 - 2
packages/subagent/subagent-fork/tests/multi-subagent.spec.ts

@@ -30,8 +30,8 @@ async function setup(script: Script) {
   await ctx.plugin(Invariants)
   await ctx.plugin(AgentLoop, { agents: [] })
   await ctx.plugin(SubagentService)
-  await ctx.plugin(Spawn, { providerName: 'spawn' })
-  await ctx.plugin(fork, { providerName: 'fork' })
+  await ctx.plugin(Spawn, { providerName: 'spawn', structuredNudgeRetries: 1 })
+  await ctx.plugin(fork, { providerName: 'fork', structuredNudgeRetries: 1 })
   ctx.llm.registerAdapter(['mock'], new MockAdapter(script))
   const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' })
   return { ctx, parent }

+ 10 - 4
packages/subagent/subagent-fork/tests/subagent-fork.spec.ts

@@ -37,7 +37,7 @@ async function setup(script: Script) {
   await ctx.plugin(Invariants)
   await ctx.plugin(AgentLoop, { agents: [] })
   await ctx.plugin(SubagentService)
-  await ctx.plugin(fork, { providerName: 'fork' })
+  await ctx.plugin(fork, { providerName: 'fork', structuredNudgeRetries: 1 })
   ctx.llm.registerAdapter(['mock'], new MockAdapter(script))
   const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' })
   return { ctx, parent }
@@ -161,16 +161,22 @@ describe('dsh-subagent-fork', () => {
     await run.dispose()
   })
 
-  it('advertises depthLimit but not outputSchema/toolFilter', async () => {
+  it('advertises depthLimit and outputSchema but not toolFilter', async () => {
     const { ctx } = await setup([])
-    expect(ctx.subagents.getProvider('fork')!.capabilities).toEqual({ outputSchema: false, depthLimit: true, toolFilter: false })
+    expect(ctx.subagents.getProvider('fork')!.capabilities).toEqual({ outputSchema: true, depthLimit: true, toolFilter: false })
   })
 
   it('unregisters the provider when its fiber is disposed (HMR safety)', async () => {
     const ctx = new Context()
     await ctx.plugin(SubagentService)
     await ctx.plugin(AgentRegistry)
-    const fiber = await ctx.plugin(fork, { providerName: 'fork' })
+    // The backend does NOT inject 'tools' (the structured runtime gates its
+    // capture-tool registration on tools availability itself, keeping backend
+    // apply timing — and the delegation tool's prompt position — unchanged);
+    // the registries are loaded here so the runtime registers eagerly anyway.
+    await ctx.plugin(SystemPrompt)
+    await ctx.plugin(ToolRegistry)
+    const fiber = await ctx.plugin(fork, { providerName: 'fork', structuredNudgeRetries: 1 })
     expect(ctx.subagents.list()).toEqual(['fork'])
     await fiber.dispose()
     expect(ctx.subagents.list()).toEqual([])

+ 16 - 5
packages/subagent/subagent-inprocess/README.md

@@ -8,16 +8,27 @@ The shared **in-process subagent run driver**. A pure library (no provider, no r
 
 Runs a child as a child [`Agent`](../../core/agent) on the same cordis context (`ctx.agents`):
 
-1. computes child depth = `depthOf(parent) + 1`; if `request.maxDepth` is set and exceeded, throws `SubagentDepthError` (the `depthLimit` capability);
-2. creates a child via `ctx.agents.create` with a fresh `AgentId`/`SessionId`, the parent's `cwd` + `parentSession` lineage, the optional `options.seed` (fork's completed-turn prefix; omitted for a fresh child), and `agentOptions` (the child inherits the **parent's model** by default — a child with no model can't run — overridable via `request.agentOptions.model`; the system prompt is NOT inherited);
-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);
-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`.
+1. computes child depth = `depthOf(parent) + 1`; if `request.maxDepth` is set and exceeded, throws `SubagentDepthError` (the `depthLimit` capability); a `request.outputSchema` is asserted against the supported subset (`assertSupportedOutputSchema` from [dsh-tools](../../core/tools/README.md)) before any child exists;
+2. creates a child via `ctx.agents.create` with a fresh `AgentId`/`SessionId`, the parent's `cwd` + `parentSession` lineage, the optional `options.seed` (fork's completed-turn prefix; omitted for a fresh child), and `agentOptions` (the child inherits the **parent's model** by default — a child with no model can't run — overridable via `request.agentOptions.model`; the deployment persona needs no inheritance — it is a context-wide prompt section);
+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); a structured child that finished a turn CLEANLY without calling `structured_output` is re-prompted (a nudge — a fresh turn) up to `options.structuredNudgeRetries` times;
+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).
 
 `dispose()` delegates to `AgentHandle.dispose()` (stop loop → await quiescence → remove session); `cancel()` cancels the child's in-flight turn. A cancel landing before any `turn/end` (the pre-turn window) still settles `aborted`, honoring the cancel contract rather than the generic no-turn `error`.
 
 ### `InProcessRunOptions`
 
-`{ providerName: string; seed?: SessionEvent[] }` — the per-backend inputs: the provider name (for error context) and the optional child-session seed.
+`{ providerName: string; seed?: SessionEvent[]; structuredNudgeRetries: number }` — the per-backend inputs: the provider name (for error context), the optional child-session seed, and the structured-run nudge budget (REQUIRED, resolved from the backend's validated Config — the driver never fills it with a hidden default).
+
+### Structured output: `acquireStructuredRuntime(ctx): StructuredAcquisition`
+
+The mechanism behind `outputSchema` for in-process children. One globally registered `structured_output` capture tool (its registered parameters are a placeholder) plus two listeners, registered once per root context and shared by every holder:
+
+- an `agent/request` waterfall listener registered `prepend: true` that post-processes `await next()` — **final-request enforcement**: the request that hits the wire never carries `structured_output` for an agent without a structured run, and for one that has it always carries the run's OWN schema (as the tool's `parameters`) plus the calling instruction appended to its `system` text (the demand travels with the tool — `AgentOptions` has no per-agent prompt field to carry it). Per-agent shaping lives here because the tool registry and prompt assembly are context-global while schemas differ per concurrent child; cooperative mutate-then-`next()` would not survive a downstream listener returning a replacement request.
+- an `agent/turn-continuation` listener (also `prepend: true` — an earlier-registered force-continue listener returning without `next()` must not decide the turn before the veto runs) that stops a child's turn once its output is captured, so a successful capture doesn't buy a wasted extra model step.
+
+The capture tool validates each call against the run's schema (`validateStructuredValue`) — violations become an `INVALID_ARGS` isError result the model retries in-turn; a valid call records the value.
+
+Lifetime is refcounted with two kinds of holder: each backend acquires for its plugin lifetime (`apply`), and each structured RUN holds its own acquisition from start to settle — so unregistration can never precede a live run's settle, and the runtime disposes only when the last backend AND the last run are gone. `release()` is idempotent per acquisition.
 
 ### `depthOf(agent): number`
 

+ 4 - 0
packages/subagent/subagent-inprocess/package.json

@@ -26,6 +26,8 @@
     "@deepseek-ai/dsh-llm": "^0.0.1",
     "@deepseek-ai/dsh-session": "^0.0.1",
     "@deepseek-ai/dsh-subagent": "^0.0.1",
+    "@deepseek-ai/dsh-system-prompt": "^0.0.1",
+    "@deepseek-ai/dsh-tools": "^0.0.1",
     "cordis": "^4.0.0-rc.6"
   },
   "devDependencies": {
@@ -35,6 +37,8 @@
     "@deepseek-ai/dsh-llm": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-subagent": "workspace:^",
+    "@deepseek-ai/dsh-subagent-fork": "workspace:^",
+    "@deepseek-ai/dsh-subagent-spawn": "workspace:^",
     "@deepseek-ai/dsh-system-prompt": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^",
     "cordis": "^4.0.0-rc.6"

+ 94 - 5
packages/subagent/subagent-inprocess/src/index.ts

@@ -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 }
 }

+ 239 - 0
packages/subagent/subagent-inprocess/src/structured.ts

@@ -0,0 +1,239 @@
+/**
+ * Structured-output support for the in-process subagent backends: the mechanism
+ * behind `SubagentStartRequest.outputSchema` for children that run as agents on
+ * the same context.
+ *
+ * The model-facing surface is one globally registered `structured_output` tool
+ * whose REGISTERED parameters are a placeholder — the real schema is per run.
+ * Because the tool registry and prompt assembly are context-global while
+ * schemas differ per child (two concurrent structured runs may carry different
+ * schemas), per-agent shaping happens on the `system-prompt/assemble`
+ * waterfall with a `prepend: true` listener that post-processes `await next()`
+ * — FINAL-ASSEMBLY enforcement: whatever downstream listeners mutated or
+ * replaced, the assembly the loop renders never carries `structured_output`
+ * for an agent without a structured run, and for one that has it always
+ * carries the run's OWN schema plus a trailing
+ * {@link STRUCTURED_OUTPUT_INSTRUCTION} section (the demand travels with the
+ * tool). The loop logs what the assembly produced as the request header, so
+ * the injection is a reconstructable fact of the session log, never a
+ * wire-only mutation (the reconstructability RFC).
+ * (Cooperative mutate-then-`next()` would not survive a downstream listener
+ * returning a replacement assembly — see the waterfall composition caveat in
+ * docs/architecture.md.)
+ *
+ * A companion `agent/turn-continuation` listener stops a child's turn once its
+ * output is captured — without it, the loop's default "had tool calls ⇒
+ * continue" buys a wasted extra model step per structured child. It is also
+ * `prepend: true`: the veto must run before any earlier-registered listener
+ * that could short-circuit the chain into a forced continue.
+ *
+ * Lifetime is refcounted with two kinds of holder: each backend acquires for
+ * its plugin lifetime (so the tool exists before any run), and each structured
+ * RUN acquires from start to settle (so a backend hot-reload mid-run cannot
+ * unregister the capture tool out from under a live child). Registrations are
+ * effects on the ROOT context — their natural upper bound is app teardown — and
+ * the refcount disposes them when the last holder releases.
+ *
+ * @module @deepseek-ai/dsh-subagent-inprocess/structured
+ */
+
+import type { Context } from 'cordis'
+import type { Agent } from '@deepseek-ai/dsh-agent'
+import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
+import type { ContinuationDecision } from '@deepseek-ai/dsh-agent'
+import type { AssembleContext, PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
+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. */
+export const STRUCTURED_OUTPUT_TOOL = 'structured_output'
+
+/**
+ * The instruction the assembly listener appends to a structured child's
+ * system prompt as a trailing section on every assembly. Per-assembly state,
+ * NOT agent prompt state: `AgentOptions` has no prompt field (the persona is
+ * deployment config on the system-prompt plugin), so the same final-assembly
+ * enforcement that injects the schema'd tool carries the instruction that
+ * demands calling it.
+ */
+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.'
+
+/** The nudge sent when a structured child finishes cleanly without calling the tool. */
+export const STRUCTURED_OUTPUT_NUDGE
+  = `You finished without calling \`${STRUCTURED_OUTPUT_TOOL}\`. `
+    + `Call \`${STRUCTURED_OUTPUT_TOOL}\` now with your final result matching its parameter schema.`
+
+/** One structured run's state: the schema to enforce and the captured value, once recorded. */
+interface RunState {
+  readonly schema: StructuredOutputSchema
+  captured?: { value: unknown }
+}
+
+/** The per-root-context runtime: run states plus the shared registrations. */
+interface StructuredRuntime {
+  refs: number
+  readonly states: WeakMap<Agent, RunState>
+  readonly disposers: (() => void)[]
+}
+
+/** One root context ⇒ one runtime (multi-app test isolation). */
+const runtimes = new WeakMap<Context, StructuredRuntime>()
+
+/**
+ * One holder's handle on the shared structured runtime. `release()` is
+ * idempotent per acquisition; the runtime's registrations are disposed when the
+ * LAST holder (backend plugin or live run) releases.
+ */
+export interface StructuredAcquisition {
+  /** Enforce `schema` on `agent`'s requests and start capturing its `structured_output` call. */
+  attach(agent: Agent, schema: StructuredOutputSchema): void
+  /** The captured value, once the child called the tool with valid arguments. */
+  captured(agent: Agent): { value: unknown } | undefined
+  /** Stop enforcing/capturing for `agent` (WeakMap-backed; safe to call twice). */
+  detach(agent: Agent): void
+  /** Drop this holder's reference (idempotent); the last release unregisters everything. */
+  release(): void
+}
+
+/**
+ * Acquire the per-root-context structured runtime, registering the capture tool
+ * and the two waterfall listeners on the FIRST acquisition. See the module doc
+ * for the enforcement and lifetime design.
+ * @param ctx - any context of the app; the runtime keys off `ctx.root`.
+ * @returns this holder's handle (attach/captured/detach + idempotent release).
+ */
+export function acquireStructuredRuntime(ctx: Context): StructuredAcquisition {
+  const root: Context = ctx.root
+  let runtime = runtimes.get(root)
+  if (!runtime) {
+    runtime = { refs: 0, states: new WeakMap(), disposers: [] }
+    runtimes.set(root, runtime)
+    registerRuntime(root, runtime)
+  }
+  runtime.refs += 1
+
+  let released = false
+  return {
+    attach(agent: Agent, schema: StructuredOutputSchema): void {
+      runtime.states.set(agent, { schema })
+    },
+    captured(agent: Agent): { value: unknown } | undefined {
+      return runtime.states.get(agent)?.captured
+    },
+    detach(agent: Agent): void {
+      runtime.states.delete(agent)
+    },
+    release(): void {
+      if (released) return
+      released = true
+      runtime.refs -= 1
+      if (runtime.refs > 0) return
+      runtimes.delete(root)
+      for (const dispose of runtime.disposers.splice(0)) dispose()
+    },
+  }
+}
+
+/** Register the capture tool + the two listeners on the root context (first acquire). */
+function registerRuntime(root: Context, runtime: StructuredRuntime): void {
+  // The registered parameters are a PLACEHOLDER: the request listener below
+  // swaps in the run's real schema per child, and strips the tool entirely for
+  // every agent without a structured run — so this shape is never model-visible.
+  //
+  // Registration does NOT ride on the acquiring backend's plugin-level
+  // `inject`: a backend that waited on `tools` would apply later than it did
+  // before this module existed, shifting when its PROVIDER registers — and the
+  // delegation tool mirrors provider lifecycle, so that shift would reorder
+  // the model-visible tool list of every existing prompt. Instead the capture
+  // tool registers synchronously when `tools` is already live (the common
+  // case), and through a scoped inject fiber when the Loader happens to start
+  // the backend first. Either way the registration lands on root and is
+  // disposed by the runtime's refcount; disposing the fiber also covers the
+  // never-activated case.
+  let disposeTool: (() => void) | undefined
+  const registerCapture = (tools: Context['tools']): void => {
+    disposeTool = tools.register({
+      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.',
+      parameters: { type: 'object', properties: {} },
+      execute(args: unknown, exec: ToolExecution): Promise<ContentBlock[]> {
+        const state = exec.agent ? runtime.states.get(exec.agent) : undefined
+        if (!state) {
+          // Reachable only if a non-structured agent somehow calls the tool (the
+          // request listener strips it, so the model never sees it) — fail loud
+          // rather than capture into nowhere.
+          throw new Error(`${STRUCTURED_OUTPUT_TOOL} is only available to subagents started with an output schema`)
+        }
+        const violations = validateStructuredValue(state.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)
+        state.captured = { value: args }
+        return Promise.resolve([{ type: 'text', text: 'Structured output recorded.' }])
+      },
+    })
+  }
+  const liveTools = root.get('tools')
+  const toolsFiber = liveTools ? undefined : root.inject(['tools'], (childCtx: Context) => {
+    registerCapture(childCtx.root.tools)
+  })
+  if (liveTools) registerCapture(liveTools)
+  runtime.disposers.push(() => {
+    disposeTool?.()
+    void toolsFiber?.dispose()
+  })
+
+  // FINAL-ASSEMBLY enforcement (prepend: true = first registered = OUTERMOST
+  // wrapper): post-process whatever the downstream listeners and the registry
+  // produced, so a downstream listener returning a replacement assembly cannot
+  // leak the tool to other agents or erase the child's schema. The loop logs
+  // the rendered assembly as the step's request header, so the swap is
+  // reconstructable log state, never a wire-only mutation.
+  runtime.disposers.push(root.on('system-prompt/assemble', async function (
+    this: unknown, _assembly: PromptAssembly, context: AssembleContext, next: () => Promise<PromptAssembly>,
+  ): Promise<PromptAssembly> {
+    const final = await next()
+    const state = context.agent ? runtime.states.get(context.agent) : undefined
+    if (state) {
+      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: state.schema as unknown as Record<string, unknown>,
+      }
+      final.tools = [...final.tools.filter(tool => tool.name !== STRUCTURED_OUTPUT_TOOL), schemaEntry]
+      // The demand travels WITH the tool: a trailing section in the
+      // tool-guidance order band, appended after next() so it renders last
+      // (renderPrompt joins in array order).
+      final.sections = [...final.sections, { name: `tool:${STRUCTURED_OUTPUT_TOOL}`, order: 190, text: STRUCTURED_OUTPUT_INSTRUCTION }]
+      return final
+    }
+    // No structured run: strip the placeholder so it is never model-visible.
+    // An empty tools array canonicalizes to an absent header/wire field
+    // (canonicalHeader pins empty ≡ absent), so no re-shaping is needed here.
+    final.tools = final.tools.filter(tool => tool.name !== STRUCTURED_OUTPUT_TOOL)
+    return final
+  }, { prepend: true }))
+
+  // Stop a structured child's turn once its output is captured: the default
+  // "had tool calls ⇒ continue" would otherwise buy a wasted extra model step
+  // after every successful capture. `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 already finished.
+  runtime.disposers.push(root.on('agent/turn-continuation', function (
+    this: unknown, agent: Agent, _turn: number, _decision: ContinuationDecision, next: () => Promise<ContinuationDecision>,
+  ): Promise<ContinuationDecision> {
+    if (runtime.states.get(agent)?.captured) return Promise.resolve({ action: 'stop' })
+    return next()
+  }, { prepend: true }))
+}

+ 485 - 0
packages/subagent/subagent-inprocess/tests/structured.spec.ts

@@ -0,0 +1,485 @@
+import { describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import LlmService, { type GenerateOptions } from '@deepseek-ai/dsh-llm'
+import SessionStore from '@deepseek-ai/dsh-session'
+import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
+import ToolRegistry from '@deepseek-ai/dsh-tools'
+import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
+import type { Agent, ContinuationDecision } from '@deepseek-ai/dsh-agent'
+import AgentLoop from '@deepseek-ai/dsh-agent-loop'
+import * as Invariants from '@deepseek-ai/dsh-invariants'
+import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
+import type { StructuredOutputSchema } from '@deepseek-ai/dsh-tools'
+import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
+import * as spawn from '@deepseek-ai/dsh-subagent-spawn'
+import * as fork from '@deepseek-ai/dsh-subagent-fork'
+import {
+  acquireStructuredRuntime,
+  STRUCTURED_OUTPUT_INSTRUCTION,
+  STRUCTURED_OUTPUT_TOOL,
+} from '../src/structured.ts'
+
+type Script = ConstructorParameters<typeof MockAdapter>[0]
+
+const SCHEMA: StructuredOutputSchema = {
+  type: 'object',
+  properties: { answer: { type: 'number' }, note: { type: 'string' } },
+  required: ['answer'],
+}
+
+/**
+ * Real loop + scripted mock model + the REAL spawn backend (which acquires the
+ * structured runtime at apply, exactly as shipped). The mock model script
+ * drives the child's structured_output calls.
+ */
+async function setup(script: Script, options?: { nudges?: number; withFork?: boolean }) {
+  const ctx = new Context()
+  const adapter = new MockAdapter(script)
+  await ctx.plugin(LlmService)
+  await ctx.plugin(SessionStore)
+  await ctx.plugin(SystemPrompt)
+  await ctx.plugin(ToolRegistry)
+  await ctx.plugin(AgentRegistry)
+  await ctx.plugin(Invariants)
+  await ctx.plugin(AgentLoop, { agents: [] })
+  await ctx.plugin(SubagentService)
+  const fiber = await ctx.plugin(spawn, { providerName: 'spawn', structuredNudgeRetries: options?.nudges ?? 1 })
+  const forkFiber = options?.withFork
+    ? await ctx.plugin(fork, { providerName: 'fork', structuredNudgeRetries: options?.nudges ?? 1 })
+    : undefined
+  ctx.llm.registerAdapter(['mock'], adapter)
+  const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' })
+  return { ctx, parent, adapter, fiber, forkFiber }
+}
+
+function structuredRequest(parent: SubagentStartRequest['parent'], extra?: Partial<SubagentStartRequest>): SubagentStartRequest {
+  return { prompt: [{ type: 'text', text: 'produce the answer' }], parent, outputSchema: SCHEMA, ...extra }
+}
+
+/** The tool names of one recorded model request. */
+function toolNames(request: GenerateOptions): string[] {
+  return (request.tools ?? []).map(tool => tool.name)
+}
+
+describe('in-process structured output', () => {
+  it('captures a valid structured_output call and surfaces result.structured', async () => {
+    const { ctx, parent } = await setup([
+      toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 42, note: 'done' }),
+    ])
+    const run = ctx.subagents.start('spawn', structuredRequest(parent))
+    const result = await run.result
+    expect(result.stopReason).toBe('completed')
+    expect(result.structured).toEqual({ answer: 42, note: 'done' })
+    await run.dispose()
+  })
+
+  it('stops the turn after a successful capture — no extra model step is spent', async () => {
+    const { ctx, parent, adapter } = await setup([
+      toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
+      textResponse('MUST NOT BE CONSUMED'),
+    ])
+    const run = ctx.subagents.start('spawn', structuredRequest(parent))
+    await run.result
+    // Default continuation would run a second step after the tool call; the
+    // structured runtime's turn-continuation veto stops the turn instead.
+    expect(adapter.requests.length).toBe(1)
+    await run.dispose()
+  })
+
+  it('the captured-turn veto is prepend: an EARLIER force-continue listener cannot short-circuit it', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SystemPrompt)
+    await ctx.plugin(ToolRegistry)
+    // Registered BEFORE the structured runtime exists — without prepend, this
+    // goal-style listener would decide the turn first (returning WITHOUT
+    // calling next()) and the veto would never run.
+    ctx.on('agent/turn-continuation', () => Promise.resolve<ContinuationDecision>({ action: 'continue' }))
+    const acquisition = acquireStructuredRuntime(ctx)
+    const agent = { id: AgentId('structured-child') } as unknown as Agent
+    acquisition.attach(agent, SCHEMA)
+    const captured = await ctx.tools.execute({
+      callId: 'call-1' as never,
+      name: STRUCTURED_OUTPUT_TOOL,
+      arguments: { answer: 1 },
+      agent,
+    })
+    expect(captured.isError).toBeFalsy()
+    const decision = await ctx.waterfall(
+      'agent/turn-continuation', agent, 1,
+      { action: 'continue' },
+      () => Promise.resolve<ContinuationDecision>({ action: 'continue' }),
+    )
+    expect(decision).toEqual({ action: 'stop' })
+    acquisition.detach(agent)
+    acquisition.release()
+  })
+
+  it('an invalid call gets an INVALID_ARGS isError result and the model retries in-turn', async () => {
+    const { ctx, parent } = await setup([
+      toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 'not-a-number' }),
+      toolCallResponse('c2', STRUCTURED_OUTPUT_TOOL, { answer: 7 }),
+    ])
+    const run = ctx.subagents.start('spawn', structuredRequest(parent))
+    const result = await run.result
+    expect(result.structured).toEqual({ answer: 7 })
+    expect(result.stopReason).toBe('completed')
+    // The child's log carries the isError tool/result for the invalid call.
+    const child = ctx.agents.get(run.id)!
+    const results = child.session.events.filter(e => e.type === 'tool/result')
+    expect(results.length).toBe(2)
+    expect((results[0]!.data as { isError?: boolean }).isError).toBe(true)
+    await run.dispose()
+  })
+
+  it('nudges a child that finished cleanly without calling the tool, then captures', async () => {
+    const { ctx, parent } = await setup([
+      textResponse('here is my answer in prose'),
+      toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 3 }),
+    ])
+    const run = ctx.subagents.start('spawn', structuredRequest(parent))
+    const result = await run.result
+    expect(result.structured).toEqual({ answer: 3 })
+    expect(result.stopReason).toBe('completed')
+    // The nudge is a real user-visible message in the child's log.
+    const child = ctx.agents.get(run.id)!
+    const users = child.session.events.filter(e => e.type === 'user/message')
+    expect(users.length).toBe(2)
+    await run.dispose()
+  })
+
+  it('settles error when the nudges run out without a capture', async () => {
+    const { ctx, parent, adapter } = await setup([
+      textResponse('prose only'),
+      textResponse('still prose'),
+    ], { nudges: 1 })
+    const run = ctx.subagents.start('spawn', structuredRequest(parent))
+    const result = await run.result
+    expect(result.stopReason).toBe('error')
+    expect(result.structured).toBeUndefined()
+    expect(adapter.requests.length).toBe(2)
+    await run.dispose()
+  })
+
+  it('zero nudge retries fails immediately after the first clean prose finish', async () => {
+    const { ctx, parent, adapter } = await setup([textResponse('prose')], { nudges: 0 })
+    const run = ctx.subagents.start('spawn', structuredRequest(parent))
+    const result = await run.result
+    expect(result.stopReason).toBe('error')
+    expect(adapter.requests.length).toBe(1)
+    await run.dispose()
+  })
+
+  it('a child that errored is NOT nudged (its failure is the honest result)', async () => {
+    // Script exhaustion on the first call → the child turn errors.
+    const { ctx, parent, adapter } = await setup([], { nudges: 3 })
+    const run = ctx.subagents.start('spawn', structuredRequest(parent))
+    const result = await run.result
+    expect(result.stopReason).toBe('error')
+    expect(adapter.requests.length).toBe(1)
+    await run.dispose()
+  })
+
+  it('a cancel landing after a clean turn end stops the nudge loop: no post-cancellation turn is spent', async () => {
+    const { ctx, parent, adapter } = await setup([textResponse('prose, no capture')], { nudges: 3 })
+    const run = ctx.subagents.start('spawn', structuredRequest(parent))
+    const child = ctx.agents.get(run.id)!
+    // Cancel synchronously inside the first turn's end recording — after the
+    // turn reads `completed`, before the nudge continuation resumes. The turn
+    // state alone cannot see this cancel (`child.cancel()` only clears
+    // queued/running work), so without the loop's own cancelled check the
+    // next send would spend a fresh child turn after the caller cancelled.
+    ctx.on('session/event', (session, event) => {
+      if (session === child.session && event.type === 'turn/end') run.cancel('cancelled between turn end and nudge')
+    })
+    const result = await run.result
+    expect(result.stopReason).toBe('aborted')
+    // Exactly one model request: the nudge turn never ran.
+    expect(adapter.requests.length).toBe(1)
+    await run.dispose()
+  })
+
+  it('rejects a schema outside the subset loud, before any child exists', async () => {
+    const { ctx, parent } = await setup([])
+    expect(() => ctx.subagents.start('spawn', structuredRequest(parent, {
+      outputSchema: { type: 'object', oneOf: [] } as unknown as StructuredOutputSchema,
+    }))).toThrow(/unsupported output schema/)
+    expect(ctx.agents.get(AgentId('parent'))).toBeDefined()
+  })
+
+  it('appends the structured instruction to the child REQUEST\'s system text (base prompt preserved)', async () => {
+    const { ctx, parent, adapter } = await setup([toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 })])
+    // A context-wide section stands in for the deployment persona: the
+    // instruction must APPEND to whatever the prompt pipeline assembled, not
+    // replace it (AgentOptions has no prompt field — the instruction is
+    // per-request wire state added by the final-request listener).
+    ctx.systemPrompt.section({ name: 'test:persona', order: 10, text: 'You are a counter.' })
+    const run = ctx.subagents.start('spawn', structuredRequest(parent))
+    await run.result
+    const childRequest = adapter.requests.at(-1)!
+    expect(childRequest.system).toContain('You are a counter.')
+    expect(childRequest.system!.endsWith(STRUCTURED_OUTPUT_INSTRUCTION)).toBe(true)
+    expect(childRequest.system!.indexOf(STRUCTURED_OUTPUT_INSTRUCTION)).toBeGreaterThan(0)
+    await run.dispose()
+  })
+
+  it('the instruction rides ONLY structured requests: appended for the child, absent for a plain agent', async () => {
+    const { ctx, parent, adapter } = await setup([
+      textResponse('parent answer'),
+      toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
+    ])
+    parent.send([{ type: 'text', text: 'hello' }])
+    await parent.whenIdle()
+    expect(adapter.requests[0]!.system ?? '').not.toContain(STRUCTURED_OUTPUT_INSTRUCTION)
+    const run = ctx.subagents.start('spawn', structuredRequest(parent))
+    await run.result
+    // The loop always assembles a base prompt (the harness identity section),
+    // so the instruction APPENDS — never replaces.
+    const childSystem = adapter.requests.at(-1)!.system!
+    expect(childSystem.endsWith(STRUCTURED_OUTPUT_INSTRUCTION)).toBe(true)
+    expect(childSystem.length).toBeGreaterThan(STRUCTURED_OUTPUT_INSTRUCTION.length)
+    await run.dispose()
+  })
+
+  describe('final-request enforcement (the prepend agent/request listener)', () => {
+    it('a structured child sees structured_output with ITS schema; a plain agent never sees the tool', async () => {
+      const { ctx, parent, adapter } = await setup([
+        // Parent turn (a plain agent): must NOT see the tool.
+        textResponse('parent answer'),
+        // Child turn: must see it, with the run's schema.
+        toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 42 }),
+      ])
+      parent.send([{ type: 'text', text: 'hello' }])
+      await parent.whenIdle()
+      expect(toolNames(adapter.requests[0]!)).not.toContain(STRUCTURED_OUTPUT_TOOL)
+
+      const run = ctx.subagents.start('spawn', structuredRequest(parent))
+      await run.result
+      const childRequest = adapter.requests[1]!
+      expect(toolNames(childRequest)).toContain(STRUCTURED_OUTPUT_TOOL)
+      const entry = childRequest.tools!.find(tool => tool.name === STRUCTURED_OUTPUT_TOOL)!
+      expect(entry.parameters).toEqual(SCHEMA)
+      await run.dispose()
+    })
+
+    it('two concurrent structured children each see their OWN schema', async () => {
+      const otherSchema: StructuredOutputSchema = {
+        type: 'object',
+        properties: { verdict: { type: 'string', enum: ['real', 'bogus'] } },
+        required: ['verdict'],
+      }
+      const { ctx, parent, adapter } = await setup([
+        (options: GenerateOptions) => {
+          // Answer with whatever schema this child was given — proves each
+          // request carried the right one regardless of scheduling order.
+          const entry = options.tools!.find(tool => tool.name === STRUCTURED_OUTPUT_TOOL)!
+          const args = 'verdict' in (entry.parameters.properties as Record<string, unknown>)
+            ? { verdict: 'real' }
+            : { answer: 1 }
+          return toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, args)
+        },
+        (options: GenerateOptions) => {
+          const entry = options.tools!.find(tool => tool.name === STRUCTURED_OUTPUT_TOOL)!
+          const args = 'verdict' in (entry.parameters.properties as Record<string, unknown>)
+            ? { verdict: 'real' }
+            : { answer: 1 }
+          return toolCallResponse('c2', STRUCTURED_OUTPUT_TOOL, args)
+        },
+      ])
+      const runA = ctx.subagents.start('spawn', structuredRequest(parent))
+      const runB = ctx.subagents.start('spawn', structuredRequest(parent, { outputSchema: otherSchema }))
+      const [a, b] = await Promise.all([runA.result, runB.result])
+      expect(a.structured).toEqual({ answer: 1 })
+      expect(b.structured).toEqual({ verdict: 'real' })
+      const schemas = adapter.requests.map(request =>
+        request.tools!.find(tool => tool.name === STRUCTURED_OUTPUT_TOOL)!.parameters)
+      expect(schemas).toContainEqual(SCHEMA)
+      expect(schemas).toContainEqual(otherSchema)
+      await runA.dispose()
+      await runB.dispose()
+    })
+
+    it('wins against a downstream listener that REPLACES the assembly object', async () => {
+      const { ctx, parent, adapter } = await setup([
+        toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 5 }),
+      ])
+      // A downstream (non-prepend) listener that returns a brand-new assembly —
+      // the composition caveat that erases cooperative mutations. Registered
+      // AFTER the runtime's prepend listener, so it runs INSIDE it.
+      ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
+        const replaced = await next()
+        return { sections: [...replaced.sections], tools: [...replaced.tools], variables: { ...replaced.variables } }
+      })
+      const run = ctx.subagents.start('spawn', structuredRequest(parent))
+      const result = await run.result
+      expect(result.structured).toEqual({ answer: 5 })
+      const entry = adapter.requests[0]!.tools!.find(tool => tool.name === STRUCTURED_OUTPUT_TOOL)
+      expect(entry).toBeDefined()
+      expect(entry!.parameters).toEqual(SCHEMA)
+      await run.dispose()
+    })
+
+    it('a non-structured agent request keeps tools ABSENT when it had none (no tools: [] materialized)', async () => {
+      const { parent, adapter } = await setup([
+        // The registry contributes the placeholder via prompt assembly, so
+        // tools is an array in the raw request — but after stripping the
+        // placeholder (its ONLY entry), the field must not be re-added as a
+        // different shape.
+        textResponse('plain'),
+      ])
+      parent.send([{ type: 'text', text: 'q' }])
+      await parent.whenIdle()
+      const request = adapter.requests[0]!
+      expect(toolNames(request)).not.toContain(STRUCTURED_OUTPUT_TOOL)
+      await new Promise(resolve => setTimeout(resolve, 0))
+    })
+
+    it('shapes a bare assembly on the waterfall: no-agent context strips the placeholder; a structured agent gains schema + trailing instruction section', async () => {
+      // Drive ctx.systemPrompt.assemble directly — the enforcement listener
+      // must tolerate a context with NO agent (a bare diagnostic assemble)
+      // and shape a structured agent's assembly on the same path the loop
+      // renders and logs as the request header.
+      const { ctx, parent } = await setup([])
+      const bare = await ctx.systemPrompt.assemble({})
+      expect(bare.tools.map(tool => tool.name)).not.toContain(STRUCTURED_OUTPUT_TOOL)
+
+      const acquisition = acquireStructuredRuntime(ctx)
+      acquisition.attach(parent, SCHEMA)
+      const shaped = await ctx.systemPrompt.assemble({ agent: parent })
+      expect(shaped.tools.map(tool => tool.name)).toContain(STRUCTURED_OUTPUT_TOOL)
+      expect(shaped.tools.find(tool => tool.name === STRUCTURED_OUTPUT_TOOL)!.parameters).toEqual(SCHEMA)
+      // The demand travels with the tool: the instruction renders LAST
+      // (appended post-next(); renderPrompt joins in array order).
+      expect(shaped.sections.at(-1)).toMatchObject({ name: `tool:${STRUCTURED_OUTPUT_TOOL}`, text: STRUCTURED_OUTPUT_INSTRUCTION })
+      acquisition.detach(parent)
+      acquisition.release()
+    })
+  })
+
+  describe('runtime lifetime (refcount: backends + live runs)', () => {
+    it('registers the capture tool while a backend is loaded and unregisters when the last unloads', async () => {
+      const { ctx, fiber, forkFiber } = await setup([], { withFork: true })
+      expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeDefined()
+      await fiber.dispose()
+      // fork still holds a reference.
+      expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeDefined()
+      await forkFiber!.dispose()
+      expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
+    })
+
+    it('a live run-level acquisition keeps the runtime registered after EVERY backend unloads', async () => {
+      // Simulates the run-holder half of the two-level lifetime: a structured
+      // run acquires at start and releases at settle, so registration ordering
+      // is settle-then-unregister even if all backends unload first. (A real
+      // in-process child dies WITH its backend's fiber — the acquisition's
+      // observable job is this ordering, which a manual holder pins directly.)
+      const { ctx, fiber, forkFiber } = await setup([], { withFork: true })
+      const runHolder = acquireStructuredRuntime(ctx)
+      await fiber.dispose()
+      await forkFiber!.dispose()
+      expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeDefined()
+      runHolder.release()
+      expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
+    })
+
+    it('a structured run releases its acquisition when it settles (backend unload mid-run)', async () => {
+      const { ctx, parent, fiber } = await setup(['hang'])
+      const run = ctx.subagents.start('spawn', structuredRequest(parent))
+      // Let the child's step start streaming, then unload the backend. The
+      // backend owns the child agent, so the unload tears the child down and
+      // the run settles — releasing its own acquisition on the way out.
+      await new Promise(resolve => setTimeout(resolve, 30))
+      await fiber.dispose()
+      const result = await run.result
+      expect(result.stopReason).toBe('error')
+      // Both holders (backend + run) released — nothing keeps the runtime now.
+      expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
+      await run.dispose()
+    })
+
+    it('fork children capture structured output through the same runtime', async () => {
+      const { ctx, parent } = await setup([
+        toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 9 }),
+      ], { withFork: true })
+      const run = ctx.subagents.start('fork', structuredRequest(parent))
+      const result = await run.result
+      expect(result.structured).toEqual({ answer: 9 })
+      await run.dispose()
+    })
+
+    it('acquisition release is idempotent (double release cannot underflow the refcount)', async () => {
+      const ctx = new Context()
+      await ctx.plugin(SystemPrompt)
+      await ctx.plugin(ToolRegistry)
+      const first = acquireStructuredRuntime(ctx)
+      const second = acquireStructuredRuntime(ctx)
+      first.release()
+      first.release()
+      // The second holder still keeps the tool registered.
+      expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeDefined()
+      second.release()
+      expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
+    })
+
+    it('registers the capture tool through the scoped fiber when tools loads after the acquisition', async () => {
+      // The Loader starts sibling plugins concurrently, so a backend can
+      // acquire the runtime before dsh-tools has applied. The capture tool
+      // must then register as soon as `tools` exists — via the inject fiber,
+      // not by deferring the backend (which would reorder the prompt's tools).
+      const ctx = new Context()
+      const acquisition = acquireStructuredRuntime(ctx)
+      await ctx.plugin(SystemPrompt)
+      await ctx.plugin(ToolRegistry)
+      // Fiber activation completes asynchronously after the service appears.
+      await new Promise(resolve => setImmediate(resolve))
+      expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeDefined()
+      acquisition.release()
+      expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
+    })
+
+    it('releasing before tools ever loads disposes the pending fiber without registering', async () => {
+      const ctx = new Context()
+      const acquisition = acquireStructuredRuntime(ctx)
+      acquisition.release()
+      await ctx.plugin(SystemPrompt)
+      await ctx.plugin(ToolRegistry)
+      await new Promise(resolve => setImmediate(resolve))
+      // The disposed fiber never fires: nothing registers after the fact.
+      expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
+    })
+
+    it('attach/captured/detach manage per-agent state through the acquisition surface', async () => {
+      const { ctx, parent } = await setup([])
+      const acquisition = acquireStructuredRuntime(ctx)
+      expect(acquisition.captured(parent)).toBeUndefined()
+      acquisition.attach(parent, SCHEMA)
+      expect(acquisition.captured(parent)).toBeUndefined()
+      acquisition.detach(parent)
+      acquisition.detach(parent)
+      acquisition.release()
+      // The backend still holds its own reference from setup().
+      expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeDefined()
+    })
+  })
+
+  it('a direct structured_output call from an agent WITHOUT a structured run is an isError', async () => {
+    const { ctx, parent } = await setup([])
+    const result = await ctx.tools.execute({
+      callId: 'x' as never,
+      name: STRUCTURED_OUTPUT_TOOL,
+      arguments: { answer: 1 },
+      agent: parent,
+    })
+    expect(result.isError).toBe(true)
+    expect(result.content[0]).toMatchObject({ type: 'text' })
+  })
+
+  it('a structured_output call with NO calling agent at all is an isError', async () => {
+    const { ctx } = await setup([])
+    const result = await ctx.tools.execute({
+      callId: 'x' as never,
+      name: STRUCTURED_OUTPUT_TOOL,
+      arguments: { answer: 1 },
+    })
+    expect(result.isError).toBe(true)
+  })
+})

+ 3 - 3
packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts

@@ -51,7 +51,7 @@ describe('depthOf', () => {
 describe('startInProcessRun', () => {
   it('drives a fresh child (no seed) to completion and returns its output', async () => {
     const { ctx, parent } = await setup([textResponse('driver child answer')])
-    const run = startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'do X' }], parent }, { providerName: 'spawn' })
+    const run = startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'do X' }], parent }, { providerName: 'spawn', structuredNudgeRetries: 1 })
     const result = await run.result
     expect(result.stopReason).toBe('completed')
     expect(text(result.output)).toBe('driver child answer')
@@ -61,7 +61,7 @@ describe('startInProcessRun', () => {
 
   it('throws SubagentDepthError when the child would exceed maxDepth', async () => {
     const { ctx, parent } = await setup([])
-    expect(() => startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'p' }], parent, maxDepth: 0 }, { providerName: 'spawn' }))
+    expect(() => startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'p' }], parent, maxDepth: 0 }, { providerName: 'spawn', structuredNudgeRetries: 1 }))
       .toThrow(SubagentDepthError)
   })
 
@@ -73,7 +73,7 @@ describe('startInProcessRun', () => {
     parent.send([{ type: 'text', text: 'parent q' }])
     await parent.whenIdle()
     const seed = parent.session.events.slice()
-    const run = startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'child q' }], parent }, { providerName: 'fork', seed })
+    const run = startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'child q' }], parent }, { providerName: 'fork', structuredNudgeRetries: 1, seed })
     const result = await run.result
     expect(result.stopReason).toBe('completed')
     expect(text(result.output)).toBe('seeded child reply')

+ 6 - 0
packages/subagent/subagent-inprocess/tsconfig.json

@@ -25,6 +25,12 @@
     },
     {
       "path": "../subagent"
+    },
+    {
+      "path": "../../core/system-prompt"
+    },
+    {
+      "path": "../../core/tools"
     }
   ]
 }

+ 3 - 2
packages/subagent/subagent-spawn/README.md

@@ -6,14 +6,15 @@ The run mechanics live in the shared [`@deepseek-ai/dsh-subagent-inprocess`](../
 
 ## What it does
 
-`start(request)` delegates to `startInProcessRun(ctx, request, { providerName })` with no seed: a fresh child agent with the parent's `cwd`/`parentSession` lineage and (by default) the parent's model. See the [driver README](../subagent-inprocess/README.md) for the full lifecycle (depth check, one-shot drive, result read, dispose).
+`start(request)` delegates to `startInProcessRun(ctx, request, { providerName, structuredNudgeRetries })` with no seed: a fresh child agent with the parent's `cwd`/`parentSession` lineage and (by default) the parent's model. See the [driver README](../subagent-inprocess/README.md) for the full lifecycle (depth check, one-shot drive, result read, dispose).
 
 ## Capabilities
 
-`{ outputSchema: false, depthLimit: true, toolFilter: false }`. It constructs the child, so it enforces a recursion cap; structured output and tool-scoping are deferred (the service rejects a request needing either before `start` runs).
+`{ outputSchema: true, depthLimit: true, toolFilter: false }`. It constructs the child, so it enforces a recursion cap, and it supports structured output via the driver's shared [structured runtime](../subagent-inprocess/README.md) (the backend acquires it for its plugin lifetime; each structured run holds its own acquisition until it settles). Tool-scoping is deferred (the service rejects a request needing it before `start` runs).
 
 ## Config
 
 | Key | Meaning |
 |---|---|
 | `providerName` | Registry name on `ctx.subagents` (default `spawn`). |
+| `structuredNudgeRetries` | How many times a structured run re-prompts a child that finished cleanly without calling `structured_output` (default 1). |

+ 42 - 9
packages/subagent/subagent-spawn/src/index.ts

@@ -9,6 +9,11 @@
  * ({@link startInProcessRun}); this backend just passes NO seed (a fresh
  * child). The fork backend is an independent peer over the same driver.
  *
+ * Structured output (`outputSchema`) is supported via the driver's shared
+ * structured runtime: the backend acquires it for its plugin lifetime (so the
+ * capture tool and request-shaping listeners exist before any run), and each
+ * structured run holds its own acquisition until it settles.
+ *
  * Plugin export shape: named `name`/`inject`/`Config`/`apply`, NO default.
  *
  * @module @deepseek-ai/dsh-subagent-spawn
@@ -17,40 +22,68 @@
 import type { Context } from 'cordis'
 import z from 'schemastery'
 import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
-import { startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess'
+import { acquireStructuredRuntime, startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess'
 
 export const name = 'subagent-spawn'
+// `tools` is deliberately NOT injected: the structured runtime gates its own
+// capture-tool registration on `tools` availability internally, so this
+// backend's apply timing — and with it the provider-mirroring delegation
+// tool's position in the model-visible tool list — stays what it was before
+// structured output existed.
 export const inject = ['subagents', 'agents']
 
-/** Config: the registry name to register the provider under. */
+/** Config: the registry name to register the provider under, plus structured-run tuning. */
 export interface Config {
   /** Provider name on `ctx.subagents` (default `spawn`). */
   providerName: string
+  /**
+   * How many times a structured run re-prompts a child that finished cleanly
+   * without calling `structured_output` before giving up (default 1).
+   */
+  structuredNudgeRetries: number
 }
 
 export const Config: z<Config> = z.object({
   providerName: z.string().default('spawn'),
+  structuredNudgeRetries: z.natural().default(1),
 })
 
 /**
  * The spawn provider. Supports `depthLimit` (it constructs the child, so it can
- * enforce a recursion cap) but NOT `outputSchema` or `toolFilter` in this cut —
- * a request that needs either is rejected by the service before `start` runs.
+ * enforce a recursion cap) and `outputSchema` (via the shared in-process
+ * structured runtime); NOT `toolFilter` in this cut — a request that needs it
+ * is rejected by the service before `start` runs.
  */
 class SpawnProvider implements SubagentProvider {
-  readonly capabilities: SubagentCapabilities = { outputSchema: false, depthLimit: true, toolFilter: false }
+  readonly capabilities: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: false }
   // Context contract: a spawned child starts fresh — it never sees the parent conversation.
   readonly inheritsParentContext = false
 
-  constructor(readonly name: string, private readonly ctx: Context) {}
+  constructor(
+    readonly name: string,
+    private readonly ctx: Context,
+    private readonly structuredNudgeRetries: number,
+  ) {}
 
   start(request: SubagentStartRequest) {
     // Fresh child: no seed. The shared driver mints ids, stamps cwd/lineage/
-    // depth, drives the one-shot, and maps the result.
-    return startInProcessRun(this.ctx, request, { providerName: this.name })
+    // depth, drives the one-shot (including the structured capture/nudge loop
+    // when the request carries an outputSchema), and maps the result.
+    return startInProcessRun(this.ctx, request, {
+      providerName: this.name,
+      structuredNudgeRetries: this.structuredNudgeRetries,
+    })
   }
 }
 
 export function apply(ctx: Context, config: Config): void {
-  ctx.subagents.registerProvider(new SpawnProvider(config.providerName, ctx))
+  // Hold the structured runtime for the plugin's lifetime, so the capture tool
+  // and its request-shaping listeners are registered before the first
+  // structured run and torn down when the last backend unloads (live runs hold
+  // their own acquisitions, so an unload mid-run cannot strand a child).
+  ctx.effect(() => {
+    const acquisition = acquireStructuredRuntime(ctx)
+    return () => { acquisition.release() }
+  }, 'subagent-spawn structured runtime')
+  ctx.subagents.registerProvider(new SpawnProvider(config.providerName, ctx, config.structuredNudgeRetries))
 }

+ 1 - 1
packages/subagent/subagent-spawn/tests/harness.ts

@@ -34,7 +34,7 @@ export async function spawnHarness(workdir: string): Promise<Context> {
   await ctx.plugin(LocalBashExecutor, { cwd: workdir, timeoutMs: 30_000 })
   await ctx.plugin(ToolBash)
   await ctx.plugin(SubagentService)
-  await ctx.plugin(Spawn, { providerName: 'spawn' })
+  await ctx.plugin(Spawn, { providerName: 'spawn', structuredNudgeRetries: 1 })
   // The model-facing subagent tool, bound to the spawn backend.
   await ctx.plugin(ToolSubagent, { provider: 'spawn' })
   return ctx

+ 10 - 4
packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts

@@ -34,7 +34,7 @@ async function setup(script: Script) {
   await ctx.plugin(Invariants)
   await ctx.plugin(AgentLoop, { agents: [] })
   await ctx.plugin(SubagentService)
-  await ctx.plugin(spawn, { providerName: 'spawn' })
+  await ctx.plugin(spawn, { providerName: 'spawn', structuredNudgeRetries: 1 })
   ctx.llm.registerAdapter(['mock'], adapter)
   const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' })
   return { ctx, parent, adapter }
@@ -241,17 +241,23 @@ describe('dsh-subagent-spawn', () => {
     await parentHandle.dispose()
   })
 
-  it('advertises depthLimit but not outputSchema/toolFilter', async () => {
+  it('advertises depthLimit and outputSchema but not toolFilter', async () => {
     const { ctx } = await setup([])
     const provider = ctx.subagents.getProvider('spawn')!
-    expect(provider.capabilities).toEqual({ outputSchema: false, depthLimit: true, toolFilter: false })
+    expect(provider.capabilities).toEqual({ outputSchema: true, depthLimit: true, toolFilter: false })
   })
 
   it('unregisters the provider when its fiber is disposed (HMR safety)', async () => {
     const ctx = new Context()
     await ctx.plugin(SubagentService)
     await ctx.plugin(AgentRegistry)
-    const fiber = await ctx.plugin(spawn, { providerName: 'spawn' })
+    // The backend does NOT inject 'tools' (the structured runtime gates its
+    // capture-tool registration on tools availability itself, keeping backend
+    // apply timing — and the delegation tool's prompt position — unchanged);
+    // the registries are loaded here so the runtime registers eagerly anyway.
+    await ctx.plugin(SystemPrompt)
+    await ctx.plugin(ToolRegistry)
+    const fiber = await ctx.plugin(spawn, { providerName: 'spawn', structuredNudgeRetries: 1 })
     expect(ctx.subagents.list()).toEqual(['spawn'])
     await fiber.dispose()
     expect(ctx.subagents.list()).toEqual([])

+ 9 - 5
packages/subagent/subagent/src/types.ts

@@ -8,7 +8,7 @@
 
 import type { Agent, AgentId, AgentOptions } from '@deepseek-ai/dsh-agent'
 import type { ContentBlock } from '@deepseek-ai/dsh-llm'
-import type { SchemaSpec } from '@deepseek-ai/dsh-tools'
+import type { StructuredOutputSchema } from '@deepseek-ai/dsh-tools'
 
 /**
  * Which START-TIME features a provider supports. Checked by the service
@@ -56,12 +56,16 @@ export interface SubagentStartRequest {
   /** Per-child agent options (model, system prompt). */
   agentOptions?: AgentOptions
   /**
-   * Optional structured-output schema. When set AND the provider's
-   * {@link SubagentCapabilities.outputSchema} is `true`, the child's final
-   * answer is shaped to this schema and surfaced as {@link SubagentResult.structured}.
+   * Optional structured-output schema — an object-rooted JSON Schema within the
+   * enforced subset (see `assertSupportedOutputSchema` in dsh-tools; a schema
+   * outside the subset is rejected loud at start). When set AND the provider's
+   * {@link SubagentCapabilities.outputSchema} is `true`, the child is driven to
+   * report a value matching this schema, surfaced as
+   * {@link SubagentResult.structured}. The schema must be plain host-realm JSON
+   * data — a caller holding foreign-realm data materializes it first.
    * Requesting it against a provider that lacks the capability is rejected at start.
    */
-  outputSchema?: SchemaSpec
+  outputSchema?: StructuredOutputSchema
   /**
    * Optional recursion cap (max delegation depth below this child). Requires
    * {@link SubagentCapabilities.depthLimit}; rejected at start otherwise.

+ 2 - 2
packages/subagent/subagent/tests/service.spec.ts

@@ -178,7 +178,7 @@ describe('SubagentService', () => {
 
   describe('start-time capability validation (fail loud, before any child)', () => {
     it.each([
-      { field: 'outputSchema', request: baseRequest({ outputSchema: { x: { type: 'string' } } }) },
+      { field: 'outputSchema', request: baseRequest({ outputSchema: { type: 'object', properties: { x: { type: 'string' } } } }) },
       { field: 'maxDepth', request: baseRequest({ maxDepth: 2 }) },
       { field: 'toolFilter', request: baseRequest({ toolFilter: { deny: ['bash'] } }) },
     ])('rejects $field against a provider that lacks the capability — before start() runs', ({ request }) => {
@@ -203,7 +203,7 @@ describe('SubagentService', () => {
       await ctx.plugin(SubagentService)
       const provider = new StubProvider('strong', ALL_CAPS)
       ctx.subagents.registerProvider(provider)
-      ctx.subagents.start('strong', baseRequest({ outputSchema: { x: { type: 'string' } }, maxDepth: 1 }))
+      ctx.subagents.start('strong', baseRequest({ outputSchema: { type: 'object', properties: { x: { type: 'string' } } }, maxDepth: 1 }))
       expect(provider.startCount).toBe(1)
     })
   })

+ 2 - 2
packages/support/subagent-mock/tests/subagent-mock.spec.ts

@@ -41,13 +41,13 @@ describe('dsh-subagent-mock', () => {
 
   it('surfaces a structured result when the request carries an outputSchema', async () => {
     const ctx = await mount({ reply: 'r', structured: { answer: 42 } })
-    const run = ctx.subagents.start('mock', baseRequest({ outputSchema: { answer: { type: 'number' } } }))
+    const run = ctx.subagents.start('mock', baseRequest({ outputSchema: { type: 'object', properties: { answer: { type: 'number' } } } }))
     await expect(run.result).resolves.toMatchObject({ structured: { answer: 42 } })
   })
 
   it('defaults structured output to { reply } when outputSchema is requested but no structured value is configured', async () => {
     const ctx = await mount({ reply: 'fallback reply' })
-    const run = ctx.subagents.start('mock', baseRequest({ outputSchema: { answer: { type: 'number' } } }))
+    const run = ctx.subagents.start('mock', baseRequest({ outputSchema: { type: 'object', properties: { answer: { type: 'number' } } } }))
     await expect(run.result).resolves.toMatchObject({ structured: { reply: 'fallback reply' } })
   })
 

+ 6 - 0
pnpm-lock.yaml

@@ -655,6 +655,12 @@ importers:
       '@deepseek-ai/dsh-subagent':
         specifier: workspace:^
         version: link:../subagent
+      '@deepseek-ai/dsh-subagent-fork':
+        specifier: workspace:^
+        version: link:../subagent-fork
+      '@deepseek-ai/dsh-subagent-spawn':
+        specifier: workspace:^
+        version: link:../subagent-spawn
       '@deepseek-ai/dsh-system-prompt':
         specifier: workspace:^
         version: link:../../core/system-prompt