diff --git a/packages/subagent/subagent-inprocess/src/structured.ts b/packages/subagent/subagent-inprocess/src/structured.ts index 51d1ea1b34..70605dfcc2 100644 --- a/packages/subagent/subagent-inprocess/src/structured.ts +++ b/packages/subagent/subagent-inprocess/src/structured.ts @@ -21,8 +21,11 @@ * always carries its capture tool and the trailing instruction section. The * registry already contributes both; this outermost wrapper preserves the * guarantee against a (global) listener that strips or replaces the - * assembly. The loop logs the rendered assembly as the request header, so - * the demand is reconstructable log state, never a wire-only mutation. + * assembly — placement-preserving, so an untampered assembly reaches the + * model byte-identical (tools replaced in place, the section re-inserted at + * its ascending-order position). The loop logs the rendered assembly as the + * request header, so the demand is reconstructable log state, never a + * wire-only mutation. * - `agent/turn-continuation` (prepend, scoped): stop the child's turn once * its output is captured — the loop's default "had tool calls ⇒ continue" * would buy a wasted extra model step per structured child. @@ -138,15 +141,39 @@ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOut // REPLACE, not merely ensure-present: a downstream listener may have // mutated or injected a same-named entry with the WRONG schema/text, and // the model-visible demand must be exactly this run's own — the same - // schema validateStructuredValue enforces. - final.tools = [ - ...final.tools.filter(tool => tool.name !== STRUCTURED_OUTPUT_TOOL), - { ...schemaEntry, parameters: structuredClone(schemaEntry.parameters) }, - ] - final.sections = [ - ...final.sections.filter(section => section.name !== `tool:${STRUCTURED_OUTPUT_TOOL}`), - { name: `tool:${STRUCTURED_OUTPUT_TOOL}`, order: 190, text: STRUCTURED_OUTPUT_INSTRUCTION }, - ] + // schema validateStructuredValue enforces. Placement-preserving on both + // arrays: the untampered path must reach the model byte-identical to the + // registry's output (tool order is the `toolOrder`/lexicographic + // contract, section order is the ascending contract `renderPrompt` + // trusts), so this never reorders what it only re-asserts. + const freshTool: ToolSchema = { ...schemaEntry, parameters: structuredClone(schemaEntry.parameters) } + // Tools: replace the first same-named entry IN PLACE (its position is the + // chain's product; a tool's list position carries no semantic band to + // restore), drop any duplicates, append only when stripped entirely. + const tools: ToolSchema[] = [] + let toolReplaced = false + for (const tool of final.tools) { + if (tool.name !== STRUCTURED_OUTPUT_TOOL) { + tools.push(tool) + } else if (!toolReplaced) { + tools.push(freshTool) + toolReplaced = true + } + } + if (!toolReplaced) tools.push(freshTool) + final.tools = tools + // Sections: remove every same-named entry and re-insert at the + // ascending-correct position (the first entry above order 190) — sections + // DO carry an order contract, and the renderer reads array order, so a + // stripped-or-moved instruction is restored to its band, not appended + // after unrelated higher-order sections. On the untampered path this + // lands exactly where the registry's stable sort put it (last of the 190 + // band — the scoped section registers after every load-time 190). + const sectionName = `tool:${STRUCTURED_OUTPUT_TOOL}` + const sections = final.sections.filter(section => section.name !== sectionName) + const insertAt = sections.findIndex(section => section.order > 190) + sections.splice(insertAt === -1 ? sections.length : insertAt, 0, { name: sectionName, order: 190, text: STRUCTURED_OUTPUT_INSTRUCTION }) + final.sections = sections return final }, { prepend: true }) diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts index 3384fe4aa3..8f2e8e69ea 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -465,6 +465,68 @@ describe('in-process structured output', () => { await run.dispose() }) + it('the re-assert preserves the untampered assembly: tool position and section band are the registry\'s own', async () => { + const { ctx, parent, adapter } = await setup([ + toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 7 }), + ]) + // A global tool sorting lexicographically AFTER structured_output and a + // global section ABOVE the 190 band: the re-assert must leave both + // exactly where the registry's ordering put them (no move-to-end). + ctx.tools.register({ + name: 'zz_probe', + description: 'probe', + parameters: { type: 'object', properties: {} }, + execute: () => Promise.resolve([{ type: 'text', text: 'x' }]), + }) + ctx.systemPrompt.section({ name: 'after-band', order: 200, text: 'AFTER-BAND' }) + const run = ctx.subagents.start('spawn', structuredRequest(parent)) + await run.result + const request = adapter.requests[0]! + const names = toolNames(request) + expect(names.indexOf(STRUCTURED_OUTPUT_TOOL)).toBeGreaterThanOrEqual(0) + expect(names.indexOf(STRUCTURED_OUTPUT_TOOL)).toBeLessThan(names.indexOf('zz_probe')) + const system = request.system ?? '' + const instructionAt = system.indexOf(STRUCTURED_OUTPUT_INSTRUCTION) + expect(instructionAt).toBeGreaterThanOrEqual(0) + expect(system.indexOf('AFTER-BAND')).toBeGreaterThan(instructionAt) + await run.dispose() + }) + + it('a stripped instruction re-inserts at its band; an added duplicate entry collapses to one', async () => { + const { ctx, parent, adapter } = await setup([ + toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 3 }), + ]) + ctx.systemPrompt.section({ name: 'after-band', order: 200, text: 'AFTER-BAND' }) + // Strip the instruction section entirely AND add a wrong-schema + // duplicate tool entry ALONGSIDE the registry's own: the re-assert must + // restore the section INTO its band (before the order-200 section, not + // appended after it) and collapse the tools to exactly one entry + // carrying the run's schema. + ctx.on('system-prompt/assemble', async (_assembly, _context, next) => { + const replaced = await next() + return { + sections: replaced.sections.filter(section => section.name !== `tool:${STRUCTURED_OUTPUT_TOOL}`), + tools: [ + ...replaced.tools, + { name: STRUCTURED_OUTPUT_TOOL, description: 'wrong', parameters: { type: 'object', properties: { bogus: { type: 'string' } } } }, + ], + variables: { ...replaced.variables }, + } + }) + const run = ctx.subagents.start('spawn', structuredRequest(parent)) + const result = await run.result + expect(result.structured).toEqual({ answer: 3 }) + const request = adapter.requests[0]! + const entries = request.tools!.filter(tool => tool.name === STRUCTURED_OUTPUT_TOOL) + expect(entries).toHaveLength(1) + expect(entries[0]!.parameters).toEqual(SCHEMA) + const system = request.system ?? '' + const instructionAt = system.indexOf(STRUCTURED_OUTPUT_INSTRUCTION) + expect(instructionAt).toBeGreaterThanOrEqual(0) + expect(system.indexOf('AFTER-BAND')).toBeGreaterThan(instructionAt) + 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([textResponse('plain')]) parent.send([{ type: 'text', text: 'q' }])