refactor(core): remove owner-final assembly machinery
This commit is contained in:
@@ -34,7 +34,7 @@ After fulfillment, the caller owns the run. Provider-plugin unload does not revo
|
||||
|
||||
- A `structured_output` tool registered with the requested schema validates and stages the model's value.
|
||||
- An order-190 system-prompt section tells the child that the tool call is the terminal answer.
|
||||
- Both contributions use `ownerFinal: true`, so their owners control their final presence after prompt/tool assembly while unrelated contributions remain extensible.
|
||||
- Both contributions are ordinary child-scoped registrations. An expert `system-prompt/assemble` listener may replace them and therefore owns preserving the structured-output protocol for that child.
|
||||
- A `tools/result` observer commits a staged value only after that execution's authoritative final tool result succeeds, including the enclosing `run_code` result for Code Mode sub-dispatch.
|
||||
- A monotonic tool guard blocks later calls after capture, and `agent/turn-stop` ends the turn after the structured result commits.
|
||||
|
||||
|
||||
@@ -16,14 +16,11 @@
|
||||
*
|
||||
* The child scope's registrations enforce the contract:
|
||||
*
|
||||
* - `ownerFinal: true` on the capture tool and instruction declares that the
|
||||
* owning registrations control their final presence. Prompt assembly restores their canonical state
|
||||
* after EVERY assembly listener. Canonical absence is protected too: pure
|
||||
* Code Mode keeps `structured_output` in the SDK only and never grows a
|
||||
* second native wire tool. Code Mode independently declares its SDK section
|
||||
* and `run_code` transport owner-final. The loop logs the finalized assembly as the
|
||||
* request header, so the demand is reconstructable log state, never a
|
||||
* wire-only mutation.
|
||||
* - The scoped capture tool and instruction are ordinary assembly inputs. The
|
||||
* loop logs the assembled request header, so the demand is reconstructable
|
||||
* log state rather than a wire-only mutation. As with every other assembly
|
||||
* contribution, an expert `system-prompt/assemble` listener that deliberately
|
||||
* removes or replaces either input owns the resulting composition.
|
||||
* - `agent/turn-stop` (serial, scoped): stop the child's turn once its output
|
||||
* is captured. This terminal checkpoint runs after the ordinary continuation
|
||||
* waterfall and steering folding, so listener order cannot resurrect a
|
||||
@@ -110,7 +107,6 @@ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOut
|
||||
|
||||
childCtx.tools.register({
|
||||
...schemaEntry,
|
||||
ownerFinal: true,
|
||||
execute(args: unknown, exec: ToolExecution): Promise<ContentBlock[]> {
|
||||
const violations = validateStructuredValue(schema, args)
|
||||
// ToolArgsError → isError result with INVALID_ARGS: the model retries
|
||||
@@ -128,7 +124,6 @@ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOut
|
||||
name: `tool:${STRUCTURED_OUTPUT_TOOL}`,
|
||||
order: 190,
|
||||
text: STRUCTURED_OUTPUT_INSTRUCTION,
|
||||
ownerFinal: true,
|
||||
})
|
||||
|
||||
// Stop the child's turn once its output is captured. This monotonic serial
|
||||
|
||||
@@ -418,9 +418,9 @@ describe('in-process structured output', () => {
|
||||
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).
|
||||
// instruction must APPEND to the other scoped and global sections, not
|
||||
// replace them (AgentOptions has no prompt field — the instruction is an
|
||||
// ordinary child-scoped prompt registration).
|
||||
ctx.systemPrompt.section({ name: 'test:persona', order: 10, text: 'You are a counter.' })
|
||||
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
await run.result
|
||||
@@ -445,22 +445,6 @@ describe('in-process structured output', () => {
|
||||
})
|
||||
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
|
||||
// This listener is registered after the child's protection and prepended.
|
||||
// Service finalization still restores the stripped transport and prompt
|
||||
// parts, while removing the fabricated native capture tool.
|
||||
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
|
||||
const result = await next()
|
||||
return {
|
||||
sections: result.sections.filter(section =>
|
||||
section.name !== 'tools:sdk' && section.name !== `tool:${STRUCTURED_OUTPUT_TOOL}`),
|
||||
tools: [
|
||||
...result.tools.filter(tool => tool.name !== RUN_CODE_NAME),
|
||||
{ name: STRUCTURED_OUTPUT_TOOL, description: 'wrong native duplicate', parameters: {} },
|
||||
],
|
||||
variables: result.variables,
|
||||
}
|
||||
}, { prepend: true })
|
||||
|
||||
const result = await run.result
|
||||
expect(result.structured).toEqual({ answer: 12 })
|
||||
const request = adapter.requests[0]!
|
||||
@@ -610,66 +594,12 @@ describe('in-process structured output', () => {
|
||||
await runB.dispose()
|
||||
})
|
||||
|
||||
it('protection replaces a conflicting injected schema, not merely ensuring presence', async () => {
|
||||
const { ctx, parent, adapter } = await setup([
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 5 }),
|
||||
])
|
||||
// A global listener that INJECTS a wrong-schema structured_output entry:
|
||||
// protection restores the run's own canonical schema.
|
||||
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
|
||||
const replaced = await next()
|
||||
return {
|
||||
sections: replaced.sections,
|
||||
tools: [
|
||||
...replaced.tools.filter(tool => tool.name !== STRUCTURED_OUTPUT_TOOL),
|
||||
{ name: STRUCTURED_OUTPUT_TOOL, description: 'wrong', parameters: { type: 'object', properties: { bogus: { type: 'string' } } } },
|
||||
],
|
||||
variables: { ...replaced.variables },
|
||||
}
|
||||
})
|
||||
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const result = await run.result
|
||||
expect(result.structured).toEqual({ answer: 5 })
|
||||
const entries = adapter.requests[0]!.tools!.filter(tool => tool.name === STRUCTURED_OUTPUT_TOOL)
|
||||
expect(entries).toHaveLength(1)
|
||||
expect(entries[0]!.parameters).toEqual(SCHEMA)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('protection wins against a listener that replaces the assembly object', async () => {
|
||||
const { ctx, parent, adapter } = await setup([
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 5 }),
|
||||
])
|
||||
// A global (every-assembly) listener that returns a brand-new assembly
|
||||
// WITHOUT the capture tool or instruction — the composition caveat that
|
||||
// erases cooperative mutations. Service finalization restores both
|
||||
// after the complete waterfall.
|
||||
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.filter(tool => tool.name !== STRUCTURED_OUTPUT_TOOL),
|
||||
variables: { ...replaced.variables },
|
||||
}
|
||||
})
|
||||
const run = await 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)
|
||||
const system = adapter.requests[0]!.system ?? ''
|
||||
expect(system).toContain(STRUCTURED_OUTPUT_INSTRUCTION)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('protection preserves the canonical tool position and section band', async () => {
|
||||
it('places the capture tool and instruction in their canonical orders', 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: protection leaves both exactly
|
||||
// where the canonical registry ordering put them.
|
||||
// A global tool sorts lexicographically after structured_output, while a
|
||||
// global section above the 190 band follows the capture instruction.
|
||||
ctx.tools.register({
|
||||
name: 'zz_probe',
|
||||
description: 'probe',
|
||||
@@ -690,41 +620,6 @@ describe('in-process structured output', () => {
|
||||
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: protection 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 = await 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' }])
|
||||
|
||||
Reference in New Issue
Block a user