subagent: implement structured output for in-process backends

The seam vocabulary (SubagentStartRequest.outputSchema, SubagentResult
.structured) existed but no in-process backend honored it — spawn/fork
advertised outputSchema: false. This lands the missing half:

- dsh-tools gains a structured-output JSON Schema subset (json-schema.ts):
  StructuredOutputSchema, assertSupportedOutputSchema (rejects loud outside
  the enforced subset, every violation listed), validateStructuredValue
  (path-qualified issues, total). outputSchema's seam type becomes this raw
  JSON-Schema subset instead of the author-facing SchemaSpec DSL — the schema
  travels verbatim to the model as a forced tool's parameters.
- dsh-subagent-inprocess gains the shared structured runtime: one global
  structured_output capture tool (placeholder parameters) + a prepend:true
  agent/request listener doing FINAL-REQUEST enforcement (strip for plain
  agents, per-run schema for structured children — survives downstream
  request-replacing listeners) + an agent/turn-continuation veto that stops
  a child's turn once captured (no wasted extra model step). Lifetime is
  refcounted by backends (plugin lifetime) AND live runs (start→settle).
- startInProcessRun drives the capture: subset asserted before the child
  exists, instruction appended to the child's system prompt, clean-finish
  nudge loop (structuredNudgeRetries, backend Config, default 1), captured
  value on result.structured; a clean finish without a capture settles
  'error' (never a silent success with a missing field).
- spawn + fork flip outputSchema: true and inject 'tools'.
This commit is contained in:
Tianyi Cui
2026-07-05 11:35:39 +08:00
parent 2bad139ece
commit dafb81be7b
26 changed files with 1402 additions and 65 deletions

View File

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

View File

@@ -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,21 @@ 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 injects 'tools' for the structured runtime, so the registry
// (and its systemPrompt dependency) must be live for the fiber to activate.
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([])
@@ -260,12 +264,12 @@ describe('dsh-subagent-spawn', () => {
it('has the namespace-plugin export shape (no stray default)', () => {
expect('default' in spawn).toBe(false)
expect(spawn.name).toBe('subagent-spawn')
expect(spawn.inject).toEqual(['subagents', 'agents'])
expect(spawn.inject).toEqual(['subagents', 'agents', 'tools'])
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(spawn) as Record<string, unknown>
expect(unwrapped).toBe(spawn)
expect(unwrapped.name).toBe('subagent-spawn')
expect(unwrapped.inject).toEqual(['subagents', 'agents'])
expect(unwrapped.inject).toEqual(['subagents', 'agents', 'tools'])
expect(typeof unwrapped.apply).toBe('function')
})
})