qwen-code/docs/design/code-mode-only.md
tanzhenxin de2612434a
feat(core): allow concurrent Bash calls in Code Mode (#13151)
* feat(core): allow concurrent Bash calls in Code Mode

* fix(acp): preserve AUTO denial state across concurrent calls
2026-10-02 01:16:21 +00:00

9.7 KiB

CodeModeOnly MVP

Status

Implemented for #10377. The feature is opt-in and defaults off.

Partly superseded by Lazy Code Mode: tool_search is now a top-level direct control, and exec omits deferred tool signatures while search is available. The exposure table and the deferred-schema paragraph below describe this MVP; tool_call stays hidden.

Goal

Add a tools.codeModeOnly setting that replaces the ordinary model-facing tool surface with one exec JavaScript tool plus the small set of tools that must remain direct control-plane calls. exec code can call ordinary tools through tools.<name>(args) without bypassing Qwen Code's validation, permissions, approvals, hooks, telemetry, cancellation, concurrency, or output budgets.

Direct mode is a compatibility boundary: when the setting is false, tool registration, deferred-tool behavior, provider requests, and execution remain unchanged.

Non-goals

  • Hybrid direct/code exposure.
  • Persistent cells, globals, or values between exec calls.
  • Background jobs, wait, yield, store, or load.
  • Raw/freeform provider calls.
  • Making tool_search or a tool_call bridge available inside code mode.
  • A Node.js-compatible sandbox.

Configuration

{
  "tools": {
    "codeModeOnly": true
  }
}

The setting resolves once to the effective ToolMode value direct or code_mode_only. ToolRegistry and the execution surfaces consume that mode. exec is only registered when the setting is enabled, so disabling the setting also removes it from diagnostics and registry listings.

Exposure policy

The registry remains the source of truth. Exposure is a view over registered tools, never a second registry.

Category Model top level tools.* inside exec
exec CodeModeOnly only No
Direct control Yes No
Ordinary registered tool No Yes
Hidden bridge (tool_search, tool_call) No No

The direct-control allowlist is centralized and deliberately small. It covers user interaction (ask_user_question), delegation (agent), terminal output contracts, plan/goal/task/team/worktree/session controls, and ACP host controls whose lifecycle cannot safely be hidden behind an interpreted program. New tools default to code-mode-callable; adding a direct-only or hidden tool requires an explicit policy edit.

Deferred tools remain registered and callable from exec, and keep their deferred registry state. The generated exec description nonetheless carries their full schemas, alongside their names and descriptions in ALL_TOOLS: CodeModeOnly hides tool_search, and a nested call never appears in history as a functionCall, so no later reveal can supply a schema the description omitted. CodeModeOnly skips deferred preload and ToolSearch reminders because neither mechanism is part of its model-facing protocol.

Deterministic JavaScript interface

Before each provider tool sync, the exec description is generated from the current registry. Tools are sorted by canonical name. A name is normalized to a JavaScript property by replacing invalid identifier characters and prefixing names that begin with a digit. If two canonical names normalize to the same property, the lexicographically first name wins and one warning names the omitted collision.

The description defines:

  • a fresh async JavaScript execution environment;
  • tools.<normalizedName>(args) for nested calls;
  • ALL_TOOLS, including canonical and JavaScript names;
  • text(value), image(value), audio(value), and exit();
  • TypeScript-like signatures generated deterministically from JSON Schema;
  • the absence of Node.js, imports, network APIs, timers, and persistent state.

The nested call returns a JSON-safe object containing the real call id, tool name, status, output, and structured content. Failed and cancelled calls reject the guest promise with the scheduler/ACP error.

Sandbox and transport

exec runs QuickJS compiled to WebAssembly in a dedicated child process. Each call creates a fresh QuickJS runtime and context. The child process receives a small framed JSON protocol over stdio; it has no tool implementations or Qwen configuration. The parent maps JavaScript names back to canonical registry names and dispatches each call.

The guest has no Node globals, require, process, filesystem, sockets, module loader, console, timers, Atomics, SharedArrayBuffer, or WebAssembly. Dynamic and static imports fail because no module loader is installed. Runtime memory and stack limits are fixed. QuickJS's interrupt hook enforces a guest CPU budget. That budget and the parent's fallback watchdog pause while the guest is suspended on registered host tools, whose own scheduler/ACP timeouts remain authoritative, and resume before guest jobs run again. This lets long builds keep their declared tool timeout without allowing guest CPU loops to escape the fixed budget. Source, protocol frames, helper output, and the final result are bounded.

Cancellation aborts every nested call and terminates the child. Settling the top-level promise also cancels unawaited nested calls before teardown. No child, timer, promise handle, or guest global survives the call.

The child receives a minimal sanitized environment and cannot inspect it from the guest. The separate process is defense in depth for interpreter failures; QuickJS/WASM is the guest capability boundary.

Reentrant dispatch

CoreToolScheduler.schedule() cannot be called recursively: a child call would queue behind its still-running parent and deadlock. Scheduler execution therefore binds an async-local ToolCallRuntime context at the invocation boundary. exec only talks to that context.

The scheduler runtime batches nested calls received in one event-loop turn and runs them through a sibling scheduler configured from the same Config and observers. This preserves the existing build/validation, permission, confirmation, hook, execution, truncation, telemetry, and concurrency path without direct tool.execute() calls. Sequential guest awaits produce sequential batches; calls submitted together enter one batch. Code Mode Bash calls bypass the read-only command classifier, with the model responsible for keeping dependent calls sequential. Other tools retain their existing concurrency classification. Nested request ids include the parent id and carry source: code_mode plus parentCallId.

Nested scheduler updates are merged into the owning scheduler's visible calls, and confirmation responses for nested ids are delegated to it. The outer model still receives only the completed exec response.

ACP keeps its existing independently audited execution chain. Its invocation boundary binds the same runtime interface, with nested dispatch re-entering Session.runTool. Thus ACP top-level and nested calls use the same ACP permissions, approvals, hooks, telemetry, persistence, and cancellation rather than borrowing CLI scheduler state or executing tools directly. ACP serializes ordinary nested calls, matching its existing direct-tool ordering; the Core scheduler retains its existing safe-read parallel batches.

Provider behavior

All providers continue to consume FunctionDeclaration[] from ToolRegistry. In Direct mode this array is byte-for-byte the existing view. In CodeModeOnly it is the exposure-policy view, so Gemini/Qwen, OpenAI-compatible, and Anthropic adapters all receive the structured exec declaration without provider-specific prompting.

Filtered subagent declarations apply the same policy. For a read-only teammate or a fork with an execution allowlist, exec is the audited gateway while the exact allowed nested names are carried in its invocation context. The same set generates the description and is checked again before Core dispatch, so an explicit allowlist can narrow code-mode-callable nested tools without becoming prompt-only policy, exposing a hidden bridge, or making exec recursive. For cache-compatible forks, an inherited exec declaration represents its ordinary bindings: an omitted fork_tools inherits them, while an explicit list replaces them with the requested subset.

Failure and rollback

Unknown, collided, hidden, direct-only, and recursively requested tools fail closed before scheduling. Invalid arguments continue to fail in the normal execution chain. A sandbox startup, protocol, timeout, memory, or teardown failure becomes an exec tool error.

Rollback is setting tools.codeModeOnly to false. No session migration or registry cleanup is required because code mode has no persistent state and the ordinary registry was never replaced.

Verification

Unit and integration coverage must include:

  • Direct and CodeModeOnly exposure, deferred retention, collision handling, deterministic descriptions, and explicit subagent filtering.
  • Valid/invalid JavaScript, async sequencing, Promise.all, helper output, thrown errors, CPU loops, memory limits, imports, unavailable globals, isolation, output limits, cancellation, unawaited work, and recursive calls.
  • Nested permissions, confirmation, Pre/Post hooks, failures, output budgets, true-name telemetry/UI updates, MCP tools, and the scheduler deadlock regression.
  • Gemini/Qwen, OpenAI-compatible, Anthropic, headless, interactive, subagent, and ACP surfaces.

The implementation is complete only after focused package tests, build, typecheck, E2E probes, and two clean full-diff audit passes.