Prechádzať zdrojové kódy

fix(tools): reserve the Code Mode transport

Tianyi Cui 3 mesiacov pred
rodič
commit
850796bb35

+ 6 - 6
packages/core/tools/README.md

@@ -11,15 +11,15 @@ tools:
   mode: native   # native (default) | code | both
 ```
 
-`native` contributes every registered tool as a wire function definition — the default, byte-for-byte the pre-config behavior. `code` contributes exactly ONE wire tool, `run_code`, plus the generated `tools:sdk` prompt section (see [Code Mode](#code-mode)). `both` contributes every native definition AND `run_code` + the SDK section. Non-native modes require a loaded `ctx.codeRuntime` with `language: 'typescript'`; a missing or mismatched runtime rejects every prompt assembly with an actionable error, and a `systemPrompt.toolOrder` naming tools the mode no longer contributes rejects the assembly the same way.
+`native` contributes every registered tool as a wire function definition — the default, byte-for-byte the pre-config behavior. `code` contributes exactly ONE wire tool, `run_code`, plus the generated `tools:sdk` prompt section (see [Code Mode](#code-mode)). `both` contributes every native definition AND `run_code` + the SDK section. In non-native modes `run_code` is reserved presentation infrastructure rather than a filterable capability: allow/deny restrictions cannot remove it, and registering, shadowing, or explicitly filtering that name fails loudly. Non-native modes require a loaded `ctx.codeRuntime` with `language: 'typescript'`; a missing or mismatched runtime rejects every prompt assembly with an actionable error, and a `systemPrompt.toolOrder` naming tools the mode no longer contributes rejects the assembly the same way.
 
 ### Public API
 
-- `ctx.tools.register(definition: ToolDefinition): () => Promise<void> | void` Register a tool. The layer is the CALLING context's scope (`dsh-scope`): a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, SHADOWING a same-named global tool there (per-agent tool variants). Duplicate names within one layer throw. Disposed with the calling fiber (= the agent, for scoped registrations).
-- `ctx.tools.restrict(filter: ToolRestriction): () => Promise<void> | void` Scoped-only (throws on a plain context): mask the GLOBAL tool surface for the calling agent — `allow` keeps only the listed tools, `deny` removes them; multiple restrictions intersect; scoped registrations bypass restriction as explicit grants. Snapshot-at-registration, loud unknown-name validation, `restrict({})` rejects (the materialized-empty-config trap).
+- `ctx.tools.register(definition: ToolDefinition): () => Promise<void> | void` Register a tool. The layer is the CALLING context's scope (`dsh-scope`): a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, SHADOWING a same-named global tool there (per-agent tool variants). Duplicate names within one layer throw; non-native modes also reject the reserved `run_code` transport name. Disposed with the calling fiber (= the agent, for scoped registrations).
+- `ctx.tools.restrict(filter: ToolRestriction): () => Promise<void> | void` Scoped-only (throws on a plain context): mask the GLOBAL end-capability surface for the calling agent — `allow` keeps only the listed tools, `deny` removes them; multiple restrictions intersect; scoped registrations bypass restriction as explicit grants. The reserved `run_code` transport remains available automatically and cannot be named explicitly. Snapshot-at-registration, loud unknown-name validation, `restrict({})` rejects (the materialized-empty-config trap).
 - `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined` Resolution as one scope sees it (shadowing applied; a restricted-away global reads as absent) — presenters pass the calling agent so the card matches what executed.
-- `ctx.tools.visible(scope?: ScopeKey): ToolDefinition[]` THE visibility function — restricted global layer ∪ the scope's own layer — feeding prompt assembly, `get`, and `execute`, so what the model sees and what dispatches can never disagree.
-- `ctx.tools.knownNames(scope?: ScopeKey): string[]` The PRE-restriction name universe configuration (`toolOrder`, `restrict`) validates against: a typo fails loud while a restricted-away tool stays a normal absence.
+- `ctx.tools.visible(scope?: ScopeKey): ToolDefinition[]` THE visibility function — restricted global layer ∪ the scope's own layer, plus the reserved transport in non-native modes — feeding prompt assembly, `get`, and `execute`, so what the model sees and what dispatches can never disagree.
+- `ctx.tools.knownNames(scope?: ScopeKey): string[]` The PRE-restriction end-capability name universe `restrict` validates against: a typo fails loud while a restricted-away tool stays a normal absence. Presentation providers add reserved transport names separately when validating `toolOrder`.
 - `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]` Schemas of everything the scope can see (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog RFC](../../../docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md)).
 - `ctx.tools.execute(exec: ToolExecution): Promise<ToolExecutionResult>` Execute one tool call through the `tools/pre-execute` → `tools/execute` → `tools/post-execute` pipeline.
 
@@ -133,7 +133,7 @@ const bash = defineTool({
 
 ### Code Mode
 
-Under `mode: code` (or `both`) the registry turns the tool surface into a programming API, per the [Code Mode RFC](../../../docs/rfc/implemented/feature/2026-06-15-code-mode.md): the model writes a TypeScript program (the body of an async function) and passes it to the ONE wire tool `run_code`; the program runs in `ctx.codeRuntime` (the [code-execution seam](../../code-runtime/README.md) — the shipped backend is a worker thread) with one async binding per registered tool (`await tools.bash({...})`), and ONLY what it prints or returns re-enters the model's context.
+Under `mode: code` (or `both`) the registry turns the tool surface into a programming API, per the [Code Mode RFC](../../../docs/rfc/implemented/feature/2026-06-15-code-mode.md): the model writes a TypeScript program (the body of an async function) and passes it to the reserved wire transport `run_code`; the program runs in `ctx.codeRuntime` (the [code-execution seam](../../code-runtime/README.md) — the shipped backend is a worker thread) with one async binding per visible end-capability tool (`await tools.bash({...})`), and ONLY what it prints or returns re-enters the model's context. Scope restrictions change those SDK bindings but cannot remove or replace the transport itself.
 
 - **The SDK section** (`tools:sdk`, order 150): a lazy prompt section regenerating, at each assembly, a `declare const tools: {...}` TypeScript declaration of every registered tool except `run_code` (exotic names via quoted keys), plus fixed usage instructions. Deterministic — lexicographic tool order, byte-identical text for an unchanged tool set (prefix-cache-friendly). The codegen (`jsonSchemaToTs`, exported) is TOTAL: constructs outside the `defineTool` subset degrade to `unknown`, never throw.
 - **The dispatch bridge** (`run_code`'s execute): every binding call is JSON-normalized BEFORE dispatch (a value that does not survive — `BigInt`, circulars — rejects that one call, so the dispatched form and the logged form are the same JSON value by construction), serialized through a per-run queue (even `Promise.all` executes the underlying `ctx.tools.execute()` calls one at a time in submission order — the tool contract carries no concurrency-safety metadata yet), gated by `tools/pre-execute`/`tools/post-execute` like any native call (a deny reaches the program as a binding rejection), and logged as one `tool/code-dispatch` session event (log-only: `deriveMessages()` never surfaces it) with the deterministic sub-id `<parent>:code:<n>`. A failed sub-call REJECTS the program-side promise with the tool's error text — real code error handling, no bespoke envelope. A sub-call's `additionalContext` is deliberately DROPPED (no safe outlet mid-run without breaking tool-call/result adjacency; deferred until a real hook needs it through Code Mode).

+ 2 - 1
packages/core/tools/src/code-mode.ts

@@ -138,7 +138,8 @@ function asRunCodeMeta(meta: unknown): RunCodeMeta | undefined {
 /**
  * Build the `run_code` {@link ToolDefinition}: one required `code` parameter,
  * executed through the dispatch bridge described in the module doc. The
- * registry registers it under non-native modes.
+ * registry reserves it as presentation infrastructure under non-native modes,
+ * outside the filterable global/scoped capability layers.
  * @param registry - the owning registry (sub-calls go through its `execute`,
  *   bindings cover its registered tools).
  * @param requireRuntime - resolves `ctx.codeRuntime` or throws the loud

+ 52 - 19
packages/core/tools/src/index.ts

@@ -359,8 +359,10 @@ export interface Config {
  * `deny` removes the listed ones; both present = allow first, then deny.
  * Restrictions never touch scoped registrations — a tool registered through
  * the same scope is an explicit grant that bypasses them (which is what keeps
- * e.g. a structured-output capture tool alive under an allow-list). Multiple
- * restrictions on one scope compose by intersection: every one must admit.
+ * e.g. a structured-output capture tool alive under an allow-list). The
+ * reserved `run_code` presentation transport is likewise outside capability
+ * filtering, and naming it explicitly is rejected. Multiple restrictions on
+ * one scope compose by intersection: every one must admit.
  */
 export interface ToolRestriction {
   /** Global tool names that stay visible; everything else is removed. */
@@ -374,8 +376,8 @@ export interface ToolRestriction {
  * loop executes calls through the `tools/pre-execute` → `tools/execute` →
  * `tools/post-execute` pipeline. The registry contributes its schemas into the
  * system-prompt assembly — WHICH schemas is governed by its `mode` config
- * (see {@link Config.mode}); under a non-native mode it also registers the
- * `run_code` tool and the `tools:sdk` prompt section itself.
+ * (see {@link Config.mode}); under a non-native mode it also owns the reserved
+ * `run_code` presentation transport and the `tools:sdk` prompt section.
  *
  * Two registration layers (`@deepseek-ai/dsh-scope`): a registration through a
  * plain plugin context is GLOBAL (visible to every agent); one through a
@@ -401,15 +403,24 @@ export class ToolRegistry extends Service {
   /** Snapshot-at-registration restriction filters, per scope (see {@link restrict}). */
   private restrictions = new Map<ScopeKey, ToolRestriction[]>()
   private readonly mode: ToolPresentationMode
+  /** Reserved presentation transport, kept outside the filterable registration layers. */
+  private readonly codeTransport: ToolDefinition | undefined
 
   constructor(ctx: Context, config: Config = {}) {
     super(ctx, 'tools')
     // The schema already defaulted an omitted mode; the ?? narrows the
     // optional-input type for direct (non-Loader) construction in tests.
     this.mode = config.mode ?? 'native'
+    // `run_code` is presentation infrastructure, not an end capability. It
+    // therefore does not enter the global layer: per-agent restrictions must
+    // not remove it, and a scoped registration must not shadow it. The
+    // visibility resolver appends this reserved definition after resolving
+    // the filterable global/scoped capability layers.
+    this.codeTransport = this.mode === 'native'
+      ? undefined
+      : createRunCodeTool(this, () => this.requireCodeRuntime())
     ctx.systemPrompt.tools(context => this.wireSchemas(context.scope))
     if (this.mode !== 'native') {
-      this.register(createRunCodeTool(this, () => this.requireCodeRuntime()))
       ctx.systemPrompt.section({
         name: 'tools:sdk',
         order: SDK_SECTION_ORDER,
@@ -442,7 +453,9 @@ export class ToolRegistry extends Service {
    * pre-restriction and a restricted-away tool in `toolOrder` is a normal
    * absence — while the MODE collapse is deployment config, so under
    * `mode: 'code'` the universe is `[run_code]` and a `toolOrder` naming a
-   * native tool is dead configuration that fails every assembly loud.
+   * native tool is dead configuration that fails every assembly loud. Under
+   * `mode: 'both'`, the provider adds the reserved transport to the
+   * capability-only {@link knownNames} universe for `toolOrder` validation.
    */
   private wireSchemas(scope?: ScopeKey): ToolProviderResult {
     if (this.mode === 'native') return { schemas: this.schemas(scope), knownNames: this.knownNames(scope) }
@@ -451,7 +464,7 @@ export class ToolRegistry extends Service {
     if (this.mode === 'code') {
       return { schemas: all.filter(schema => schema.name === RUN_CODE_NAME), knownNames: [RUN_CODE_NAME] }
     }
-    return { schemas: all, knownNames: this.knownNames(scope) }
+    return { schemas: all, knownNames: [...this.knownNames(scope), RUN_CODE_NAME] }
   }
 
   /**
@@ -480,9 +493,10 @@ export class ToolRegistry extends Service {
    * with the scope, and shadowing a same-named global tool for that agent.
    * Throws if the SAME layer already has the name (cross-layer name twins are
    * the shadowing feature, not an error; the global-duplicate message names
-   * `agent.ctx` as the per-agent alternative). The visible schema set flows
-   * into prompt assembly automatically. Disposed with the calling fiber.
-   * Emits `tools/change` on register/unregister.
+   * `agent.ctx` as the per-agent alternative), or if a non-native mode reserves
+   * the `run_code` name for its presentation transport. The visible schema set
+   * flows into prompt assembly automatically. Disposed with the calling
+   * fiber. Emits `tools/change` on register/unregister.
    * @param definition - the tool's schema plus its execute (and optional
    *   presentation) functions.
    * @returns the disposer that unregisters the tool. The exact
@@ -491,6 +505,9 @@ export class ToolRegistry extends Service {
    */
   register(definition: ToolDefinition): () => Promise<void> | void {
     const scope = scopeOf(this.ctx)
+    if (this.codeTransport !== undefined && definition.name === RUN_CODE_NAME) {
+      throw new Error(`tool name "${RUN_CODE_NAME}" is reserved for the Code Mode presentation transport and cannot be registered or shadowed`)
+    }
     const dispose = this.ctx.effect(function* (this: ToolRegistry) {
       const layer = scope === undefined ? this.global : this.layerFor(scope)
       if (layer.has(definition.name)) {
@@ -531,10 +548,13 @@ export class ToolRegistry extends Service {
    * name universe ({@link knownNames}) and throws on an unknown one (fail loud
    * beats a typo silently filtering nothing) — register restrictions after the
    * global tools they mask exist (the agent-creation `setup` window satisfies
-   * this). The filter is SNAPSHOT at registration: later caller mutation of
-   * the arrays changes nothing. Multiple restrictions compose by intersection.
-   * Scoped registrations bypass restrictions (explicit grants win). Disposed
-   * with the calling fiber (revocable independently); emits `tools/change`.
+   * this). A non-native mode's reserved `run_code` presentation transport is
+   * not a filterable capability; naming it explicitly throws, while omitting
+   * it from an allow-list cannot remove it. The filter is SNAPSHOT at
+   * registration: later caller mutation of the arrays changes nothing.
+   * Multiple restrictions compose by intersection. Scoped registrations
+   * bypass restrictions (explicit grants win). Disposed with the calling
+   * fiber (revocable independently); emits `tools/change`.
    * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).
    * @returns the disposer that lifts this restriction. The exact
    *   Cordis effect disposer (single-shot): composite (generator) effects may
@@ -553,6 +573,10 @@ export class ToolRegistry extends Service {
       ...filter.allow !== undefined ? { allow: [...filter.allow] } : {},
       ...filter.deny !== undefined ? { deny: [...filter.deny] } : {},
     }
+    if (this.codeTransport !== undefined
+      && [...snapshot.allow ?? [], ...snapshot.deny ?? []].includes(RUN_CODE_NAME)) {
+      throw new Error(`tools.restrict() cannot name reserved Code Mode presentation transport "${RUN_CODE_NAME}"; restrict end-capability tools instead`)
+    }
     const known = new Set(this.knownNames(scope))
     const unknown = [...snapshot.allow ?? [], ...snapshot.deny ?? []].filter(name => !known.has(name))
     if (unknown.length > 0) {
@@ -604,7 +628,8 @@ export class ToolRegistry extends Service {
    * THE visibility function — one resolution feeding prompt assembly,
    * {@link get}, and {@link execute}: the global layer masked by the scope's
    * restrictions, unioned with the scope's own layer, scoped shadowing global
-   * on a name conflict. No scope = the unrestricted global view.
+   * on a name conflict, then the non-native mode's reserved `run_code`
+   * presentation transport. No scope = the unrestricted global view.
    * @param scope - the viewing scope (the agent), or undefined for the global view.
    * @returns the visible definitions (scoped shadows applied), in per-layer
    *   registration order, global layer first.
@@ -618,6 +643,10 @@ export class ToolRegistry extends Service {
     // Scoped layer second: same-name entries REPLACE (shadow) the global ones,
     // and grants bypass restrictions by construction (never filtered above).
     for (const [name, definition] of layer ?? []) result.set(name, definition)
+    // Presentation infrastructure is resolved last and outside capability
+    // filtering. Registration rejects this reserved name, so this set is an
+    // invariant assertion as well as protection against future layer changes.
+    if (this.codeTransport !== undefined) result.set(RUN_CODE_NAME, this.codeTransport)
     return [...result.values()]
   }
 
@@ -631,6 +660,7 @@ export class ToolRegistry extends Service {
    * @returns the definition the scope resolves, or undefined when none is visible.
    */
   get(name: string, scope?: ScopeKey): ToolDefinition | undefined {
+    if (name === RUN_CODE_NAME && this.codeTransport !== undefined) return this.codeTransport
     const shadowed = scope === undefined ? undefined : this.scoped.get(scope)?.get(name)
     if (shadowed) return shadowed
     if (!this.admits(scope, name)) return undefined
@@ -658,10 +688,13 @@ export class ToolRegistry extends Service {
   }
 
   /**
-   * The PRE-restriction name universe for `scope`: every global name plus the
-   * scope's own layer, ignoring restrictions. This is the set configuration
-   * (`toolOrder`, `restrict()` filters) validates against, so a typo fails
-   * loud while a restricted-away tool remains a normal, non-erroneous absence.
+   * The PRE-restriction END-CAPABILITY name universe for `scope`: every global
+   * name plus the scope's own layer, ignoring restrictions. This is the set
+   * `restrict()` validates against, so a typo fails loud while a
+   * restricted-away tool remains a normal, non-erroneous absence. Reserved
+   * presentation transports are deliberately absent: `restrict()` rejects
+   * naming one, while {@link wireSchemas} adds it to the separate `toolOrder`
+   * validation universe when its presentation mode contributes it.
    * @param scope - the viewing scope (the agent); omitted = global names only.
    * @returns the known names, deduplicated.
    */

+ 106 - 0
packages/core/tools/tests/code-mode.spec.ts

@@ -1,11 +1,14 @@
 import { describe, expect, it } from 'vitest'
 import { Context } from 'cordis'
 import { CallId } from '@deepseek-ai/dsh-llm'
+import { createScope } from '@deepseek-ai/dsh-scope'
+import type { Scope } from '@deepseek-ai/dsh-scope'
 import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
 import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
 import type { CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
 import ToolRegistry, { CodeRunFailedError, RUN_CODE_NAME, defineTool } from '@deepseek-ai/dsh-tools'
 import type { Config, PostToolDecision, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
+import { AgentId } from '@deepseek-ai/dsh-agent'
 import type { Agent } from '@deepseek-ai/dsh-agent'
 import { Session, SessionId } from '@deepseek-ai/dsh-session'
 import type { SessionEventMap } from '@deepseek-ai/dsh-session'
@@ -54,6 +57,15 @@ async function setup(options: SetupOptions = {}) {
   return { ctx, tools: ctx.tools, systemPrompt: ctx.systemPrompt, runtime: runtime! }
 }
 
+/** Mint one production-shaped agent scope that can register scoped tool policy. */
+async function mintAgentScope(ctx: Context, name = 'scoped'): Promise<{ scope: Scope; agent: Agent }> {
+  const agent = { id: AgentId(name) } as Agent
+  let scope!: Scope
+  await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, agent) },
+    { inject: ['tools', 'systemPrompt'] }))
+  return { scope, agent }
+}
+
 /** Register a trivial echo tool; returns the calls it received. */
 function registerEcho(ctx: Context, name = 'echo'): unknown[] {
   const calls: unknown[] = []
@@ -119,6 +131,100 @@ describe('mode-aware wire contribution', () => {
     expect(assembly.sections.some(section => section.name === 'tools:sdk')).toBe(true)
   })
 
+  it.each(['code', 'both'] as const)('keeps the run_code transport outside scoped allow-list filtering in mode %s', async (mode) => {
+    const { ctx, systemPrompt, runtime } = await setup({ mode })
+    registerEcho(ctx, 'echo')
+    registerEcho(ctx, 'hidden')
+    const { scope, agent } = await mintAgentScope(ctx)
+    const lift = scope.ctx.tools.restrict({ allow: ['echo'] })
+
+    const assembly = await systemPrompt.assemble({ scope: agent })
+    expect(assembly.tools.map(tool => tool.name)).toEqual(mode === 'code'
+      ? [RUN_CODE_NAME]
+      : ['echo', RUN_CODE_NAME])
+    const sdk = assembly.sections.find(section => section.name === 'tools:sdk')?.text
+    expect(sdk).toContain('echo(args:')
+    expect(sdk).not.toContain('hidden(args:')
+
+    runtime.behavior = request => Promise.resolve({
+      logs: [],
+      value: Object.keys(request.bindings[0]!.functions).sort().join(','),
+    })
+    const result = await runCode(ctx, 'return Object.keys(tools)', { agent })
+    expect(result.isError).toBe(false)
+    expect(result.content).toEqual([{ type: 'text', text: 'echo' }])
+
+    await lift()
+    const unrestricted = await systemPrompt.assemble({ scope: agent })
+    expect(unrestricted.tools.map(tool => tool.name)).toEqual(mode === 'code'
+      ? [RUN_CODE_NAME]
+      : ['echo', 'hidden', RUN_CODE_NAME])
+  })
+
+  it.each(['code', 'both'] as const)('keeps the run_code transport outside scoped deny-list filtering in mode %s', async (mode) => {
+    const { ctx, systemPrompt, runtime } = await setup({ mode })
+    registerEcho(ctx, 'denied')
+    registerEcho(ctx, 'kept')
+    const { scope, agent } = await mintAgentScope(ctx)
+    scope.ctx.tools.restrict({ deny: ['denied'] })
+
+    const assembly = await systemPrompt.assemble({ scope: agent })
+    expect(assembly.tools.map(tool => tool.name)).toEqual(mode === 'code'
+      ? [RUN_CODE_NAME]
+      : ['kept', RUN_CODE_NAME])
+    const sdk = assembly.sections.find(section => section.name === 'tools:sdk')?.text
+    expect(sdk).not.toContain('denied(args:')
+    expect(sdk).toContain('kept(args:')
+
+    runtime.behavior = request => Promise.resolve({
+      logs: [],
+      value: Object.keys(request.bindings[0]!.functions).sort().join(','),
+    })
+    const result = await runCode(ctx, 'return Object.keys(tools)', { agent })
+    expect(result.isError).toBe(false)
+    expect(result.content).toEqual([{ type: 'text', text: 'kept' }])
+  })
+
+  it.each(['code', 'both'] as const)('reserves run_code against scoped shadows and explicit restrictions in mode %s', async (mode) => {
+    const { ctx, systemPrompt } = await setup({ mode })
+    const { scope, agent } = await mintAgentScope(ctx)
+    const impostor = defineTool({
+      name: RUN_CODE_NAME,
+      description: 'Scoped impostor.',
+      parameters: {},
+      execute: () => Promise.resolve([{ type: 'text' as const, text: 'impostor' }]),
+    })
+
+    expect(() => scope.ctx.tools.register(impostor)).toThrow(/reserved for the Code Mode presentation transport/)
+    expect(() => ctx.tools.register(impostor)).toThrow(/reserved for the Code Mode presentation transport/)
+    expect(() => scope.ctx.tools.restrict({ allow: [RUN_CODE_NAME] })).toThrow(/cannot name reserved Code Mode presentation transport/)
+    expect(() => scope.ctx.tools.restrict({ deny: [RUN_CODE_NAME] })).toThrow(/cannot name reserved Code Mode presentation transport/)
+
+    const assembly = await systemPrompt.assemble({ scope: agent })
+    const transports = assembly.tools.filter(tool => tool.name === RUN_CODE_NAME)
+    expect(transports).toHaveLength(1)
+    expect(transports[0]?.description).toContain('Execute a TypeScript program')
+    expect(ctx.tools.get(RUN_CODE_NAME, agent)).toBe(ctx.tools.get(RUN_CODE_NAME))
+    expect(ctx.tools.knownNames(agent)).not.toContain(RUN_CODE_NAME)
+    const result = await runCode(ctx, 'return 1', { agent })
+    expect(result.content).toEqual([{ type: 'text', text: '(run_code completed with no output)' }])
+  })
+
+  it.each(['code', 'both'] as const)('keeps run_code in the toolOrder universe without exposing it as a restriction target in mode %s', async (mode) => {
+    const { ctx, systemPrompt } = await setup({
+      mode,
+      toolOrder: [RUN_CODE_NAME, '<unlisted-tools>'],
+    })
+    registerEcho(ctx)
+    const { agent } = await mintAgentScope(ctx)
+
+    expect(ctx.tools.knownNames(agent)).toEqual(['echo'])
+    const assembly = await systemPrompt.assemble({ scope: agent })
+    expect(assembly.tools.map(tool => tool.name)).toEqual(mode === 'code'
+      ? [RUN_CODE_NAME]
+      : [RUN_CODE_NAME, 'echo'])
+  })
+
   it("never exposes run_code to programs, even under mode 'both' (no recursive dispatch path)", async () => {
     const { ctx, runtime } = await setup({ mode: 'both' })
     registerEcho(ctx)