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

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

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

One deliberate divergence from the #170 revision: the backends do NOT add
'tools' to their plugin inject. Doing so deferred their apply past the todo
plugin, and the delegation tool mirrors provider lifecycle — so the
model-visible tool order of every existing prompt changed, invalidating every
recorded snapshot fixture. The runtime now gates its capture-tool registration
on tools availability itself (sync when live, a scoped inject fiber when the
Loader starts the backend first), keeping this PR byte-invisible to existing
transcripts: all 35 snapshot scenarios pass against master's fixtures
unchanged.
This commit is contained in:
Tianyi Cui
2026-07-06 23:29:08 +08:00
parent 0606cd559c
commit 74502fa8c2
28 changed files with 1570 additions and 62 deletions

View File

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

View File

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