From 0c2e773d6706008033eb194678ab3039cc634909 Mon Sep 17 00:00:00 2001 From: kingwl Date: Fri, 10 Jul 2026 22:09:36 +0800 Subject: [PATCH] fix(mode): the default mode hides the exit binding from the Code Mode SDK too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding, valid — the previous SDK fix covered only the non-default branch: in the default mode under Code Mode the wire filter dropped exit_plan_mode but the registry-rendered tools:sdk section still advertised its binding, offering default-mode agents a call that can only error and breaking the byte-identical claim (a no-dsh-mode deployment's registry never saw the tool, so its SDK never listed it). The SDK re-render extracts to one helper both branches share: the non-default branch passes the mode's visibility rule, the default branch hides exactly the exit binding. The pinning test now compares the default-mode SDK byte-for-byte against a bare deployment without dsh-mode — the strongest form of the invariant the RFC states. --- .../feature/2026-07-07-plan-mode.md | 2 +- packages/mode/mode/README.md | 2 +- packages/mode/mode/src/index.ts | 33 ++++++++++++++----- packages/mode/mode/tests/mode.spec.ts | 27 ++++++++++----- 4 files changed, 45 insertions(+), 19 deletions(-) diff --git a/docs/rfc/implemented/feature/2026-07-07-plan-mode.md b/docs/rfc/implemented/feature/2026-07-07-plan-mode.md index 1a743158bd..06c6ec97ec 100644 --- a/docs/rfc/implemented/feature/2026-07-07-plan-mode.md +++ b/docs/rfc/implemented/feature/2026-07-07-plan-mode.md @@ -94,7 +94,7 @@ A contained `session/event` listener ([defensive patterns](../../../defensive-pa A `system-prompt/assemble` waterfall listener reads the calling agent's mode (the `AssembleContext` carries `agent`) and, in a non-default mode, filters `assembly.tools` down to the mode's allowlist and appends the mode's guidance section. The loop already renders per step and logs the result: entering or leaving a mode surfaces on the next step as a `request/header-delta` — or as the full `request/header` fallback snapshot when the change is inexpressible in the delta encoding (adding `exit_plan_mode` resorts the canonical tool list, and a pure reordering has no delta form) — so every mode transition is an attributable log fact. The section is static per mode and the plan itself stays in the conversation (messages and tool args, already in context), so a mode does not add per-step prompt churn — re-injecting plan state into every request ([Prior art](#prior-art)'s compaction-survival hack) is unnecessary and would only burn prefix cache. -The guidance section is an ordinary registered section, `{ name: 'mode:policy', order: 50, text: context => … }` — order 50 sits after the persona (0) and before tool guidance (100–199); it resolves to the folded mode's configured text and to `''` (dropped at render) for the default mode or an agent-less assembly. The tool filter wraps with `prepend: true`: it awaits `next()` and filters the RETURNED assembly's `tools`, so additions made anywhere inside its wrap — including every append-registered listener's post-`next()` mutation, regardless of load order — are covered. `run_code` survives the filter in every mode: under the registry's Code Mode it is the only wire tool (filtering it would strip the model of everything, the exit included), and it is a transport, not a capability — each bridged sub-call re-enters the hard gate individually. Code Mode's soft surface is the `tools:sdk` section rather than the wire schemas, and section text resolves in assemble's base, so the same wrapper re-renders that section under the mode's visibility rule — the prompt documents exactly the callable bindings, never one the gate would deny. The filter enforces one rule in every mode: `exit_plan_mode` is visible IFF the agent's folded mode is `plan` — which is also what keeps a default-mode assembly byte-identical to a no-`dsh-mode` deployment even though the tool is always registered. In a non-default mode it additionally intersects with the mode's allowlist. +The guidance section is an ordinary registered section, `{ name: 'mode:policy', order: 50, text: context => … }` — order 50 sits after the persona (0) and before tool guidance (100–199); it resolves to the folded mode's configured text and to `''` (dropped at render) for the default mode or an agent-less assembly. The tool filter wraps with `prepend: true`: it awaits `next()` and filters the RETURNED assembly's `tools`, so additions made anywhere inside its wrap — including every append-registered listener's post-`next()` mutation, regardless of load order — are covered. `run_code` survives the filter in every mode: under the registry's Code Mode it is the only wire tool (filtering it would strip the model of everything, the exit included), and it is a transport, not a capability — each bridged sub-call re-enters the hard gate individually. Code Mode's soft surface is the `tools:sdk` section rather than the wire schemas, and section text resolves in assemble's base, so the same wrapper re-renders that section under the mode's visibility rule — the prompt documents exactly the callable bindings, never one the gate would deny. The default mode re-renders it too, hiding only the exit binding: that keeps a default-mode Code Mode assembly byte-identical to a deployment that never loaded `dsh-mode` (whose registry never saw the tool), instead of advertising a binding that can only error. The filter enforces one rule in every mode: `exit_plan_mode` is visible IFF the agent's folded mode is `plan` — which is also what keeps a default-mode assembly byte-identical to a no-`dsh-mode` deployment even though the tool is always registered. In a non-default mode it additionally intersects with the mode's allowlist. ### The hard layer: the gate diff --git a/packages/mode/mode/README.md b/packages/mode/mode/README.md index 1a2f1002ef..0bead580c7 100644 --- a/packages/mode/mode/README.md +++ b/packages/mode/mode/README.md @@ -10,7 +10,7 @@ The `default` mode is the absence of policy: no section, no filtering, no gate. ## Two layers of enforcement -**Soft — what the model sees.** A `system-prompt/assemble` listener filters the returned assembly's tools down to the mode's allowlist and the `mode:policy` section (order 50) renders the mode's guidance text. Every transition therefore surfaces as an attributable `request/header` event on the next step (a delta when expressible; adding `exit_plan_mode` resorts the canonical tool list, which the delta encoding cannot express, so entering plan mode logs the full fallback snapshot). The `exit_plan_mode` tool is visible IFF the folded mode is `plan`. +**Soft — what the model sees.** A `system-prompt/assemble` listener filters the returned assembly's tools down to the mode's allowlist and the `mode:policy` section (order 50) renders the mode's guidance text. Every transition therefore surfaces as an attributable `request/header` event on the next step (a delta when expressible; adding `exit_plan_mode` resorts the canonical tool list, which the delta encoding cannot express, so entering plan mode logs the full fallback snapshot). The `exit_plan_mode` tool is visible IFF the folded mode is `plan` — on the wire and, under the registry's Code Mode, in the `tools:sdk` section alike. **Hard — what can run.** A `tools/pre-execute` listener denies, deny-by-default against the same allowlist, any call the mode does not permit — a hallucinated call to a still-registered (or freshly re-widened) tool cannot run. Agent-less executions and the default mode pass through; the gate judges by the LOGGED mode only, never a pending intent. `run_code` passes both layers as a TRANSPORT: under the registry's Code Mode it is the only wire tool, every bridged sub-call re-enters this gate with the same agent, and the `tools:sdk` section is re-rendered under the mode's visibility rule — the allowlist governs each capability individually and the prompt documents exactly the callable set. diff --git a/packages/mode/mode/src/index.ts b/packages/mode/mode/src/index.ts index e307248eca..6125c3c1eb 100644 --- a/packages/mode/mode/src/index.ts +++ b/packages/mode/mode/src/index.ts @@ -264,6 +264,13 @@ export class ModesService extends Service { const active = this.activeDefinition(agent.session) if (active === undefined) { result.tools = result.tools.filter(tool => tool.name !== EXIT_PLAN_MODE) + // The default mode hides exactly one thing on BOTH soft surfaces: the + // exit tool (registered always, callable only in plan). Without the + // SDK re-render a Code Mode deployment would still advertise an + // exit_plan_mode binding that can only error — and the default-mode + // assembly would no longer be byte-identical to a no-dsh-mode + // deployment, whose registry never saw the tool at all. + rerenderSdk(result, name => name !== EXIT_PLAN_MODE) return result } const allowed = new Set(active.definition.tools) @@ -277,19 +284,27 @@ export class ModesService extends Service { result.tools = result.tools.filter(tool => visible(tool.name) || tool.name === RUN_CODE_NAME) // Code Mode's soft surface is the SDK section, not the wire schemas — // section text resolves in assemble's base, so the outermost wrapper - // can re-render it here from the same visibility rule the wire filter - // applies (minus run_code, mirroring the registry's own exclusion). + // re-renders it under the same visibility rule the wire filter applies. // Without this the prompt would document bindings the gate denies. - const sdkIndex = result.sections.findIndex(section => section.name === 'tools:sdk') - if (sdkIndex >= 0) { - const sdkText = renderToolsSdk(ctx.tools.schemas().filter(schema => - visible(schema.name) && schema.name !== RUN_CODE_NAME)) - result.sections = result.sections.map((section, index) => - index === sdkIndex ? { ...section, text: sdkText } : section) - } + rerenderSdk(result, visible) return result }, { prepend: true }) + /** + * Re-render the `tools:sdk` section (present only under the registry's + * Code Mode) from the registry schemas the given rule admits — minus + * `run_code` itself, mirroring the registry's own exclusion. A no-op when + * the section is absent (native mode). + */ + function rerenderSdk(result: { sections: { name: string; order: number; text: string }[] }, include: (name: string) => boolean): void { + const sdkIndex = result.sections.findIndex(section => section.name === 'tools:sdk') + if (sdkIndex < 0) return + const sdkText = renderToolsSdk(ctx.tools.schemas().filter(schema => + include(schema.name) && schema.name !== RUN_CODE_NAME)) + result.sections = result.sections.map((section, index) => + index === sdkIndex ? { ...section, text: sdkText } : section) + } + ctx.on('tools/pre-execute', (exec, next): Promise => { if (exec.agent === undefined) return next() const active = this.activeDefinition(exec.agent.session) diff --git a/packages/mode/mode/tests/mode.spec.ts b/packages/mode/mode/tests/mode.spec.ts index 21a15fb2db..d03cd7bacf 100644 --- a/packages/mode/mode/tests/mode.spec.ts +++ b/packages/mode/mode/tests/mode.spec.ts @@ -402,22 +402,33 @@ describe('the soft layer', () => { expect(sdk).not.toContain('write(args:') }) - it('leaves the Code Mode SDK section untouched in the default mode', async () => { + it('default-mode Code Mode SDK is byte-identical to a no-dsh-mode deployment (exit binding hidden)', async () => { class FakeRuntime extends CodeRuntime { readonly language = 'typescript' readonly isolation = 'fake' run(_request: CodeRunRequest): Promise { return Promise.resolve({ logs: [] }) } } - const ctx = new Context() - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry, { mode: 'code' }) - await ctx.plugin(FakeRuntime) - await ctx.plugin(ModesService) - registerNamedTools(ctx, ['read', 'write']) + const withModes = new Context() + await withModes.plugin(SystemPrompt) + await withModes.plugin(ToolRegistry, { mode: 'code' }) + await withModes.plugin(FakeRuntime) + await withModes.plugin(ModesService) + registerNamedTools(withModes, ['read', 'write']) const agent = agentWithSession() - const sdk = (await ctx.systemPrompt.assemble({ agent })).sections.find(section => section.name === 'tools:sdk')?.text ?? '' + const sdk = (await withModes.systemPrompt.assemble({ agent })).sections.find(section => section.name === 'tools:sdk')?.text ?? '' expect(sdk).toContain('read(args:') expect(sdk).toContain('write(args:') + // The always-registered exit tool is callable only in plan mode, so a + // default-mode SDK advertising it would offer a binding that can only + // error — and diverge from a deployment that never loaded dsh-mode: + const bare = new Context() + await bare.plugin(SystemPrompt) + await bare.plugin(ToolRegistry, { mode: 'code' }) + await bare.plugin(FakeRuntime) + registerNamedTools(bare, ['read', 'write']) + const bareSdk = (await bare.systemPrompt.assemble({ agent })).sections.find(section => section.name === 'tools:sdk')?.text ?? '' + expect(sdk).toBe(bareSdk) + expect(sdk).not.toContain('exit_plan_mode(args:') }) it('treats a dropped folded definition as the default mode', async () => {