fix(mode): the Code Mode SDK section is re-rendered under the mode's visibility rule

Review follow-up on the residual the previous commit accepted — and the
acceptance was wrong, because the fix is clean: in Code Mode the SDK
section IS the soft surface (the wire carries only run_code), section
text resolves in assemble's base, and renderToolsSdk is an exported
pure renderer. The outermost wrapper therefore re-renders tools:sdk
from the same visibility predicate the wire filter applies (allowlist,
exit-IFF-plan, minus run_code mirroring the registry's own exclusion):
a plan-mode program is documented exactly the callable bindings — read
and the exit, never the denied write. The default mode leaves the
section untouched (absence of policy), both pinned by tests.

The soft layer's promise — the model is never encouraged toward a tool
the gate denies — now holds in Code Mode too; the only remaining
prompt-honesty residual is a prepend-after-load assemble listener,
where the gate still covers execution.
This commit is contained in:
kingwl
2026-07-10 21:09:28 +08:00
parent 976deda91b
commit e2628442fa
4 changed files with 44 additions and 7 deletions

View File

@@ -12,7 +12,7 @@ The `default` mode is the absence of policy: no section, no filtering, no gate.
**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`.
**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, and every bridged sub-call re-enters this gate with the same agent, so the allowlist governs each capability individually.
**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.
## `ctx.modes`

View File

@@ -26,7 +26,7 @@
import { Context, Service } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import { defineTool, RUN_CODE_NAME } from '@deepseek-ai/dsh-tools'
import { defineTool, renderToolsSdk, RUN_CODE_NAME } from '@deepseek-ai/dsh-tools'
import type { PreToolDecision } from '@deepseek-ai/dsh-tools'
import type {} from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-user-interaction'
@@ -267,14 +267,26 @@ export class ModesService extends Service {
return result
}
const allowed = new Set(active.definition.tools)
const visible = (name: string): boolean =>
allowed.has(name) && (name !== EXIT_PLAN_MODE || active.name === PLAN_MODE)
// run_code is a TRANSPORT, not a capability: under the registry's Code
// Mode it is the only wire tool (filtering it would leave the model
// with nothing, not even the exit), and every bridged sub-call
// re-enters tools/pre-execute with the same agent, where the allowlist
// governs each capability individually.
result.tools = result.tools.filter(tool =>
(allowed.has(tool.name) || tool.name === RUN_CODE_NAME)
&& (tool.name !== EXIT_PLAN_MODE || active.name === PLAN_MODE))
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).
// 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)
}
return result
}, { prepend: true })

View File

@@ -370,6 +370,31 @@ describe('the soft layer', () => {
// Code Mode's only wire tool survives the filter — without it the model
// would have NO tools at all, not even a path to the exit review.
expect(assembly.tools.map(tool => tool.name)).toEqual(['run_code'])
// The SDK section is Code Mode's soft surface: it is re-rendered under
// the same visibility rule, so plan mode documents exactly the callable
// bindings — the allowlisted read and the exit — and never the denied write.
const sdk = assembly.sections.find(section => section.name === 'tools:sdk')?.text ?? ''
expect(sdk).toContain('read(args:')
expect(sdk).toContain('exit_plan_mode(args:')
expect(sdk).not.toContain('write(args:')
})
it('leaves the Code Mode SDK section untouched in the default mode', 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 agent = agentWithSession()
const sdk = (await ctx.systemPrompt.assemble({ agent })).sections.find(section => section.name === 'tools:sdk')?.text ?? ''
expect(sdk).toContain('read(args:')
expect(sdk).toContain('write(args:')
})
it('treats a dropped folded definition as the default mode', async () => {