fix(mode): the default mode hides the exit binding from the Code Mode SDK too

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.
This commit is contained in:
kingwl
2026-07-10 22:09:36 +08:00
parent bedd908331
commit 0c2e773d67
4 files changed
+45 -19

No files matched your search

@@ -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 (100199); 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 (100199); 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
+1 -1
View File
@@ -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.
+24 -9
View File
@@ -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<PreToolDecision> => {
if (exec.agent === undefined) return next()
const active = this.activeDefinition(exec.agent.session)
+19 -8
View File
@@ -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<CodeRunResult> { 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 () => {