Merge remote-tracking branch 'origin/worktree-agent-scope-design' into codex/trim-ai-prose

This commit is contained in:
Tianyi Cui
2026-07-13 13:22:22 +08:00
31 changed files with 143 additions and 476 deletions

View File

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

View File

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

View File

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