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:
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user