fix(mode): run_code passes both layers as a transport — Code Mode composes with plan mode
Review finding, valid: under the registry's Code Mode the assembly's only wire tool is run_code, which the plan allowlist filtered out — leaving the model with NO tools at all, the exit review included. The composition exists today (the acp-agent example ships a code-mode overlay), so plan mode bricked it outright. run_code is a transport, not a capability: every bridged sub-call is serialized back through ToolRegistry.execute() carrying the same agent, so tools/pre-execute judges each capability individually — exactly like native calls. Both layers now exempt it by name: the filter keeps it visible (tests pin plan-mode Code Mode assembly = ['run_code']) and the gate passes the wrapper while the same run's write sub-call still denies with the plan-mode reason. Documented residual, same class as the prepend-after-load one: the SDK section renders from the registry's store, so a plan-mode program may be offered bindings whose dispatch the gate then denies — nothing runs that a native call could not.
This commit is contained in:
@@ -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.
|
||||
**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.
|
||||
|
||||
## `ctx.modes`
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-code-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
|
||||
@@ -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 } from '@deepseek-ai/dsh-tools'
|
||||
import { defineTool, 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,8 +267,14 @@ export class ModesService extends Service {
|
||||
return result
|
||||
}
|
||||
const allowed = new Set(active.definition.tools)
|
||||
// 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 !== EXIT_PLAN_MODE || active.name === PLAN_MODE))
|
||||
(allowed.has(tool.name) || tool.name === RUN_CODE_NAME)
|
||||
&& (tool.name !== EXIT_PLAN_MODE || active.name === PLAN_MODE))
|
||||
return result
|
||||
}, { prepend: true })
|
||||
|
||||
@@ -276,6 +282,11 @@ export class ModesService extends Service {
|
||||
if (exec.agent === undefined) return next()
|
||||
const active = this.activeDefinition(exec.agent.session)
|
||||
if (active === undefined) return next()
|
||||
// Transport pass-through: a run_code program's every tool call is
|
||||
// serialized back through ToolRegistry.execute() with the same agent,
|
||||
// so each sub-call is judged here individually — gating the wrapper
|
||||
// would only remove the vehicle, not widen or narrow any capability.
|
||||
if (exec.name === RUN_CODE_NAME) return next()
|
||||
if (active.definition.tools.includes(exec.name)) return next()
|
||||
const reason = active.name === PLAN_MODE
|
||||
? `tool "${exec.name}" is not available in plan mode; continue planning and present your plan with ${EXIT_PLAN_MODE} when ready`
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { AgentId, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import UserInteractionService, { type AskUserQuestionRequest } from '@deepseek-ai/dsh-user-interaction'
|
||||
import { CodeRuntime, type CodeRunRequest, type CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
|
||||
import ModesService, { DEFAULT_MODE, EXIT_PLAN_MODE, PLAN_MODE, foldMode, resolveConfig } from '../src/index.ts'
|
||||
import type { ModeConfig } from '../src/index.ts'
|
||||
|
||||
@@ -349,6 +350,28 @@ describe('the soft layer', () => {
|
||||
expect(assembly.tools.map(tool => tool.name)).toEqual(['exit_plan_mode', 'read'])
|
||||
})
|
||||
|
||||
it('keeps run_code visible in plan mode under the registry Code Mode (transport, not capability)', async () => {
|
||||
// Minimal scriptable runtime: the SDK section resolves ctx.codeRuntime at
|
||||
// assembly time (the code-mode.spec fake's shape).
|
||||
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()
|
||||
agent.session.append('mode/set', { mode: PLAN_MODE })
|
||||
const assembly = await ctx.systemPrompt.assemble({ agent })
|
||||
// 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'])
|
||||
})
|
||||
|
||||
it('treats a dropped folded definition as the default mode', async () => {
|
||||
const ctx = await setup()
|
||||
registerNamedTools(ctx, ['read', 'write'])
|
||||
@@ -395,6 +418,25 @@ describe('the hard layer', () => {
|
||||
expect(denied.content).toEqual([{ type: 'text', text: 'Error: tool "write" is not available in "review" mode' }])
|
||||
})
|
||||
|
||||
it('passes run_code through the gate; bridged sub-calls are judged individually', async () => {
|
||||
const ctx = await setup()
|
||||
// Native mode here, so a stand-in run_code can register without clashing
|
||||
// with the registry's own (Code Mode) instance; the gate exempts by name.
|
||||
registerNamedTools(ctx, ['run_code', 'write'])
|
||||
const agent = agentWithSession()
|
||||
agent.session.append('mode/set', { mode: PLAN_MODE })
|
||||
const wrapper = await execute(ctx, 'run_code', agent)
|
||||
expect(wrapper.isError).toBe(false)
|
||||
// A sub-dispatch re-enters execute() with the same agent — the capability
|
||||
// is what the allowlist judges, exactly like a native call.
|
||||
const sub = await execute(ctx, 'write', agent)
|
||||
expect(sub.isError).toBe(true)
|
||||
expect(sub.content).toEqual([{
|
||||
type: 'text',
|
||||
text: 'Error: tool "write" is not available in plan mode; continue planning and present your plan with exit_plan_mode when ready',
|
||||
}])
|
||||
})
|
||||
|
||||
it('judges by the logged mode only — a pending intent does not gate', async () => {
|
||||
const ctx = await setup()
|
||||
registerNamedTools(ctx, ['write'])
|
||||
|
||||
Reference in New Issue
Block a user