diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 753cf8f4d0..0f7ed1dc68 100644 --- a/docs/cordis-catalog/events.md +++ b/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) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 0221946118..58186533fd 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -204,7 +204,7 @@ async execute(exec: ToolExecution): Promise 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` diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index 926e6ce9b8..f21ef15669 100644 --- a/docs/core-data-structures/subagent.md +++ b/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[] } } diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 24a2793826..a77015ceda 100644 --- a/docs/event-producer-consumer.md +++ b/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`. diff --git a/docs/module-graph.md b/docs/module-graph.md index ed1cc592d4..8283a8b756 100644 --- a/docs/module-graph.md +++ b/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) | diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 65039aea58..d43f24e123 100644 --- a/packages/core/tools/README.md +++ b/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`): diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index dd0ed918db..39dafd6f1a 100644 --- a/packages/core/tools/src/index.ts +++ b/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). diff --git a/packages/core/tools/src/json-schema.ts b/packages/core/tools/src/json-schema.ts new file mode 100644 index 0000000000..35eeb240a4 --- /dev/null +++ b/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 + /** 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 { + 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): 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): 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 = { + 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') +} diff --git a/packages/core/tools/tests/json-schema.spec.ts b/packages/core/tools/tests/json-schema.spec.ts new file mode 100644 index 0000000000..e7635b06f3 --- /dev/null +++ b/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 = {} + 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 = { 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/) + }) +}) diff --git a/packages/subagent/subagent-fork/README.md b/packages/subagent/subagent-fork/README.md index c691d56355..7b43f82261 100644 --- a/packages/subagent/subagent-fork/README.md +++ b/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. diff --git a/packages/subagent/subagent-fork/src/index.ts b/packages/subagent/subagent-fork/src/index.ts index b6d0c10e44..10d0492198 100644 --- a/packages/subagent/subagent-fork/src/index.ts +++ b/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 = 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)) } diff --git a/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts b/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts index 1f932fbaf9..82caf25948 100644 --- a/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts +++ b/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 } diff --git a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts index 56441a656f..2545188b52 100644 --- a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts +++ b/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([]) diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index af6d5792a9..b816da8946 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/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` diff --git a/packages/subagent/subagent-inprocess/package.json b/packages/subagent/subagent-inprocess/package.json index f3bd774554..ecc177162f 100644 --- a/packages/subagent/subagent-inprocess/package.json +++ b/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" diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index 1107926aa2..381f94acba 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/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 } } diff --git a/packages/subagent/subagent-inprocess/src/structured.ts b/packages/subagent/subagent-inprocess/src/structured.ts new file mode 100644 index 0000000000..69e4ea4fd6 --- /dev/null +++ b/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 + readonly disposers: (() => void)[] +} + +/** One root context ⇒ one runtime (multi-app test isolation). */ +const runtimes = new WeakMap() + +/** + * 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 { + 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, + ): Promise { + 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, + } + 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, + ): Promise { + if (runtime.states.get(agent)?.captured) return Promise.resolve({ action: 'stop' }) + return next() + }, { prepend: true })) +} diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts new file mode 100644 index 0000000000..6982f7cde5 --- /dev/null +++ b/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[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 { + 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({ 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({ 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) + ? { 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) + ? { 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) + }) +}) diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index 7219e03988..d3870ae51a 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/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') diff --git a/packages/subagent/subagent-inprocess/tsconfig.json b/packages/subagent/subagent-inprocess/tsconfig.json index 4cb435d4fb..7b7a015cc9 100644 --- a/packages/subagent/subagent-inprocess/tsconfig.json +++ b/packages/subagent/subagent-inprocess/tsconfig.json @@ -25,6 +25,12 @@ }, { "path": "../subagent" + }, + { + "path": "../../core/system-prompt" + }, + { + "path": "../../core/tools" } ] } diff --git a/packages/subagent/subagent-spawn/README.md b/packages/subagent/subagent-spawn/README.md index 97dfae9304..059c996215 100644 --- a/packages/subagent/subagent-spawn/README.md +++ b/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). | diff --git a/packages/subagent/subagent-spawn/src/index.ts b/packages/subagent/subagent-spawn/src/index.ts index e6cf5039a7..248e887ce7 100644 --- a/packages/subagent/subagent-spawn/src/index.ts +++ b/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 = 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)) } diff --git a/packages/subagent/subagent-spawn/tests/harness.ts b/packages/subagent/subagent-spawn/tests/harness.ts index b3e9d4ec24..97dab3c3ee 100644 --- a/packages/subagent/subagent-spawn/tests/harness.ts +++ b/packages/subagent/subagent-spawn/tests/harness.ts @@ -34,7 +34,7 @@ export async function spawnHarness(workdir: string): Promise { 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 diff --git a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts index ccfd6492f4..70ce7a4774 100644 --- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts +++ b/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([]) diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index ef76a96e5d..82fd12af36 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/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. diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index e49b4c12e0..f70abf7e72 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/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) }) }) diff --git a/packages/support/subagent-mock/tests/subagent-mock.spec.ts b/packages/support/subagent-mock/tests/subagent-mock.spec.ts index f35ed884eb..ddd725da4b 100644 --- a/packages/support/subagent-mock/tests/subagent-mock.spec.ts +++ b/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' } }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d5fb68c746..35d940028b 100644 --- a/pnpm-lock.yaml +++ b/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