fix(tools): state the code-mode collapse in the prompt and the denial

The executor collapse landed without telling the model it exists. Every
tool contributes its own guidance section naming its tool, none of them
qualify how that tool is reached, and they all render before the SDK
(orders 100-199 against SDK_SECTION_ORDER 150), so the prompt said "Use
the read tool" eleven times and never said only run_code is callable.

A real session shows the consequence: the model emitted a native call,
read `unknown tool "read"` for a tool the same prompt declares, and
concluded the deployment was inconsistent rather than routing through
run_code.

The registry now contributes `tools:code-only` at order 99 -- ahead of
the guidance band -- stating the rule, registered wherever `tools:sdk`
is and rendering empty outside an effective `code`. `both` renders it
empty because its native calls do execute, which is also why
both-mode-turn no longer shares code-mode-turn's expected prompt. The
denial itself now names the route back, since a bare UNKNOWN_TOOL for a
declared tool is what misled the model.
This commit is contained in:
Yichen Jiang
2026-08-11 22:51:16 +08:00
parent 428aec44e6
commit 5d2c943d38
13 changed files with 571 additions and 25 deletions
@@ -135,6 +135,32 @@ describe('mode-aware wire contribution', () => {
expect(sdk?.text).not.toContain('run_code:')
})
it("mode 'code' states the run_code-only rule BEFORE the per-tool guidance that names each tool", async () => {
const { ctx, systemPrompt } = await setup({ mode: 'code' })
registerEcho(ctx)
// Stand in for a real tool's guidance section, which sits in the 100-199
// band and names its tool without saying how it is reached.
ctx.systemPrompt.section({ name: 'tool:echo', order: 100, text: 'Use the echo tool.' })
const assembly = await systemPrompt.assemble()
const names = assembly.sections.map(section => section.name)
const rule = assembly.sections.find(section => section.name === 'tools:code-only')
expect(rule?.text).toContain(`\`${RUN_CODE_NAME}\` is the only tool you can call directly`)
// The rule is worthless after the guidance it qualifies.
expect(names.indexOf('tools:code-only')).toBeLessThan(names.indexOf('tool:echo'))
expect(names.indexOf('tools:code-only')).toBeLessThan(names.indexOf('tools:sdk'))
})
it("mode 'both' omits the run_code-only rule, because native calls do execute there", async () => {
const { ctx, systemPrompt } = await setup({ mode: 'both' })
registerEcho(ctx)
const assembly = await systemPrompt.assemble()
// Registered (the deployment is non-native) but empty, so the renderer
// drops it: `both` executes the native call the rule would forbid.
expect(assembly.sections.find(section => section.name === 'tools:code-only')?.text).toBe('')
expect(assembly.tools.map(tool => tool.name)).toContain('echo')
})
it('projects deeply nested output schemas into the Code Mode SDK without structured-clone recursion', async () => {
const { ctx, systemPrompt } = await setup({ mode: 'code' })
let output: JsonSchemaNode = { type: 'string' }
@@ -1574,6 +1600,11 @@ describe('the run_code dispatch bridge', () => {
})
expect(result.isError).toBe(true)
expect(result.error?.info).toEqual({ name: 'ToolNotFoundError', code: 'UNKNOWN_TOOL' })
// The name IS declared to this model, so a bare `unknown tool` reads as a
// broken deployment. The denial carries the route instead.
expect(result.error?.message).toBe(
`unknown tool "write": only \`${RUN_CODE_NAME}\` is callable directly — call \`write\` from inside a \`${RUN_CODE_NAME}\` program instead`,
)
})
it('routes a pre-aborted collapsed call through ABORTED_BEFORE_DISPATCH', async () => {