feat(core): add CodeModeOnly tool execution (#10607)

* feat(core): add CodeModeOnly tool execution

* fix(core): preserve CodeModeOnly execution semantics

* fix(core): drop the tool_search reminder in CodeModeOnly

CodeModeOnly hides `tool_search` and binds every deferred tool — schema
included — into the `exec` description, so progressive discovery has
nothing left to do. The session prelude still announced those tools as
"reachable via `tool_search`", pointing the model at a tool that is
never declared and re-billing the text on every cached prefix. Worse,
tools already callable as `tools.web_fetch(...)` looked gated behind a
lookup step that does not exist.

The subagent and fork-resume callers already opted out, but the
main-session prelude sites did not, so the gate now lives where the
prelude is built rather than in each caller.

* chore: refresh settings schema

* chore: refresh companion notices

* fix: register exec tool metadata

* fix(cli): localize exec tool display name

* fix(core): stop offering ToolSearch lookups in CodeModeOnly

CodeModeOnly hides tool_search and binds every deferred schema into the
exec description, so text that tells the model to look a tool up
describes a surface it cannot reach. Return an empty deferred summary
from the registry itself - this also covers the fork-resume reminder
that bypassed the earlier call-site gate - and point the image zoom
hint at tools.zoom_image.

* feat(core): teach the system prompt CodeModeOnly's tool surface

In CodeModeOnly the model can only call exec, so the tool guidance and the
worked examples described a surface it does not have: bare tool names it
cannot call, an instruction to issue several tool calls in one response, and
examples in a direct tool-call syntax that does not exist there.

Give "Using Your Tools" a code-mode branch that routes every tool through
tools.<name>, states which tools are direct controls instead, and replaces
multi-call parallelism with batching inside one exec program. Swap the four
model-family example sets for one shared exec set, since the syntax
differences they exist for do not apply. The mechanics of exec itself stay in
its tool description; the prompt carries only policy.

* test: include code mode host in standalone fixture

* fix(core): return code mode media as multimodal output

* feat(core): support image tool results in code mode

* fix(core): carry deferred tool schemas in the exec description

CodeModeOnly hides tool_search and never surfaces a nested call as a history
functionCall, so a signature collapsed to Record<string, unknown> could never
be filled in later: parameter names were reachable only by guessing, or by
reading them off validation errors one at a time. Deferred tools keep their
registry state; only the generated signature gains the schema.

* fix(node-repl): restore the bin exec bit after rebuild

npm chmods dist/index.js when it links the bin at install time, but the
build deletes that exact file and tsc re-emits it as 0644. After any
rebuild node_modules/.bin/node-repl-mcp therefore points at a
non-executable target and spawning it fails with EACCES (exit 126)
before the shebang is ever read.

OR the exec bits into the emitted mode instead of setting 0o755, so a
restrictive umask is preserved rather than widened.

* fix(cli): pin patched Ink version

* feat(cli): add collapsible tool call details

* fix(dev): use CLI tsconfig outside repository

* fix(cli): preserve expanded tool details after completion

* fix(cli): fail headless runs on model API errors

* fix(core): expose shell timeout bounds in code mode

* feat(core): add code mode timeout helpers

* feat(cli): expose code mode in settings

* fix(mcp): preserve structured results for model and exec consumers

* fix(core): preserve code mode output and truncate inline

* feat(core): port scoped context tools to code mode

* fix(build): synchronize pnpm lockfile and capability docs

* fix(core): recover from rejected Responses encrypted reasoning
This commit is contained in:
Dragon authored and GitHub committed 2026-09-11 07:40:35 +00:00
1 parent 7ea46451d6
commit 4ec9f63d0a
86 files changed
+7096 -174

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
# Context tools in code mode
Port the tool exposure and context delivery changes from dragon/code-mode-only onto the QuickJS implementation on dragon/code-mode-only-10377. Preserve the target branch's media helpers, timeout accounting, inline output limits, scheduler batching, explicit per-agent allowlist, and output recovery.
Only eleven registered tools remain direct: ask_user_question, agent, enter_plan_mode, exit_plan_mode, structured_output, create_sub_session, enter_worktree, exit_worktree, send_message, speak_to_user, and wait_threads. Exec is the top-level entry point; tool_search and tool_call stay hidden. Other registered tools are eligible subject to existing agent restrictions and approvals. Worktree approval behavior is unchanged.
The core and ACP dispatchers expose completed native responses to exec via an optional callback before converting them to JavaScript values. Exec retains skill, update_goal and capture_screen_context outputs even without text(), forwards modelOverride property presence and terminateTurn, and delivers native images outside the QuickJS protocol. A host-owned JSON first line records toolResults for skill history restoration. Required context is not subjected to a second script output cap; ordinary script output retains its existing bound and global response budgets still apply. Later script errors remain visible alongside retained context through the normal delivery path. Cancellation clears skill tracking when context cannot be delivered.
A goal proposal forms an execution barrier: previous nested tools finish before it and later tools wait for it. Ordinary calls still execute concurrently. A terminal proposal triggers a host termination message, which completes the QuickJS call with accumulated output and disposes the runtime without allowing script code to catch the termination. Tests cover sequential and concurrent dispatch, nonterminal proposals, native media, skill restoration, cancellation, partial errors, and agent restrictions.
Build, typecheck, bundle, run focused core and ACP tests and the mock CLI plan in .qwen/e2e-tests/code-mode-10377-port.md. Push the migrated branch before removing the old branch locally and on its fork remote. Preserve any unrelated worktree files.
+51
View File
@@ -0,0 +1,51 @@
# Code Mode media helpers
## Goal
Let Code Mode consume the media shapes that Qwen Code already returns from
nested MCP and `image_gen` tool calls:
- `image(result.content[0])` accepts a Qwen MCP image content block.
- `generatedImage(await tools.image_gen(...))` accepts the complete
`CodeModeToolResult` returned by Qwen Code's built-in image generator.
## Data contract
Nested tool responses expose image media through `content` as Qwen's existing
MCP image shape:
```ts
type ImageContent = {
type: 'image';
data: string;
mimeType: string;
};
```
The Core scheduler and the CLI ACP session derive these blocks through one
shared normalizer over their existing GenAI response parts. Text remains
available through `CodeModeToolResult.output`. This keeps the guest API
independent of the scheduler's internal `functionResponse` wrapper.
`generatedImage()` accepts the complete successful result from `image_gen`,
appends its image content, and appends the existing `output` string as the
generated-image save hint. It does not introduce Codex's
`{ image_url, output_hint }` shape because that is not what Qwen Code returns.
## Limits and validation
Both helpers use the existing Code Mode media item and decoded-byte budgets.
MCP image blocks must contain base64 data and an `image/*` MIME type.
`generatedImage()` only accepts a successful result whose tool name is
`image_gen` and which contains image content.
Media-bearing nested tool results use the existing 16 MiB media frame limit so
generated images larger than the 1 MiB control limit are preserved. Tool calls
and tool results without image content retain the 1 MiB control frame limit.
## Non-goals
- Image detail hints.
- Remote image URLs.
- `yield_control`, persistent storage, or timers.
- Changing the built-in `image_gen` tool's direct-call response.
+194
View File
@@ -0,0 +1,194 @@
# CodeModeOnly MVP
## Status
Implemented for [#10377](https://github.com/QwenLM/qwen-code/issues/10377).
The feature is opt-in and defaults off.
## 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
```json
{
"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; `Promise.all` calls enter one batch, where the existing
read-only concurrency classifier applies. 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.
+9
View File
@@ -0,0 +1,9 @@
# CodeMode output on failure
The `dragon/code-mode-only-10377` runtime already accumulates `text()` in its host process, but its error frame discards that output. Return the accumulated text and media with script errors, keep failure status, and bound the combined text and error at the exec boundary. This follows Codex's inline result behavior; it does not save a full-output recovery file.
Exec owns its output limit. Exempt its successful and failed results from the scheduler's persistence gates, and declare an empty persisted-file list so aggregate batch truncation also stays inline. Preserve both the beginning and end when truncating text, including a visible truncation marker.
Pass the scheduler's call source through an asynchronous context at the invocation boundary. Nested read_file calls must return file content even if a prior nested read happened, because reading inside JavaScript does not establish that the model saw it. Preserve read-before-edit tracking but mark those bytes absent from model history. Nested shell results omit the submitted command, which is already present in the exec source, while retaining output and exit/error information.
Validate plain and nested failures after text, large success and error output, scheduler and aggregate truncation without output artifacts, nested read cache behavior, and shell command omission. Use deterministic local mocked CLI requests; no real model evaluation is required.
@@ -0,0 +1,35 @@
# Collapsible tool call details
## Problem
Code mode `exec` calls can place large results directly in the transcript. The
existing compact renderer only summarizes read, search, and list tools, so code
execution still consumes substantial screen space.
## Design
Add `ui.showToolCallDetails`, a user-facing boolean setting that defaults to
`true` to preserve the current display. When set to `false`, ordinary tool
groups render as one line containing only status, tool name, count, elapsed
time, and an expansion hint. Arguments, results, images, and notices remain in
the history item and are only hidden by the renderer.
In Virtualized History with mouse tracking enabled, clicking the collapsed row
expands that tool group. Scheduler-backed groups keep this state by `batchId`
when their live pending row becomes committed history; adapter-built groups
without a batch id keep it for their mounted lifetime. In append-only terminal
mode, the row points to `Ctrl+O`, which already opens full transcript detail.
Approval prompts, user-initiated shell calls, and focused interactive shells
stay expanded because collapsing them would hide required interaction.
The setting is runtime-only UI state: it does not change tool execution,
recording, model context, or serialized history.
## Test plan
- Verify the setting is schema-registered, visible in `/settings`, and does not
require restart.
- Verify a collapsed tool group omits its description and result.
- Verify a complete click expands one group and a drag does not.
- Verify approval prompts and focused/user-initiated shells remain expanded.
- Verify `Ctrl+O` still forces full detail.
+1
View File
@@ -140,6 +140,7 @@ These settings are read from operator scopes only (User, System, and SystemDefau
| `ui.history.collapseOnResume` | boolean | Whether to collapse history by default when resuming a session. Can be toggled via `/history collapse-on-resume` and `/history expand-on-resume`. | `false` |
| `ui.history.collapsePreviewCount` | number | Number of most recent user turns to keep visible when `ui.history.collapseOnResume` is enabled. `0` collapses all restored history by default; `-1` shows all restored history. | `0` |
| `ui.compactMode` | boolean | **RETIRED everywhere.** The CLI now always shows the compact, type-based tool view in the main transcript; press `Ctrl+O` to toggle expanded detail mode (expand or collapse all thinking blocks and tool outputs inline) instead of toggling a mode, and the web shell now fixes its compact view on as well. The key is kept only so existing settings files do not warn; writes are accepted but nothing reads the value. | `false` |
| `ui.showToolCallDetails` | boolean | Show tool arguments and results inline. Set to `false` to render ordinary tool calls as a one-line summary. Click the row in Virtualized History or press `Ctrl+O` to expand details. Approval prompts, user-initiated shell commands, and focused interactive shells remain expanded. | `true` |
| `ui.showToolCallArgs` | boolean | Render tool calls on their own line with their full raw arguments inline, instead of the type-based compact summary that folds read/search/list batches into `Read 3 files`. Recovers parameters the per-tool description summarizes away (e.g. `Edit` normally shows only the filename). Useful when debugging MCP integrations or tool schemas. The args row is capped at 2 wrapped lines (and at most 1000 characters), so a batch of pending calls cannot outgrow the terminal; press `Ctrl+O` to lift the cap and expand result output too. Two cases keep the compact view: groups of running parallel subagents, which the live agent roster owns — expanding them re-inflates the live frame past the terminal height (#5798) — and daemon-attached sessions, which do not carry arguments across the daemon boundary. Use `Ctrl+O` there. TUI only — the web shell is unaffected. | `false` |
| `ui.shellOutputMaxLines` | number | Max number of shell output lines shown inline. Set to `0` to disable the cap and show full output. Hidden lines are surfaced via the `+N lines` indicator. Errors, `!`-prefix user-initiated commands, confirming tools, and focused embedded shells always show full output. | `5` |
| `ui.enableWelcomeBack` | boolean | Show welcome back dialog when returning to a project with conversation history. When enabled, Qwen Code will automatically detect if you're returning to a project with a previously generated project summary (`.qwen/PROJECT_SUMMARY.md`) and show a dialog allowing you to continue your previous conversation or start fresh. If you choose **Start new chat session**, that choice is remembered for the current project until the project summary changes. This feature integrates with the `/summary` command and quit confirmation dialog. | `true` |
+15 -1
View File
@@ -276,7 +276,21 @@ const workerBuild = esbuild.build({
keepNames: true,
});
Promise.all([mainBuild, workerBuild])
const codeModeHostBuild = esbuild.build({
entryPoints: ['packages/core/src/code-mode/host.ts'],
bundle: true,
outfile: 'dist/codeModeHost.js',
platform: 'node',
format: 'esm',
target: 'node22',
packages: 'bundle',
inject: [path.resolve(__dirname, 'scripts/esbuild-shims.js')],
banner: { js: `"use strict";` },
write: true,
keepNames: true,
});
Promise.all([mainBuild, workerBuild, codeModeHostBuild])
.then(([{ metafile }]) => {
if (process.env.DEV === 'true') {
writeFileSync('./dist/esbuild.json', JSON.stringify(metafile, null, 2));
+27 -1
View File
@@ -28,7 +28,7 @@
],
"dependencies": {
"@testing-library/dom": "^10.4.1",
"ink": "^7.0.3",
"ink": "7.0.3",
"simple-git": "^3.36.0"
},
"bin": {
@@ -3155,6 +3155,21 @@
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/@jitl/quickjs-ffi-types": {
"version": "0.32.0",
"resolved": "https://registry.npmjs.org/@jitl/quickjs-ffi-types/-/quickjs-ffi-types-0.32.0.tgz",
"integrity": "sha512-v9T+GQpmk43VDJ7d72sf0Nexhk+ArvtUihW27dy7lqAl0zBObFKtSBBIm5RBjwIhE8VwsPPm9PNuvPvNqLWUEg==",
"license": "MIT"
},
"node_modules/@jitl/quickjs-singlefile-mjs-release-sync": {
"version": "0.32.0",
"resolved": "https://registry.npmjs.org/@jitl/quickjs-singlefile-mjs-release-sync/-/quickjs-singlefile-mjs-release-sync-0.32.0.tgz",
"integrity": "sha512-h9g2Dri6WxKNjX6a1+sFAafOVxj1n5YIA1GAzMoSdIqK1M8cG07tz3GxKVC3Gbdutz14uQ3oc3Q2lll8otInhA==",
"license": "MIT",
"dependencies": {
"@jitl/quickjs-ffi-types": "0.32.0"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -21728,6 +21743,15 @@
],
"license": "MIT"
},
"node_modules/quickjs-emscripten-core": {
"version": "0.32.0",
"resolved": "https://registry.npmjs.org/quickjs-emscripten-core/-/quickjs-emscripten-core-0.32.0.tgz",
"integrity": "sha512-QFnPfjFey8EqknSrSxe1hZrf1/8z7/6s1QzGOmKo6++02r7QRRX7ZoyNaZh7JuVjWsVW87KnQrbZqnHkOAzUyg==",
"license": "MIT",
"dependencies": {
"@jitl/quickjs-ffi-types": "0.32.0"
}
},
"node_modules/qwen-code-vscode-ide-companion": {
"resolved": "packages/vscode-ide-companion",
"link": true
@@ -27906,6 +27930,7 @@
"@anthropic-ai/sdk": "^0.36.1",
"@google/genai": "2.6.0",
"@iarna/toml": "^2.2.5",
"@jitl/quickjs-singlefile-mjs-release-sync": "0.32.0",
"@modelcontextprotocol/client": "2.0.0",
"@modelcontextprotocol/core": "2.0.0",
"@modelcontextprotocol/sdk": "^1.30.0",
@@ -27945,6 +27970,7 @@
"picomatch": "^4.0.1",
"prompts": "^2.4.2",
"proper-lockfile": "^4.1.2",
"quickjs-emscripten-core": "0.32.0",
"sharp": "^0.35.4",
"shell-quote": "^1.9.0",
"simple-git": "^3.36.0",
+1 -1
View File
@@ -175,7 +175,7 @@
},
"dependencies": {
"@testing-library/dom": "^10.4.1",
"ink": "^7.0.3",
"ink": "7.0.3",
"simple-git": "^3.36.0"
},
"optionalDependencies": {
@@ -34587,6 +34587,139 @@ describe('Session', () => {
}>;
};
it('re-enters the ACP tool chain and preserves native result metadata', async () => {
const onResult = vi.fn();
mockConfig.getDisableAllHooks = vi.fn().mockReturnValue(true);
mockConfig.getApprovalMode = vi.fn().mockReturnValue(ApprovalMode.YOLO);
mockConfig.getToolMode = vi
.fn()
.mockReturnValue(core.ToolMode.CodeModeOnly);
const nestedExecute = vi.fn().mockImplementation(async () => {
expect(core.getToolCallRuntime()).toBeUndefined();
return {
llmContent: [
{ text: 'nested ACP output' },
{
inlineData: {
mimeType: 'image/png',
data: 'QUJD',
},
},
],
returnDisplay: 'nested ACP output',
modelOverride: undefined,
terminateTurn: true,
};
});
const nestedTool = {
name: 'read_file',
kind: core.Kind.Read,
displayName: 'Read file',
description: 'Read file',
build: vi.fn().mockReturnValue({
params: { path: '/tmp/example.txt' },
execute: nestedExecute,
getDefaultPermission: vi.fn().mockResolvedValue('allow'),
getDescription: vi.fn().mockReturnValue('Read file'),
toolLocations: vi.fn().mockReturnValue([]),
}),
canUpdateOutput: false,
isOutputMarkdown: true,
};
const outerTool = {
name: core.ToolNames.EXEC,
kind: core.Kind.Other,
displayName: 'Exec',
description: 'Exec',
build: vi.fn().mockReturnValue({
params: { source: 'probe' },
getDefaultPermission: vi.fn().mockResolvedValue('allow'),
getDescription: vi.fn().mockReturnValue('Exec'),
toolLocations: vi.fn().mockReturnValue([]),
execute: vi.fn().mockImplementation(async (signal: AbortSignal) => {
const runtime = core.getToolCallRuntime();
if (!runtime) throw new Error('missing runtime');
const nested = await runtime.dispatch(
'read_file',
{ path: '/tmp/example.txt' },
signal,
onResult,
);
expect(nested.content).toEqual([
{ type: 'image', mimeType: 'image/png', data: 'QUJD' },
]);
return {
llmContent: nested.output,
returnDisplay: nested.output,
};
}),
}),
canUpdateOutput: false,
isOutputMarkdown: true,
};
mockToolRegistry.getTool.mockImplementation((name: string) =>
name === core.ToolNames.EXEC ? outerTool : nestedTool,
);
const result = await (
session as unknown as ToolCallInternals
).runToolCalls(new AbortController().signal, 'prompt-code-mode-acp', [
{
id: 'exec-acp-parent',
name: core.ToolNames.EXEC,
args: { source: 'probe' },
},
]);
expect(onResult).toHaveBeenCalledWith(
expect.objectContaining({
modelOverride: undefined,
terminateTurn: true,
responseParts: [
expect.objectContaining({
functionResponse: expect.objectContaining({
parts: [
{ inlineData: { mimeType: 'image/png', data: 'QUJD' } },
],
}),
}),
],
}),
);
expect(Object.hasOwn(onResult.mock.calls[0][0], 'modelOverride')).toBe(
true,
);
expect(nestedExecute).toHaveBeenCalledOnce();
expect(mockToolRegistry.getTool).toHaveBeenCalledWith('read_file');
expect(result.parts).toHaveLength(1);
expect(result.parts[0].functionResponse?.name).toBe(core.ToolNames.EXEC);
expect(result.parts[0].functionResponse?.response?.['output']).toBe(
'nested ACP output',
);
expect(mockChatRecordingService.recordToolResult).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
functionResponse: expect.objectContaining({ name: 'read_file' }),
}),
]),
expect.anything(),
);
const direct = await (
session as unknown as ToolCallInternals
).runToolCalls(new AbortController().signal, 'prompt-code-mode-direct', [
{
id: 'read-acp-direct',
name: 'read_file',
args: { path: '/tmp/example.txt' },
},
]);
expect(nestedExecute).toHaveBeenCalledOnce();
expect(direct.parts[0].functionResponse?.response?.['error']).toContain(
'unavailable on this CodeModeOnly call surface',
);
});
function emitNestedAskUserQuestion(
eventEmitter: EventEmitter,
respond: ReturnType<typeof vi.fn>,
@@ -59,6 +59,7 @@ import type {
WorkflowSnapshot,
WorkflowTask,
BranchPoint,
CodeModeToolResult,
AdmissibleNotification,
} from '@qwen-code/qwen-code-core';
import {
@@ -199,6 +200,11 @@ import {
sessionIdContext,
promptIdContext,
todoWorkChainContext,
extractCodeModeImageContent,
runWithoutToolCallRuntime,
runWithToolCallRuntime,
isCodeModeToolCallAllowed,
ToolMode,
dedupeToolCallsById,
getFunctionCallFingerprint,
getProviderToolCallId,
@@ -574,6 +580,7 @@ function isUnattendedRestorePermissionCancel(reason: unknown): boolean {
}
type RunToolResult = {
modelOverride?: string;
parts: Part[];
stopAfterPermissionCancel: boolean;
loopDetected?: boolean;
@@ -2027,6 +2034,7 @@ export class Session implements SessionContext {
*/
private followupAbort: AbortController | null = null;
private turn: number = 0;
private codeModeNestedSequence = 0;
private refreshContextFilesOnWrite = false;
private activeTodoWorkChainPromptId: string | undefined;
private readonly createdAt: number = Date.now();
@@ -11336,11 +11344,17 @@ export class Session implements SessionContext {
]),
);
const pendingToolResultRecords: PendingToolResultRecord[] = [];
const pendingNestedToolResultRecords: PendingToolResultRecord[] = [];
let toolResultRecordSequence = 0;
const queueToolResultRecord: QueueToolResultRecord = (fc, record) => {
pendingToolResultRecords.push({
const ordinal = dedupedFunctionCalls.indexOf(fc);
const target =
ordinal === -1
? pendingNestedToolResultRecords
: pendingToolResultRecords;
target.push({
...record,
ordinal: dedupedFunctionCalls.indexOf(fc),
ordinal: Math.max(0, ordinal),
sequence: toolResultRecordSequence++,
});
};
@@ -11349,47 +11363,20 @@ export class Session implements SessionContext {
// end the turn asked no matter which of the exits below the batch takes,
// and the exits that run before any tool does read it as false anyway.
let batchTerminatesTurn = false;
const finalizeRunToolResult = async (
result: RunToolResult,
): Promise<RunToolResult> => {
const orderedRecords = [...pendingToolResultRecords].sort(
(left, right) =>
left.ordinal - right.ordinal || left.sequence - right.sequence,
);
const repeatedToolFailureBatch: RepeatedToolFailureBatch = {
complete:
orderedRecords.length === dedupedFunctionCalls.length &&
new Set(orderedRecords.map((record) => record.ordinal)).size ===
dedupedFunctionCalls.length,
observations: orderedRecords.map((record) => ({
callId: record.callId,
policyToolName: record.policyToolName,
toolType: record.toolType,
terminalStatus: record.metadata.status,
executionStatus: record.metadata.executionStatus,
executionErrorType: record.executionErrorType,
providerDuplicate: record.providerDuplicate,
})),
};
if (orderedRecords.length === 0) {
return {
...result,
repeatedToolFailureBatch,
...(batchTerminatesTurn ? { terminateTurn: true } : {}),
};
}
const finalizeAndRecord = async (records: PendingToolResultRecord[]) => {
if (records.length === 0) return [];
const finalized = await finalizeToolResponses(
this.config,
orderedRecords.map((record) => ({
records.map((record) => ({
callId: record.callId,
toolName: record.toolName,
responseParts: record.responseParts,
persistedOutputFiles: record.persistedOutputFiles,
artifacts: record.metadata.artifacts,
})),
new Map(orderedRecords.map((record) => [record.callId, promptId])),
new Map(records.map((record) => [record.callId, promptId])),
);
orderedRecords.forEach((record, index) => {
records.forEach((record, index) => {
// A restored ask_user_question whose permission wait timed out stays
// dangling on disk so a later load can re-hang it; only the
// in-memory result is produced. The flag check is retroactive on
@@ -11416,6 +11403,51 @@ export class Session implements SessionContext {
...(goalProvenance ? ([goalProvenance] as const) : ([] as const)),
);
});
return finalized;
};
const finalizeNestedToolResult = async (
result: RunToolResult,
): Promise<Part[]> => {
const records = pendingNestedToolResultRecords.splice(0);
if (records.length === 0) return result.parts;
const finalized = await finalizeAndRecord(records);
return finalized.flatMap((entry) => entry.responseParts);
};
const finalizeRunToolResult = async (
result: RunToolResult,
): Promise<RunToolResult> => {
await finalizeAndRecord(
[...pendingNestedToolResultRecords].sort(
(left, right) => left.sequence - right.sequence,
),
);
const orderedRecords = [...pendingToolResultRecords].sort(
(left, right) =>
left.ordinal - right.ordinal || left.sequence - right.sequence,
);
const repeatedToolFailureBatch: RepeatedToolFailureBatch = {
complete:
orderedRecords.length === dedupedFunctionCalls.length &&
new Set(orderedRecords.map((record) => record.ordinal)).size ===
dedupedFunctionCalls.length,
observations: orderedRecords.map((record) => ({
callId: record.callId,
policyToolName: record.policyToolName,
toolType: record.toolType,
terminalStatus: record.metadata.status,
executionStatus: record.metadata.executionStatus,
executionErrorType: record.executionErrorType,
providerDuplicate: record.providerDuplicate,
})),
};
if (orderedRecords.length === 0) {
return {
...result,
repeatedToolFailureBatch,
...(batchTerminatesTurn ? { terminateTurn: true } : {}),
};
}
const finalized = await finalizeAndRecord(orderedRecords);
return {
...result,
parts: finalized.flatMap((entry) => entry.responseParts),
@@ -11806,6 +11838,8 @@ export class Session implements SessionContext {
queueToolResultRecord,
executionCallIds.get(calls[idx]),
onFullTurnModel,
undefined,
finalizeNestedToolResult,
)
.then((r) => {
results[idx] = r;
@@ -11949,6 +11983,8 @@ export class Session implements SessionContext {
queueToolResultRecord,
executionCallIds.get(fc),
onFullTurnModel,
undefined,
finalizeNestedToolResult,
);
parts.push(...r.parts);
collectMemoryWriteCandidates(r);
@@ -12050,6 +12086,11 @@ export class Session implements SessionContext {
queueToolResultRecord?: QueueToolResultRecord,
generatedCallId?: string,
onFullTurnModel?: (model: string) => boolean,
codeModeContext?: {
parentCallId: string;
source: 'code_mode';
},
finalizeCodeModeToolResult?: (result: RunToolResult) => Promise<Part[]>,
): Promise<RunToolResult> {
const callId = fc.id ?? generatedCallId ?? `${fc.name}-${Date.now()}`;
let args = (fc.args ?? {}) as Record<string, unknown>;
@@ -12116,6 +12157,12 @@ export class Session implements SessionContext {
'event.name': 'tool_call',
'event.timestamp': new Date().toISOString(),
call_id: callId,
...(codeModeContext
? {
parent_call_id: codeModeContext.parentCallId,
source: codeModeContext.source,
}
: {}),
prompt_id: promptId,
function_name: toolName,
function_args: args,
@@ -12298,6 +12345,22 @@ export class Session implements SessionContext {
}
const toolName = fc.name;
if (
this.config.getToolMode?.() === ToolMode.CodeModeOnly &&
!isCodeModeToolCallAllowed(toolName, codeModeContext?.source ?? 'model')
) {
return earlyErrorResponse(
new Error(
`Tool "${toolName}" is unavailable on this CodeModeOnly call surface.`,
),
toolName,
{
status: 'error',
errorType: ToolErrorType.EXECUTION_DENIED,
executionStatus: 'not_started',
},
);
}
const toolRegistry = this.config.getToolRegistry();
const tool = toolRegistry.getTool(toolName);
@@ -12340,6 +12403,12 @@ export class Session implements SessionContext {
// matching daemon/ACP tool spans during the migration window.
call_id: callId,
tool_name: policyToolName,
...(codeModeContext
? {
'tool.parent_call_id': codeModeContext.parentCallId,
'tool.source': codeModeContext.source,
}
: {}),
},
tool.description,
promptId,
@@ -13718,11 +13787,106 @@ export class Session implements SessionContext {
executionStatus = 'error';
executeAttempted = true;
try {
toolResult = await invocation.execute(
activeToolAbortSignal,
onToolProgress,
this.config.getShellExecutionConfig(),
);
const execute = () =>
invocation.execute(
activeToolAbortSignal,
onToolProgress,
this.config.getShellExecutionConfig(),
);
if (toolName !== ToolNames.EXEC) {
toolResult = await execute();
} else {
let dispatchTail = Promise.resolve();
const dispatch = (
nestedName: string,
nestedArgs: Record<string, unknown>,
nestedSignal: AbortSignal,
onResult?: (response: ToolCallResponseInfo) => void,
): Promise<CodeModeToolResult> => {
const next = dispatchTail.then(async () => {
if (!isCodeModeToolCallAllowed(nestedName, 'code_mode')) {
throw new Error(
`Tool "${nestedName}" is not callable from exec.`,
);
}
const nestedCallId = `${callId}:code:${++this.codeModeNestedSequence}`;
const nested = await runWithoutToolCallRuntime(() =>
this.runTool(
nestedSignal,
promptId,
{
id: nestedCallId,
name: nestedName,
args: nestedArgs,
},
onStopAfterPermissionCancel,
toolLoopState,
recordSkippedToolCall,
queueToolResultRecord,
nestedCallId,
onFullTurnModel,
{ parentCallId: callId, source: 'code_mode' },
),
);
const nestedParts = finalizeCodeModeToolResult
? await finalizeCodeModeToolResult(nested)
: nested.parts;
const functionResponse = nestedParts
.map((part) => part.functionResponse)
.find((part) => part?.id === nestedCallId);
const response = functionResponse?.response as
| Record<string, unknown>
| undefined;
const nestedError = response?.['error'];
onResult?.({
callId: nestedCallId,
responseParts: nestedParts,
resultDisplay: undefined,
error:
nestedError === undefined
? undefined
: new Error(String(nestedError)),
errorType: undefined,
...('modelOverride' in nested
? { modelOverride: nested.modelOverride }
: {}),
...(nested.terminateTurn ? { terminateTurn: true } : {}),
});
if (nestedError !== undefined) {
throw new Error(
typeof nestedError === 'string'
? nestedError
: JSON.stringify(nestedError),
);
}
const nestedOutput =
response?.['output'] ?? response?.['content'] ?? '';
const content = extractCodeModeImageContent(nestedParts);
return {
callId: nestedCallId,
name: nestedName,
status: 'success' as const,
output:
typeof nestedOutput === 'string'
? nestedOutput
: JSON.stringify(nestedOutput),
...(content ? { content } : {}),
};
});
dispatchTail = next.then(
() => undefined,
() => undefined,
);
return next;
};
toolResult = await runWithToolCallRuntime(
{
parentCallId: callId,
dispatch,
},
execute,
);
}
executeReturned = true;
try {
settledArtifacts = toolResult.artifacts;
@@ -14100,6 +14264,12 @@ export class Session implements SessionContext {
'event.name': 'tool_call',
'event.timestamp': new Date().toISOString(),
call_id: callId,
...(codeModeContext
? {
parent_call_id: codeModeContext.parentCallId,
source: codeModeContext.source,
}
: {}),
function_name: toolName,
function_args: args,
duration_ms: durationMs,
@@ -14168,8 +14338,13 @@ export class Session implements SessionContext {
}
return {
parts: responseParts,
...('modelOverride' in toolResult && succeeded
? { modelOverride: toolResult.modelOverride }
: {}),
stopAfterPermissionCancel: nestedPermissionCancelled,
...(toolResult.terminateTurn ? { terminateTurn: true } : {}),
...(toolResult.terminateTurn && succeeded
? { terminateTurn: true }
: {}),
memoryWriteCandidates:
status === 'success'
? [
+34
View File
@@ -3505,6 +3505,40 @@ describe('mergeExcludeTools', () => {
expect(config.getToolSearchThreshold()).toBe(10);
});
it('should enable CodeModeOnly only when explicitly configured', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
const direct = await loadCliConfig({}, argv, undefined, []);
const codeMode = await loadCliConfig(
{ tools: { codeModeOnly: true } },
argv,
undefined,
[],
);
expect(direct.getCodeModeOnly()).toBe(false);
expect(codeMode.getCodeModeOnly()).toBe(true);
expect(direct.getToolMode()).toBe('direct');
expect(codeMode.getToolMode()).toBe('code_mode_only');
});
it.each(['--safe-mode', '--bare'])(
'should disable CodeModeOnly in %s mode',
async (flag) => {
process.argv = ['node', 'script.js', flag];
const argv = await parseArguments();
const config = await loadCliConfig(
{ tools: { codeModeOnly: true } },
argv,
undefined,
[],
);
expect(config.getCodeModeOnly()).toBe(false);
},
);
it('should default tools.listDirectory.enabled to false', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
+2
View File
@@ -2222,6 +2222,8 @@ export async function loadCliConfig(
disabledTools: disabledTools.length > 0 ? disabledTools : undefined,
visibleTools: visibleTools.length > 0 ? visibleTools : undefined,
eagerTools,
codeModeOnly:
!bareMode && !safeMode && settings.tools?.codeModeOnly === true,
toolSearchThreshold:
bareMode || safeMode ? 0 : settings.tools?.toolSearch?.threshold,
// New unified permissions (PermissionManager source of truth).
@@ -695,6 +695,16 @@ describe('SettingsSchema', () => {
expect(mouseTracking.requiresRestart).toBe(true);
});
it('should expose tool call details as a live UI setting', () => {
const showToolCallDetails =
getSettingsSchema().ui.properties.showToolCallDetails;
expect(showToolCallDetails).toBeDefined();
expect(showToolCallDetails.type).toBe('boolean');
expect(showToolCallDetails.default).toBe(true);
expect(showToolCallDetails.showInDialog).toBe(true);
expect(showToolCallDetails.requiresRestart).toBe(false);
});
it('should have showToolCallArgs in ui settings', () => {
const showToolCallArgs =
getSettingsSchema().ui.properties.showToolCallArgs;
+20
View File
@@ -1257,6 +1257,16 @@ const SETTINGS_SCHEMA = {
'Enable in-app SGR mouse tracking. While enabled, Qwen Code captures mouse events for text selection, click-to-position in text inputs, row hover, history-item toggling, and viewport scrolling. Because the terminal forwards all mouse events to the app, Qwen Code supplies its own equivalents for what the terminal can no longer do natively: a single click opens an http(s) hyperlink under the pointer (other link schemes are copied to the clipboard), and right-click over a link or a text selection opens an in-app context menu with Open Link / Copy Link Address / Copy Selection. Disable to hand the mouse fully back to the terminal (native right-click menu and link clicks); this turns off all in-app mouse interaction, and in Virtualized History the wheel no longer scrolls the transcript — use Shift+↑/↓, PgUp/PgDn, or Ctrl+Home/End instead (pair with ui.useTerminalBuffer: false to restore native terminal scrollback).',
showInDialog: true,
},
showToolCallDetails: {
type: 'boolean',
label: 'Show Tool Call Details',
category: 'UI',
requiresRestart: false,
default: true,
description:
'Show tool arguments and results inline. Disable to render ordinary tool calls as a one-line summary; click a summary in Virtualized History or press Ctrl+O to expand its details. Approval prompts, user-initiated shell commands, and focused interactive shells remain expanded.',
showInDialog: true,
},
showToolCallArgs: {
type: 'boolean',
label: 'Show Tool Call Arguments',
@@ -2683,6 +2693,16 @@ const SETTINGS_SCHEMA = {
description: 'Settings for built-in and custom tools.',
showInDialog: false,
properties: {
codeModeOnly: {
type: 'boolean',
label: 'Code Mode Only (Experimental)',
category: 'Tools',
requiresRestart: true,
default: false,
description:
'Expose ordinary tools to the model only through the isolated exec JavaScript tool. Direct control tools remain available. Ignored in safe and bare modes.',
showInDialog: true,
},
sandbox: {
type: 'object',
label: 'Sandbox',
+2
View File
@@ -164,6 +164,7 @@ export function getAllSettingKeys(): string[] {
const SETTINGS_DIALOG_ORDER: readonly string[] = [
// Workflow Control - most impactful setting
'tools.approvalMode',
'tools.codeModeOnly',
// Localization - users often set this first
'general.language',
@@ -181,6 +182,7 @@ const SETTINGS_DIALOG_ORDER: readonly string[] = [
'ide.enabled',
'ui.showLineNumbers',
'ui.hideTips',
'ui.showToolCallDetails',
'general.terminalBell',
'ui.enableWelcomeBack',
+2
View File
@@ -177,6 +177,7 @@ export default {
// self-mapping and returns the bare display name. Localized values live in
// zh.js / zh-TW.js; other locales fall back to the English display name.
// ============================================================================
'toolDisplayName.Exec': 'toolDisplayName.Exec',
'toolDisplayName.Edit': 'toolDisplayName.Edit',
'toolDisplayName.WriteFile': 'toolDisplayName.WriteFile',
'toolDisplayName.ReadFile': 'toolDisplayName.ReadFile',
@@ -721,6 +722,7 @@ export default {
// ============================================================================
// Settings Labels
// ============================================================================
'Code Mode Only (Experimental)': 'Code Mode Only (Experimental)',
'Vim Mode': 'Vim Mode',
'Attribution: commit': 'Attribution: commit',
'Terminal Bell Notification': 'Terminal Bell Notification',
+2
View File
@@ -168,6 +168,7 @@ export default {
// via `localizeToolDisplayName`. A product name (e.g. `Notebook`) is kept
// verbatim inside an otherwise-translated label.
// ============================================================================
'toolDisplayName.Exec': '執行程式碼',
'toolDisplayName.Edit': '編輯',
'toolDisplayName.WriteFile': '寫入檔案',
'toolDisplayName.ReadFile': '讀取檔案',
@@ -682,6 +683,7 @@ export default {
Settings: '設置',
'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.':
'要查看更改,必須重啟 Qwen Code。按 r 退出並立即應用更改。',
'Code Mode Only (Experimental)': '僅程式碼模式(實驗性)',
'Vim Mode': 'Vim 模式',
'Attribution: commit': '署名:提交',
'Terminal Bell Notification': '終端響鈴通知',
+2
View File
@@ -169,6 +169,7 @@ export default {
// acronyms are kept in English (Agent, Grep, Glob, LSP), as is a product name
// inside an otherwise-translated label (e.g. `Notebook`).
// ============================================================================
'toolDisplayName.Exec': '执行代码',
'toolDisplayName.Edit': '编辑',
'toolDisplayName.WriteFile': '写入文件',
'toolDisplayName.ReadFile': '读取文件',
@@ -724,6 +725,7 @@ export default {
// ============================================================================
// Settings Labels
// ============================================================================
'Code Mode Only (Experimental)': '仅代码模式(实验性)',
'Vim Mode': 'Vim 模式',
'Attribution: commit': '署名:提交',
'Terminal Bell Notification': '终端响铃通知',
@@ -5758,6 +5758,47 @@ describe('runNonInteractive', () => {
expect(errorOutput).toContain('Incorrect API key provided');
});
it('fails stream-json runs when the model stream emits an API error', async () => {
(mockConfig.getOutputFormat as Mock).mockReturnValue(
OutputFormat.STREAM_JSON,
);
setupMetricsMock();
const apiErrorEvent: ServerLlmStreamEvent = {
type: LlmEventType.Error,
value: {
error: {
message: '429 Too Many Requests',
status: 429,
},
},
};
mockLlmClient.sendMessageStream.mockReturnValue(
createStreamFromEvents([apiErrorEvent]),
);
await expect(
runNonInteractive(
mockConfig,
mockSettings,
'Test input',
'prompt-id-stream-api-error',
),
).rejects.toBeInstanceOf(AlreadyReportedError);
const messages = processStdoutSpy.mock.calls
.map((call) => String(call[0]).trim())
.filter(Boolean)
.map((line) => JSON.parse(line) as { [key: string]: unknown });
const results = messages.filter((message) => message['type'] === 'result');
expect(results).toHaveLength(1);
expect(results[0]).toMatchObject({
subtype: 'error_during_execution',
is_error: true,
error: { message: expect.stringContaining('429 Too Many Requests') },
});
});
it('does not double-wrap or double-format an API error in non-interactive mode', async () => {
// Regression test for the bug where a 4xx error event flowed through
// both the stream handler and handleError, each calling
+12 -17
View File
@@ -2552,19 +2552,17 @@ export async function runNonInteractive(
}
loopDetected = true;
}
if (
outputFormat === OutputFormat.TEXT &&
event.type === LlmEventType.Error
) {
if (event.type === LlmEventType.Error) {
const errorText = parseAndFormatApiError(
event.value.error,
config.getContentGeneratorConfig()?.authType,
);
process.stderr.write(`${errorText}\n`);
// We have already formatted and written the message; mark the
// throw so the top-level handleError doesn't reformat (which
// would yield "[API Error: [API Error: ...]]") or print it a
// second time. Exit code stays 1 — same as before.
if (outputFormat === OutputFormat.TEXT) {
process.stderr.write(`${errorText}\n`);
}
// The adapter has already captured the formatted error in JSON
// modes, while text mode wrote it above. Mark the throw so the
// terminal error result is emitted without formatting it again.
throw new AlreadyReportedError(errorText);
}
}
@@ -2869,18 +2867,15 @@ export async function runNonInteractive(
}
loopDetected = true;
}
if (
outputFormat === OutputFormat.TEXT &&
event.type === LlmEventType.Error
) {
if (event.type === LlmEventType.Error) {
const errorText = parseAndFormatApiError(
event.value.error,
config.getContentGeneratorConfig()?.authType,
);
process.stderr.write(`${errorText}\n`);
// See the matching note in the first stream loop above —
// we mark the throw so handleError doesn't reformat or
// reprint downstream.
if (outputFormat === OutputFormat.TEXT) {
process.stderr.write(`${errorText}\n`);
}
// See the matching main-stream branch above.
throw new AlreadyReportedError(errorText);
}
}
+34 -11
View File
@@ -163,6 +163,7 @@ import {
useVimModeActions,
} from './contexts/VimModeContext.js';
import { ThoughtExpandedProvider } from './contexts/ThoughtExpandedContext.js';
import { ToolDetailsExpandedProvider } from './contexts/ToolDetailsExpandedContext.js';
import { useTerminalSize } from './hooks/useTerminalSize.js';
import { calculatePromptWidths } from './components/InputPrompt.js';
import { useStdin, useStdout } from 'ink';
@@ -968,6 +969,18 @@ export const AppContainer = (props: AppContainerProps) => {
});
}, []);
const [expandedToolBatchIds, setExpandedToolBatchIds] = useState<
ReadonlySet<string>
>(() => new Set<string>());
const expandToolBatch = useCallback((batchId: string) => {
setExpandedToolBatchIds((prev) => {
if (prev.has(batchId)) return prev;
const next = new Set(prev);
next.add(batchId);
return next;
});
}, []);
// Terminal and layout hooks
const { columns: terminalWidth, rows: terminalHeight } = useTerminalSize();
const { stdin, setRawMode } = useStdin();
@@ -5468,6 +5481,14 @@ export const AppContainer = (props: AppContainerProps) => {
[thoughtExpanded, expandedThoughtHeadIds, toggleThoughtExpanded],
);
const toolDetailsExpandedValue = useMemo(
() => ({
expandedBatchIds: expandedToolBatchIds,
expandBatch: expandToolBatch,
}),
[expandedToolBatchIds, expandToolBatch],
);
return (
<VirtualViewportContext.Provider value={useTerminalBuffer}>
<UIStateContext.Provider value={uiState}>
@@ -5480,17 +5501,19 @@ export const AppContainer = (props: AppContainerProps) => {
}}
>
<ThoughtExpandedProvider value={thoughtExpandedValue}>
<RenderModeProvider value={renderModeValue}>
<TerminalOutputProvider value={writeRaw}>
<ShellFocusContext.Provider value={isFocused}>
<ContextMenuProvider
onMenuChange={handleContextMenuChange}
>
<App />
</ContextMenuProvider>
</ShellFocusContext.Provider>
</TerminalOutputProvider>
</RenderModeProvider>
<ToolDetailsExpandedProvider value={toolDetailsExpandedValue}>
<RenderModeProvider value={renderModeValue}>
<TerminalOutputProvider value={writeRaw}>
<ShellFocusContext.Provider value={isFocused}>
<ContextMenuProvider
onMenuChange={handleContextMenuChange}
>
<App />
</ContextMenuProvider>
</ShellFocusContext.Provider>
</TerminalOutputProvider>
</RenderModeProvider>
</ToolDetailsExpandedProvider>
</ThoughtExpandedProvider>
</AppContext.Provider>
</ConfigContext.Provider>
@@ -57,6 +57,7 @@ function makeMockConfig(contextWindowSize = 32_000): Config {
getUserMemory: vi.fn().mockReturnValue(''),
getSystemPrompt: vi.fn().mockReturnValue(undefined),
getOutputStyle: vi.fn().mockReturnValue(undefined),
getCodeModeOnly: vi.fn().mockReturnValue(false),
isTodoWriteEnabled: vi.fn().mockReturnValue(false),
getAutoMemoryPrompt: vi.fn().mockReturnValue(''),
getSkillManager: vi.fn().mockReturnValue({
@@ -96,6 +97,7 @@ describe('collectContextData (contextCommand)', () => {
getUserMemory: vi.fn().mockReturnValue(''),
getSystemPrompt: vi.fn().mockReturnValue(undefined),
getOutputStyle: vi.fn().mockReturnValue(undefined),
getCodeModeOnly: vi.fn().mockReturnValue(false),
isTodoWriteEnabled: vi.fn().mockReturnValue(false),
getAutoMemoryPrompt: vi.fn().mockReturnValue(''),
getSkillManager: vi.fn().mockReturnValue({
@@ -226,6 +228,7 @@ describe('collectContextData (contextCommand)', () => {
getUserMemory: vi.fn().mockReturnValue(''),
getSystemPrompt: vi.fn().mockReturnValue(undefined),
getOutputStyle: vi.fn().mockReturnValue(undefined),
getCodeModeOnly: vi.fn().mockReturnValue(false),
isTodoWriteEnabled: vi.fn().mockReturnValue(false),
getAutoMemoryPrompt: vi.fn().mockReturnValue(''),
getSkillManager: vi.fn().mockReturnValue({
@@ -276,6 +279,7 @@ describe('collectContextData (contextCommand)', () => {
getUserMemory: vi.fn().mockReturnValue(''),
getSystemPrompt: vi.fn().mockReturnValue(undefined),
getOutputStyle: vi.fn().mockReturnValue(undefined),
getCodeModeOnly: vi.fn().mockReturnValue(false),
isTodoWriteEnabled: vi.fn().mockReturnValue(false),
getAutoMemoryPrompt: vi.fn().mockReturnValue(''),
getSkillManager: vi.fn().mockReturnValue({
@@ -5,13 +5,13 @@
*/
import type React from 'react';
import { memo, useMemo, useRef, useCallback } from 'react';
import { memo, useMemo, useRef, useCallback, useState } from 'react';
import type { DOMElement } from 'ink';
import {
escapeAnsiCtrlCodes,
sanitizeSensitiveText,
} from '../utils/textUtils.js';
import type { HistoryItem } from '../types.js';
import { ToolCallStatus, type HistoryItem } from '../types.js';
import {
UserMessage,
UserShellMessage,
@@ -63,6 +63,7 @@ import { GoalStatusMessage } from './messages/GoalStatusMessage.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { useVirtualViewport } from '../contexts/VirtualViewportContext.js';
import { useThoughtExpanded } from '../contexts/ThoughtExpandedContext.js';
import { useToolDetailsExpanded } from '../contexts/ToolDetailsExpandedContext.js';
import { useMouseEvents } from '../hooks/useMouseEvents.js';
import { useMouseTrackingEnabled } from '../hooks/use-mouse-tracking-enabled.js';
import { useContextMenu } from '../context-menu/ContextMenuContext.js';
@@ -209,6 +210,120 @@ const ClickableThinkMessage: React.FC<{
);
};
function hasRequiredToolInteraction(
props: React.ComponentProps<typeof ToolGroupMessage>,
): boolean {
if (props.isUserInitiated) return true;
if (
props.embeddedShellFocused &&
props.toolCalls.some(
(tool) =>
tool.ptyId === props.activeShellPtyId &&
tool.status === ToolCallStatus.Executing,
)
) {
return true;
}
return props.toolCalls.some((tool) => {
if (tool.status === ToolCallStatus.Confirming) return true;
const display = tool.resultDisplay;
return (
typeof display === 'object' &&
display !== null &&
'type' in display &&
display.type === 'task_execution' &&
'pendingConfirmation' in display &&
display.pendingConfirmation !== undefined
);
});
}
type CollapsibleToolGroupMessageProps = React.ComponentProps<
typeof ToolGroupMessage
> & {
expansionKey?: string;
};
export const CollapsibleToolGroupMessage: React.FC<
CollapsibleToolGroupMessageProps
> = ({ expansionKey, ...props }) => {
const settings = useSettings();
const [locallyExpanded, setLocallyExpanded] = useState(false);
const { expandedBatchIds, expandBatch } = useToolDetailsExpanded();
const ref = useRef<DOMElement>(null);
const pressRef = useRef<{ col: number; row: number } | null>(null);
const { rows: terminalHeight } = useTerminalSize();
const mouseTrackingEnabled = useMouseTrackingEnabled();
const clickable =
useVirtualViewport(settings.merged.ui?.useTerminalBuffer) &&
mouseTrackingEnabled;
const collapsed =
settings.merged.ui?.showToolCallDetails === false &&
!locallyExpanded &&
!(expansionKey && expandedBatchIds.has(expansionKey)) &&
!props.fullDetail &&
!hasRequiredToolInteraction(props);
useMouseEvents(
useCallback(
(event: MouseEvent) => {
if (!collapsed || !ref.current) return;
if (event.name === 'move') {
if (
pressRef.current &&
(event.col !== pressRef.current.col ||
event.row !== pressRef.current.row)
) {
pressRef.current = null;
}
return;
}
if (event.name !== 'left-press' && event.name !== 'left-release') {
pressRef.current = null;
return;
}
const metrics = measureElementPosition(ref.current);
const col = event.col - 1;
const row = layoutRowForEvent(ref.current, event.row, terminalHeight);
const isInside =
col >= metrics.x &&
col < metrics.x + metrics.width &&
row >= metrics.y &&
row < metrics.y + metrics.height;
if (event.name === 'left-press') {
pressRef.current = isInside
? { col: event.col, row: event.row }
: null;
return;
}
const press = pressRef.current;
pressRef.current = null;
if (isInside && press?.col === event.col && press.row === event.row) {
if (expansionKey) {
expandBatch(expansionKey);
} else {
setLocallyExpanded(true);
}
}
},
[collapsed, terminalHeight, expansionKey, expandBatch],
),
{ isActive: collapsed && clickable },
);
if (!collapsed) return <ToolGroupMessage {...props} />;
return (
<ToolGroupMessage
{...props}
hideDetails
expandHint={clickable ? 'click to expand' : 'ctrl+o to expand'}
summaryRef={ref}
/>
);
};
function getHistoryItemMarginTop(item: HistoryItem): number {
switch (item.type) {
case 'gemini':
@@ -417,7 +532,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
/>
)}
{itemForDisplay.type === 'tool_group' && (
<ToolGroupMessage
<CollapsibleToolGroupMessage
toolCalls={itemForDisplay.tools}
groupId={itemForDisplay.id}
availableTerminalHeight={availableTerminalHeight}
@@ -430,6 +545,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
memoryReadCount={itemForDisplay.memoryReadCount}
isUserInitiated={itemForDisplay.isUserInitiated}
fullDetail={fullDetail}
expansionKey={itemForDisplay.batchId}
/>
)}
{itemForDisplay.type === 'tool_use_summary' && (
@@ -1417,7 +1417,7 @@ describe('SettingsDialog', () => {
describe('String Settings Editing', () => {
it('should allow editing and committing a string setting', async () => {
let settings = createMockSettings({ 'a.string.setting': 'initial' });
let settings = createMockSettings({ advisorModel: 'initial' });
const onSelect = vi.fn();
const { stdin, unmount, rerender } = render(
@@ -1429,8 +1429,9 @@ describe('SettingsDialog', () => {
// Wait for the dialog to render
await wait();
// Navigate to the last setting
for (let i = 0; i < 20; i++) {
const stringSettingIndex = getDialogSettingKeys().indexOf('advisorModel');
expect(stringSettingIndex).toBeGreaterThanOrEqual(0);
for (let i = 0; i < stringSettingIndex; i++) {
stdin.write('j'); // Down
await wait(10);
}
@@ -1447,11 +1448,7 @@ describe('SettingsDialog', () => {
stdin.write('\r');
await wait();
settings = createMockSettings(
{ 'a.string.setting': 'new value' },
{},
{},
);
settings = createMockSettings({ advisorModel: 'new value' }, {}, {});
rerender(
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
@@ -10,13 +10,13 @@ exports[`SettingsDialog > Snapshot Tests > should render default state correctly
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
│ │
│ ●︎ Tool Approval Mode Auto │
│ Code Mode Only (Experimental) false │
│ Language: UI Auto (detect from system) │
│ Language: Model Auto (follow user input) │
│ Theme Qwen Dark ▸ │
│ Vim Mode false │
│ Interactive Shell (PTY) true │
│ Preferred Editor ▸ │
│ Auto-connect to IDE false │
│ ▼ │
│ │
│ Approval mode for tool usage. Controls how tools are approved before execution. │
@@ -35,13 +35,13 @@ exports[`SettingsDialog > Snapshot Tests > should render focused on scope select
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
│ │
│ ●︎ Tool Approval Mode Auto │
│ Code Mode Only (Experimental) false │
│ Language: UI Auto (detect from system) │
│ Language: Model Auto (follow user input) │
│ Theme Qwen Dark ▸ │
│ Vim Mode false │
│ Interactive Shell (PTY) true │
│ Preferred Editor ▸ │
│ Auto-connect to IDE false │
│ ▼ │
│ │
│ Approval mode for tool usage. Controls how tools are approved before execution. │
@@ -60,13 +60,13 @@ exports[`SettingsDialog > Snapshot Tests > should render with accessibility sett
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
│ │
│ ●︎ Tool Approval Mode Auto │
│ Code Mode Only (Experimental) false │
│ Language: UI Auto (detect from system) │
│ Language: Model Auto (follow user input) │
│ Theme Qwen Dark ▸ │
│ Vim Mode true* │
│ Interactive Shell (PTY) true │
│ Preferred Editor ▸ │
│ Auto-connect to IDE false │
│ ▼ │
│ │
│ Approval mode for tool usage. Controls how tools are approved before execution. │
@@ -85,13 +85,13 @@ exports[`SettingsDialog > Snapshot Tests > should render with all boolean settin
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
│ │
│ ●︎ Tool Approval Mode Auto │
│ Code Mode Only (Experimental) false │
│ Language: UI Auto (detect from system) │
│ Language: Model Auto (follow user input) │
│ Theme Qwen Dark ▸ │
│ Vim Mode false* │
│ Interactive Shell (PTY) true │
│ Preferred Editor ▸ │
│ Auto-connect to IDE false* │
│ ▼ │
│ │
│ Approval mode for tool usage. Controls how tools are approved before execution. │
@@ -110,13 +110,13 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
│ │
│ ●︎ Tool Approval Mode Auto │
│ Code Mode Only (Experimental) false │
│ Language: UI Auto (detect from system) │
│ Language: Model Auto (follow user input) │
│ Theme Qwen Dark ▸ │
│ Vim Mode (Modified in System) false │
│ Interactive Shell (PTY) true │
│ Preferred Editor ▸ │
│ Auto-connect to IDE false │
│ ▼ │
│ │
│ Approval mode for tool usage. Controls how tools are approved before execution. │
@@ -135,13 +135,13 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
│ │
│ ●︎ Tool Approval Mode Auto │
│ Code Mode Only (Experimental) false │
│ Language: UI Auto (detect from system) │
│ Language: Model Auto (follow user input) │
│ Theme Qwen Dark ▸ │
│ Vim Mode (Modified in Workspace) false │
│ Interactive Shell (PTY) true │
│ Preferred Editor ▸ │
│ Auto-connect to IDE false │
│ ▼ │
│ │
│ Approval mode for tool usage. Controls how tools are approved before execution. │
@@ -160,13 +160,13 @@ exports[`SettingsDialog > Snapshot Tests > should render with file filtering set
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
│ │
│ ●︎ Tool Approval Mode Auto │
│ Code Mode Only (Experimental) false │
│ Language: UI Auto (detect from system) │
│ Language: Model Auto (follow user input) │
│ Theme Qwen Dark ▸ │
│ Vim Mode false │
│ Interactive Shell (PTY) true │
│ Preferred Editor ▸ │
│ Auto-connect to IDE false │
│ ▼ │
│ │
│ Approval mode for tool usage. Controls how tools are approved before execution. │
@@ -185,13 +185,13 @@ exports[`SettingsDialog > Snapshot Tests > should render with mixed boolean and
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
│ │
│ ●︎ Tool Approval Mode Auto │
│ Code Mode Only (Experimental) false │
│ Language: UI Auto (detect from system) │
│ Language: Model Auto (follow user input) │
│ Theme Qwen Dark ▸ │
│ Vim Mode false* │
│ Interactive Shell (PTY) true │
│ Preferred Editor ▸ │
│ Auto-connect to IDE false │
│ ▼ │
│ │
│ Approval mode for tool usage. Controls how tools are approved before execution. │
@@ -210,13 +210,13 @@ exports[`SettingsDialog > Snapshot Tests > should render with tools and security
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
│ │
│ ●︎ Tool Approval Mode Auto │
│ Code Mode Only (Experimental) false │
│ Language: UI Auto (detect from system) │
│ Language: Model Auto (follow user input) │
│ Theme Qwen Dark ▸ │
│ Vim Mode false │
│ Interactive Shell (PTY) true │
│ Preferred Editor ▸ │
│ Auto-connect to IDE false │
│ ▼ │
│ │
│ Approval mode for tool usage. Controls how tools are approved before execution. │
@@ -235,13 +235,13 @@ exports[`SettingsDialog > Snapshot Tests > should render with various boolean se
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
│ │
│ ●︎ Tool Approval Mode Auto │
│ Code Mode Only (Experimental) false │
│ Language: UI Auto (detect from system) │
│ Language: Model Auto (follow user input) │
│ Theme Qwen Dark ▸ │
│ Vim Mode true* │
│ Interactive Shell (PTY) true │
│ Preferred Editor ▸ │
│ Auto-connect to IDE true* │
│ ▼ │
│ │
│ Approval mode for tool usage. Controls how tools are approved before execution. │
@@ -0,0 +1,302 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { act } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { Box } from 'ink';
import type { Config } from '@qwen-code/qwen-code-core';
import { LoadedSettings } from '../../config/settings.js';
import { renderWithProviders } from '../../test-utils/render.js';
import { VirtualViewportContext } from '../contexts/VirtualViewportContext.js';
import { ToolDetailsExpandedProvider } from '../contexts/ToolDetailsExpandedContext.js';
import { useMouseEvents } from '../hooks/useMouseEvents.js';
import type { MouseEvent } from '../utils/mouse.js';
import {
layoutRowForEvent,
measureElementPosition,
} from '../utils/measure-element-position.js';
import { ToolCallStatus } from '../types.js';
import {
CollapsibleToolGroupMessage,
HistoryItemDisplay,
} from './HistoryItemDisplay.js';
import { ToolGroupMessage } from './messages/ToolGroupMessage.js';
vi.mock('../hooks/useMouseEvents.js', () => ({
useMouseEvents: vi.fn(),
}));
vi.mock('../utils/measure-element-position.js', () => ({
layoutRowForEvent: vi.fn(),
measureElementPosition: vi.fn(),
}));
const emptySettingsFile = {
path: '',
settings: {},
originalSettings: {},
};
const collapsedSettings = new LoadedSettings(
emptySettingsFile,
emptySettingsFile,
{
path: '',
settings: { ui: { showToolCallDetails: false, useTerminalBuffer: true } },
originalSettings: {},
},
emptySettingsFile,
true,
new Set(),
);
const tool = {
callId: 'exec-1',
name: 'exec',
description: 'const veryLongSource = true;',
resultDisplay: 'very long result',
status: ToolCallStatus.Success,
confirmationDetails: undefined,
};
const mouseEvent = (name: MouseEvent['name'], col: number): MouseEvent => ({
name,
col,
row: 1,
shift: false,
meta: false,
ctrl: false,
button: 'left',
});
function renderCollapsedTool(viewport = true) {
vi.mocked(useMouseEvents).mockClear();
vi.mocked(measureElementPosition).mockReturnValue({
x: 0,
y: 0,
width: 80,
height: 1,
});
vi.mocked(layoutRowForEvent).mockImplementation((_node, row) => row - 1);
const view = renderWithProviders(
<VirtualViewportContext.Provider value={viewport}>
<Box width={100}>
<CollapsibleToolGroupMessage
toolCalls={[tool]}
groupId={1}
contentWidth={96}
isPending={false}
/>
</Box>
</VirtualViewportContext.Provider>,
{ settings: collapsedSettings, config: {} as Config },
);
return {
...view,
handler: vi.mocked(useMouseEvents).mock.calls.at(-1)?.[0],
};
}
describe('<CollapsibleToolGroupMessage />', () => {
it('renders the underlying hidden-details row', () => {
const { lastFrame } = renderWithProviders(
<ToolGroupMessage
toolCalls={[tool]}
groupId={1}
contentWidth={96}
hideDetails
expandHint="click to expand"
/>,
{ settings: collapsedSettings, config: {} as Config },
);
expect(lastFrame()).toContain('exec');
});
it('hides arguments and results in one-line mode', () => {
const { lastFrame } = renderCollapsedTool();
const frame = lastFrame() ?? '';
expect(frame).toContain('exec');
expect(frame).toContain('click to expand');
expect(frame).not.toContain('veryLongSource');
expect(frame).not.toContain('very long result');
expect(frame.split('\n')).toHaveLength(1);
expect(vi.mocked(useMouseEvents).mock.calls.at(-1)?.[1]).toMatchObject({
isActive: true,
});
});
it('expands after a complete click', () => {
const { handler, lastFrame } = renderCollapsedTool();
act(() => {
handler?.(mouseEvent('left-press', 5));
handler?.(mouseEvent('left-release', 5));
});
expect(lastFrame()).toContain('const veryLongSource = true;');
expect(lastFrame()).toContain('very long result');
});
it('keeps a live tool expanded when its history row remounts', () => {
vi.mocked(measureElementPosition).mockReturnValue({
x: 0,
y: 0,
width: 80,
height: 1,
});
vi.mocked(layoutRowForEvent).mockImplementation((_node, row) => row - 1);
const expandBatch = vi.fn();
vi.mocked(useMouseEvents).mockClear();
const pending = renderWithProviders(
<ToolDetailsExpandedProvider
value={{ expandedBatchIds: new Set<string>(), expandBatch }}
>
<VirtualViewportContext.Provider value={true}>
<HistoryItemDisplay
item={{
id: 0,
type: 'tool_group',
batchId: 'tool-batch-test-1',
tools: [tool],
}}
terminalWidth={100}
isPending
/>
</VirtualViewportContext.Provider>
</ToolDetailsExpandedProvider>,
{ settings: collapsedSettings, config: {} as Config },
);
const handler = vi.mocked(useMouseEvents).mock.calls.at(-1)?.[0];
act(() => {
handler?.(mouseEvent('left-press', 5));
handler?.(mouseEvent('left-release', 5));
});
expect(expandBatch).toHaveBeenCalledWith('tool-batch-test-1');
pending.unmount();
vi.mocked(useMouseEvents).mockClear();
const committed = renderWithProviders(
<ToolDetailsExpandedProvider
value={{
expandedBatchIds: new Set(['tool-batch-test-1']),
expandBatch,
}}
>
<VirtualViewportContext.Provider value={true}>
<HistoryItemDisplay
item={{
id: 1,
type: 'tool_group',
batchId: 'tool-batch-test-1',
tools: [tool],
}}
terminalWidth={100}
isPending={false}
/>
</VirtualViewportContext.Provider>
</ToolDetailsExpandedProvider>,
{ settings: collapsedSettings, config: {} as Config },
);
expect(committed.lastFrame()).toContain('very long result');
expect(vi.mocked(useMouseEvents).mock.calls.at(-1)?.[1]).toMatchObject({
isActive: false,
});
});
it('does not expand while selecting text', () => {
const { handler, lastFrame } = renderCollapsedTool();
act(() => {
handler?.(mouseEvent('left-press', 5));
handler?.(mouseEvent('move', 20));
handler?.(mouseEvent('left-release', 20));
});
expect(lastFrame()).not.toContain('veryLongSource');
expect(lastFrame()).not.toContain('very long result');
});
it('falls back to Ctrl+O when viewport clicking is unavailable', () => {
const { lastFrame } = renderCollapsedTool(false);
expect(lastFrame()).toContain('ctrl+o to expand');
expect(vi.mocked(useMouseEvents).mock.calls.at(-1)?.[1]).toMatchObject({
isActive: false,
});
});
it('lets Ctrl+O full detail override the setting', () => {
const { lastFrame } = renderWithProviders(
<CollapsibleToolGroupMessage
toolCalls={[tool]}
groupId={1}
contentWidth={96}
isPending={false}
fullDetail
/>,
{ settings: collapsedSettings, config: {} as Config },
);
expect(lastFrame()).toContain('const veryLongSource = true;');
expect(lastFrame()).toContain('very long result');
});
it('keeps user-initiated tools expanded', () => {
const { lastFrame } = renderWithProviders(
<CollapsibleToolGroupMessage
toolCalls={[tool]}
groupId={1}
contentWidth={96}
isPending={false}
isUserInitiated
/>,
{ settings: collapsedSettings, config: {} as Config },
);
expect(lastFrame()).toContain('const veryLongSource = true;');
expect(lastFrame()).toContain('very long result');
});
it('keeps approval prompts expanded', () => {
const { lastFrame } = renderWithProviders(
<CollapsibleToolGroupMessage
toolCalls={[{ ...tool, status: ToolCallStatus.Confirming }]}
groupId={1}
contentWidth={96}
isPending
/>,
{ settings: collapsedSettings, config: {} as Config },
);
expect(lastFrame()).toContain('const veryLongSource = true;');
expect(lastFrame()).not.toContain('click to expand');
});
it('keeps a focused interactive shell expanded', () => {
vi.mocked(useMouseEvents).mockClear();
const { lastFrame } = renderWithProviders(
<CollapsibleToolGroupMessage
toolCalls={[{ ...tool, status: ToolCallStatus.Executing, ptyId: 42 }]}
groupId={1}
contentWidth={96}
isPending
embeddedShellFocused
activeShellPtyId={42}
/>,
{ settings: collapsedSettings, config: {} as Config },
);
expect(lastFrame()).not.toContain('click to expand');
expect(vi.mocked(useMouseEvents).mock.calls.at(-1)?.[1]).toMatchObject({
isActive: false,
});
});
});
@@ -406,6 +406,34 @@ describe('<ToolGroupMessage />', () => {
expect(frame).toContain('MockTool[tool-2]');
});
it('renders a one-line summary without tool arguments or results', () => {
vi.mocked(ToolMessage).mockClear();
const toolCalls = [
createToolCall({
name: 'exec',
description: 'const source = "many lines";',
resultDisplay: 'large tool output',
}),
];
const { lastFrame } = renderWithProviders(
<ToolGroupMessage
{...baseProps}
toolCalls={toolCalls}
hideDetails
expandHint="click to expand"
/>,
);
const frame = lastFrame() ?? '';
expect(frame).toContain('exec');
expect(frame).toContain('click to expand');
expect(frame).not.toContain('const source');
expect(frame).not.toContain('large tool output');
expect(ToolMessage).not.toHaveBeenCalled();
expect(frame.split('\n')).toHaveLength(1);
});
it('renders collapsible tools as summary via CompactToolGroupDisplay', () => {
const toolCalls = [
createToolCall({ callId: 'r1', name: 'ReadFile', description: 'a.ts' }),
@@ -6,6 +6,7 @@
import type React from 'react';
import { Box, Text } from 'ink';
import type { DOMElement } from 'ink';
import { useMemo, useRef } from 'react';
import type { IndividualToolCallDisplay } from '../../types.js';
import { ToolCallStatus } from '../../types.js';
@@ -14,6 +15,7 @@ import { ToolConfirmationMessage } from './ToolConfirmationMessage.js';
import {
CompactToolGroupDisplay,
estimateCompactToolGroupHeight,
getOverallStatus,
isCollapsibleTool,
} from './CompactToolGroupDisplay.js';
import { InlineParallelAgentsDisplay } from './InlineParallelAgentsDisplay.js';
@@ -21,6 +23,9 @@ import { useConfig } from '../../contexts/ConfigContext.js';
import { useShowToolCallArgs } from '../../hooks/use-show-tool-call-args.js';
import { ICON } from '../../constants.js';
import type { AgentResultDisplay } from '@qwen-code/qwen-code-core';
import { ToolStatusIndicator } from '../shared/ToolStatusIndicator.js';
import { ToolElapsedTime } from '../shared/ToolElapsedTime.js';
import { localizeToolDisplayName } from '../../../i18n/index.js';
function isAgentWithPendingConfirmation(
rd: IndividualToolCallDisplay['resultDisplay'],
@@ -162,8 +167,58 @@ interface ToolGroupMessageProps {
* type-based partition baseline).
*/
fullDetail?: boolean;
/** Render only status, tool name/count, elapsed time, and an expand hint. */
hideDetails?: boolean;
expandHint?: string;
summaryRef?: React.Ref<DOMElement>;
}
const HiddenToolDetailsSummary: React.FC<{
toolCalls: IndividualToolCallDisplay[];
contentWidth: number;
expandHint?: string;
summaryRef?: React.Ref<DOMElement>;
}> = ({ toolCalls, contentWidth, expandHint, summaryRef }) => {
if (toolCalls.length === 0) return null;
const status = getOverallStatus(toolCalls);
const activeTool =
toolCalls.find((tool) => tool.status === ToolCallStatus.Confirming) ??
toolCalls.find((tool) => tool.status === ToolCallStatus.Executing) ??
toolCalls.find((tool) => tool.status === ToolCallStatus.Pending) ??
toolCalls[toolCalls.length - 1];
const names = Array.from(
new Set(toolCalls.map((tool) => localizeToolDisplayName(tool.name))),
);
const label =
toolCalls.length === 1
? names[0]
: names.length === 1
? `${names[0]} × ${toolCalls.length}`
: `${names.join(', ')} · ${toolCalls.length} tool calls`;
return (
<Box ref={summaryRef} flexDirection="row" width={contentWidth}>
<ToolStatusIndicator status={status} name={activeTool.name} />
<Box flexGrow={1}>
<Text bold wrap="truncate-end">
{label}
{expandHint && (
<Text bold={false} dimColor>
{' '}
· {expandHint}
</Text>
)}
</Text>
</Box>
<ToolElapsedTime
status={status}
executionStartTime={activeTool.executionStartTime}
/>
</Box>
);
};
// Main component maps the tools using ToolMessage
export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
toolCalls,
@@ -177,6 +232,9 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
memoryReadCount,
isUserInitiated,
fullDetail = false,
hideDetails = false,
expandHint,
summaryRef,
}) => {
const config = useConfig();
const showToolCallArgs = useShowToolCallArgs();
@@ -294,6 +352,18 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
const hasSubagentPendingConfirmation = subagentsAwaitingApproval.length > 0;
if (hideDetails) {
if (isPending && inlineToolCalls.length === 0) return null;
return (
<HiddenToolDetailsSummary
toolCalls={inlineToolCalls}
contentWidth={contentWidth}
expandHint={expandHint}
summaryRef={summaryRef}
/>
);
}
// Pure parallel agent group (≥2 agents, nothing else).
//
// Render through the SAME `inlineToolCalls` hand-off as every other group:
@@ -0,0 +1,24 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { createContext, useContext } from 'react';
export interface ToolDetailsExpandedValue {
expandedBatchIds: ReadonlySet<string>;
expandBatch: (batchId: string) => void;
}
const EMPTY_IDS: ReadonlySet<string> = new Set<string>();
const ToolDetailsExpandedContext = createContext<ToolDetailsExpandedValue>({
expandedBatchIds: EMPTY_IDS,
expandBatch: () => {},
});
export const useToolDetailsExpanded = (): ToolDetailsExpandedValue =>
useContext(ToolDetailsExpandedContext);
export const ToolDetailsExpandedProvider = ToolDetailsExpandedContext.Provider;
@@ -69,6 +69,9 @@ describe('buildSettingsListItems', () => {
expect(items.length).toBeGreaterThan(0);
const keys = items.map((item) => item.key);
expect(keys).toContain('ui.theme');
expect(keys.indexOf('tools.codeModeOnly')).toBe(
keys.indexOf('tools.approvalMode') + 1,
);
// Labels are resolved from the schema definitions.
const themeItem = items.find((item) => item.key === 'ui.theme');
expect(themeItem?.label).toBeTruthy();
+5 -5
View File
@@ -22,12 +22,12 @@ import { writeStderrLine } from './stdioHelpers.js';
const debugLogger = createDebugLogger('CLI_ERRORS');
/**
* Marker thrown when a producer has already formatted the error message and
* written it to stderr — the downstream `handleError` should propagate the
* exit code without printing or reformatting again.
* Marker thrown when a producer has already formatted and reported an error
* through the active output path — the downstream `handleError` should
* propagate the exit code without printing or reformatting it again.
*
* The non-interactive runner uses this when an upstream API error event
* arrives mid-stream: it formats with parseAndFormatApiError, writes once,
* arrives mid-stream: it formats with parseAndFormatApiError, reports it,
* and then throws. Without this marker, handleError would call
* parseAndFormatApiError a second time on the (now formatted) Error.message,
* yielding "[API Error: [API Error: ...]]" plus a duplicate stderr line.
@@ -145,7 +145,7 @@ export async function handleError(
config: Config,
customErrorCode?: string | number,
): Promise<never> {
// Producers that already wrote a formatted message to stderr (see
// Producers that already reported a formatted message (see
// AlreadyReportedError above) should not be reprinted or reformatted here.
// In TEXT mode this short-circuits straight to a clean re-throw; in JSON
// mode we still emit the structured payload exactly once so machine
+2
View File
@@ -88,6 +88,7 @@
"@anthropic-ai/sdk": "^0.36.1",
"@google/genai": "2.6.0",
"@iarna/toml": "^2.2.5",
"@jitl/quickjs-singlefile-mjs-release-sync": "0.32.0",
"@modelcontextprotocol/client": "2.0.0",
"@modelcontextprotocol/core": "2.0.0",
"@modelcontextprotocol/sdk": "^1.30.0",
@@ -127,6 +128,7 @@
"picomatch": "^4.0.1",
"prompts": "^2.4.2",
"proper-lockfile": "^4.1.2",
"quickjs-emscripten-core": "0.32.0",
"sharp": "^0.35.4",
"shell-quote": "^1.9.0",
"simple-git": "^3.36.0",
@@ -74,6 +74,7 @@ const createMockConfig = (
getSessionId: () => 'test-session',
getUserMemory: () => '',
getOutputStyle: (): ReturnType<typeof getBuiltInOutputStyle> => undefined,
getCodeModeOnly: () => false,
isTodoWriteEnabled: () => false,
// Read by resolveMainSessionOutputStyle: the peer inherits the style the
// main session actually carries, so the main session's prompt-override and
@@ -1112,6 +1112,7 @@ export class ArenaManager {
'headless',
this.resolvePeerOutputStyle(),
this.config.isTodoWriteEnabled(),
this.config.getCodeModeOnly(),
),
contextFiles: this.config.getUserMemory(),
}),
@@ -8,6 +8,10 @@ import { describe, it, expect, vi } from 'vitest';
import type { FunctionDeclaration } from '@google/genai';
import { AgentCore } from './agent-core.js';
import { ToolNames } from '../../tools/tool-names.js';
import { makeFakeConfig } from '../../test-utils/config.js';
import { ToolRegistry } from '../../tools/tool-registry.js';
import { ExecTool } from '../../tools/exec.js';
import { MockTool } from '../../test-utils/mock-tool.js';
// The skill-announcement gate asks whether the model can INVOKE a skill, and
// that is two conditions, not one.
@@ -182,6 +186,92 @@ describe('AgentCore skill-gate inputs', () => {
});
});
describe('code mode skill gate', () => {
function makeCodeModeCore(
toolConfig: ConstructorParameters<typeof AgentCore>[5],
includeSkill = true,
) {
const config = makeFakeConfig({ codeModeOnly: true });
const registry = new ToolRegistry(config);
vi.spyOn(config, 'getToolRegistry').mockReturnValue(registry);
registry.registerTool(new ExecTool(config));
registry.registerTool(new MockTool({ name: ToolNames.READ_FILE }));
registry.registerTool(new MockTool({ name: ToolNames.TEAM_DELETE }));
registry.registerTool(new MockTool({ name: ToolNames.CRON_CREATE }));
if (includeSkill)
registry.registerTool(new MockTool({ name: ToolNames.SKILL }));
return new AgentCore(
'skill-code-mode',
config,
{ systemPrompt: '' } as never,
{ model: 'test-model' } as never,
{ max_turns: 1 } as never,
toolConfig,
);
}
it.each([
{ tools: ['*'] },
{ tools: [ToolNames.SKILL] },
{ tools: [ToolNames.EXEC], executionAllowedTools: [ToolNames.EXEC] },
])('opens for an executable nested skill: %j', async (toolConfig) => {
const core = makeCodeModeCore(toolConfig);
const declared = await declaredNames(core);
expect(declared).toEqual(new Set([ToolNames.EXEC]));
expect(gate(core, declared)).toBe(true);
});
it.each([
{ tools: ['*'], disallowedTools: [ToolNames.SKILL] },
{ tools: ['*'], disallowedTools: [ToolNames.EXEC] },
{ tools: [ToolNames.READ_FILE] },
{ tools: [ToolNames.EXEC], executionAllowedTools: [ToolNames.READ_FILE] },
])(
'closes when skill is outside effective permissions: %j',
async (toolConfig) => {
const core = makeCodeModeCore(toolConfig);
expect(gate(core, await declaredNames(core))).toBe(false);
},
);
it('prepares a fork policy when inherited declarations skip preparation', async () => {
const core = makeCodeModeCore({
tools: [ToolNames.EXEC],
executionAllowedTools: [ToolNames.READ_FILE],
});
const prepare = vi.spyOn(core, 'prepareTools');
await core.processFunctionCalls(
[],
new AbortController(),
'fork-prompt',
1,
[{ name: ToolNames.EXEC }],
);
expect(prepare).toHaveBeenCalledOnce();
expect(
(core as unknown as { codeModeAllowedToolNames?: readonly string[] })
.codeModeAllowedToolNames,
).toEqual([ToolNames.READ_FILE]);
expect(gate(core, new Set([ToolNames.EXEC]))).toBe(false);
});
it('closes for an unregistered skill', async () => {
const core = makeCodeModeCore({ tools: ['*'] }, false);
expect(gate(core, await declaredNames(core))).toBe(false);
});
it('keeps newly nested leader tools out of the subagent catalog', async () => {
const core = makeCodeModeCore({ tools: ['*'] });
const declarations = await core.prepareTools();
const description = declarations.find(
(item) => item.name === ToolNames.EXEC,
)?.description;
expect(description).toContain('tools.skill(args:');
expect(description).not.toContain('tools.team_delete(args:');
expect(description).not.toContain('tools.cron_create(args:');
});
});
describe('executable', () => {
it('allows everything when no execution allowlist is set', () => {
const core = makeCore({ tools: ['*'] });
@@ -203,5 +293,135 @@ describe('AgentCore skill-gate inputs', () => {
expect((await declaredNames(core)).has(ToolNames.SKILL)).toBe(true);
expect(executable(core, ToolNames.SKILL)).toBe(false);
});
it('uses exec as a restricted gateway for a narrowed CodeModeOnly agent', async () => {
const config = makeFakeConfig({ codeModeOnly: true });
const registry = new ToolRegistry(config);
vi.spyOn(config, 'getToolRegistry').mockReturnValue(registry);
registry.registerTool(new ExecTool(config));
registry.registerTool(new MockTool({ name: ToolNames.READ_FILE }));
registry.registerTool(new MockTool({ name: ToolNames.WRITE_FILE }));
const core = new AgentCore(
'restricted-code-mode',
config,
{ systemPrompt: '' } as never,
{ model: 'test-model' } as never,
{ max_turns: 1 } as never,
{
tools: [ToolNames.READ_FILE, ToolNames.WRITE_FILE],
executionAllowedTools: [ToolNames.READ_FILE],
},
);
const declarations = await core.prepareTools();
const exec = declarations.find(
(declaration) => declaration.name === ToolNames.EXEC,
);
expect(exec).toBeDefined();
expect(executable(core, ToolNames.EXEC)).toBe(true);
expect(exec?.description).toContain('tools.read_file');
expect(exec?.description).not.toContain('tools.write_file');
expect(
(
core as unknown as {
codeModeAllowedToolNames?: readonly string[];
}
).codeModeAllowedToolNames,
).toEqual([ToolNames.READ_FILE]);
});
it('narrows an inherited fork exec surface to its execution allowlist', async () => {
const config = makeFakeConfig({ codeModeOnly: true });
const registry = new ToolRegistry(config);
vi.spyOn(config, 'getToolRegistry').mockReturnValue(registry);
registry.registerTool(new ExecTool(config));
registry.registerTool(new MockTool({ name: ToolNames.READ_FILE }));
registry.registerTool(new MockTool({ name: ToolNames.SHELL }));
const core = new AgentCore(
'restricted-fork',
config,
{ systemPrompt: '' } as never,
{ model: 'test-model' } as never,
{ max_turns: 1 } as never,
{
tools: [ToolNames.EXEC],
executionAllowedTools: [ToolNames.READ_FILE],
},
);
const declarations = await core.prepareTools();
const exec = declarations.find(
(declaration) => declaration.name === ToolNames.EXEC,
);
expect(executable(core, ToolNames.EXEC)).toBe(true);
expect(exec?.description).toContain('tools.read_file');
expect(exec?.description).not.toContain('tools.run_shell_command');
});
it('inherits the parent exec bindings for an unrestricted fork', async () => {
const config = makeFakeConfig({ codeModeOnly: true });
const registry = new ToolRegistry(config);
vi.spyOn(config, 'getToolRegistry').mockReturnValue(registry);
registry.registerTool(new ExecTool(config));
registry.registerTool(new MockTool({ name: ToolNames.READ_FILE }));
registry.registerTool(new MockTool({ name: ToolNames.SHELL }));
const core = new AgentCore(
'unrestricted-fork',
config,
{ systemPrompt: '' } as never,
{ model: 'test-model' } as never,
{ max_turns: 1 } as never,
{
tools: [ToolNames.EXEC],
executionAllowedTools: [ToolNames.EXEC],
},
);
const declarations = await core.prepareTools();
const exec = declarations.find(
(declaration) => declaration.name === ToolNames.EXEC,
);
expect(exec?.description).toContain('tools.read_file');
expect(exec?.description).toContain('tools.run_shell_command');
});
it('keeps disallowed tools out of a restricted CodeModeOnly gateway', async () => {
const config = makeFakeConfig({ codeModeOnly: true });
const registry = new ToolRegistry(config);
vi.spyOn(config, 'getToolRegistry').mockReturnValue(registry);
registry.registerTool(new ExecTool(config));
registry.registerTool(new MockTool({ name: ToolNames.READ_FILE }));
registry.registerTool(new MockTool({ name: ToolNames.WRITE_FILE }));
const core = new AgentCore(
'disallowed-code-mode',
config,
{ systemPrompt: '' } as never,
{ model: 'test-model' } as never,
{ max_turns: 1 } as never,
{
tools: ['*'],
disallowedTools: [ToolNames.WRITE_FILE],
executionAllowedTools: [ToolNames.READ_FILE, ToolNames.WRITE_FILE],
},
);
const declarations = await core.prepareTools();
const exec = declarations.find(
(declaration) => declaration.name === ToolNames.EXEC,
);
expect(exec?.description).toContain('tools.read_file');
expect(exec?.description).not.toContain('tools.write_file');
expect(
(
core as unknown as {
codeModeAllowedToolNames?: readonly string[];
}
).codeModeAllowedToolNames,
).toEqual([ToolNames.READ_FILE]);
});
});
});
+92 -1
View File
@@ -112,6 +112,7 @@ import { AgentEventEmitter, AgentEventType } from './agent-events.js';
import { AgentStatistics, type AgentStatsSummary } from './agent-statistics.js';
import { matchesMcpPattern } from '../../permissions/rule-parser.js';
import { ToolNames } from '../../tools/tool-names.js';
import { getToolExposure, ToolMode } from '../../tools/code-mode.js';
import { DEFAULT_QWEN_MODEL } from '../../config/models.js';
import { type ContextState, templateString } from './agent-headless.js';
import { getResponseText } from '../../utils/partUtils.js';
@@ -452,6 +453,7 @@ export class AgentCore {
private readonly executionAllowedExactTools?: ReadonlySet<string>;
private readonly executionAllowedMcpPatterns?: readonly string[];
private readonly executionAllowlistErrorSummary?: string;
private codeModeAllowedToolNames?: readonly string[];
/**
* Event emitter for this agent. Always present — if the caller doesn't
* pass one, AgentCore allocates its own so the observable state below
@@ -714,6 +716,66 @@ export class AgentCore {
toolRegistry.isPermissionDeferred?.(name) === true &&
toolRegistry.isDeferredAndHidden?.(name) === true;
const isDisallowed = (name: string): boolean =>
this.toolConfig?.disallowedTools?.some((pattern) =>
name.startsWith('mcp__')
? matchesMcpPattern(pattern, name)
: pattern === name,
) === true;
if (this.runtimeContext.getToolMode?.() === ToolMode.CodeModeOnly) {
const stringTools =
this.toolConfig?.tools.filter(
(tool): tool is string => typeof tool === 'string',
) ?? [];
const inlineTools =
this.toolConfig?.tools.filter(
(tool): tool is FunctionDeclaration => typeof tool !== 'string',
) ?? [];
const inheritsRegistry =
!this.toolConfig ||
stringTools.includes('*') ||
(stringTools.length === 0 && inlineTools.length === 0);
const configuredNames = inheritsRegistry
? undefined
: new Set(stringTools);
const inheritsCodeModeBindings =
configuredNames?.has(ToolNames.EXEC) === true;
const allowedNames = toolRegistry
.getAllToolNames()
.filter(
(name) =>
(!configuredNames ||
configuredNames.has(name) ||
(inheritsCodeModeBindings &&
getToolExposure(name) === 'code-mode-callable')) &&
!isExcluded(name) &&
!isHiddenByEagerAllowList(name) &&
!isDisallowed(name) &&
this.isToolExecutionAllowed(name),
);
this.codeModeAllowedToolNames = Object.freeze(
allowedNames.filter(
(name) => getToolExposure(name) === 'code-mode-callable',
),
);
const declarations =
toolRegistry.getFunctionDeclarationsFiltered(allowedNames);
declarations.push(
...inlineTools.filter(
(tool) =>
!isExcluded(tool.name) &&
!isHiddenByEagerAllowList(tool.name) &&
(!tool.name || !isDisallowed(tool.name)),
),
);
return declarations.filter(
(declaration) => !declaration.name || !isDisallowed(declaration.name),
);
}
this.codeModeAllowedToolNames = undefined;
if (this.toolConfig) {
const asStrings = this.toolConfig.tools.filter(
(t): t is string => typeof t === 'string',
@@ -1623,7 +1685,11 @@ export class AgentCore {
declaredToolNames: ReadonlySet<string | undefined>,
): boolean {
return (
declaredToolNames.has(ToolNames.SKILL) &&
(declaredToolNames.has(ToolNames.SKILL) ||
(this.runtimeContext.getToolMode?.() === ToolMode.CodeModeOnly &&
declaredToolNames.has(ToolNames.EXEC) &&
!!this.runtimeContext.getToolRegistry().getTool(ToolNames.SKILL) &&
this.codeModeAllowedToolNames?.includes(ToolNames.SKILL) === true)) &&
this.isToolExecutionAllowed(ToolNames.SKILL)
);
}
@@ -1632,6 +1698,19 @@ export class AgentCore {
if (this.executionAllowedTools === undefined) {
return true;
}
if (
toolName === ToolNames.EXEC &&
this.runtimeContext.getToolMode?.() === ToolMode.CodeModeOnly
) {
return true;
}
if (
this.runtimeContext.getToolMode?.() === ToolMode.CodeModeOnly &&
this.executionAllowedExactTools?.has(ToolNames.EXEC) &&
getToolExposure(toolName) === 'code-mode-callable'
) {
return true;
}
if (this.executionAllowedExactTools?.has(toolName)) {
return true;
}
@@ -1714,6 +1793,12 @@ export class AgentCore {
responseParts: Part[];
}>;
}> {
if (
this.codeModeAllowedToolNames === undefined &&
this.runtimeContext.getToolMode?.() === ToolMode.CodeModeOnly
) {
await this.prepareTools();
}
const responseByCallId = new Map<
string,
{
@@ -2200,6 +2285,12 @@ export class AgentCore {
prompt_id: promptId,
response_id: responseId,
wasOutputTruncated,
...(toolName === ToolNames.EXEC &&
this.runtimeContext.getToolMode?.() === ToolMode.CodeModeOnly
? {
codeModeAllowedToolNames: this.codeModeAllowedToolNames ?? [],
}
: {}),
};
const description = this.getToolDescription(toolName, args);
@@ -0,0 +1,957 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it, vi } from 'vitest';
import type { Tool } from '@google/genai';
import { AnthropicContentConverter } from '../core/anthropicContentGenerator/converter.js';
import { convertLlmToolsToOpenAI } from '../core/openaiContentGenerator/converter.js';
import { makeFakeConfig } from '../test-utils/config.js';
import { MockTool } from '../test-utils/mock-tool.js';
import { ToolRegistry } from '../tools/tool-registry.js';
import {
buildExecDescription,
getToolExposure,
isCodeModeToolCallAllowed,
planCodeModeBindings,
type CodeModeBindingPlan,
} from '../tools/code-mode.js';
import { executeCodeMode } from './host-client.js';
import {
CODE_MODE_MAX_CONTROL_FRAME_BYTES,
CODE_MODE_MAX_FRAME_BYTES,
encodeFrame,
FrameDecoder,
type HostMessage,
type ParentMessage,
} from './protocol.js';
import type { ToolCallRuntimeContext } from './tool-call-runtime.js';
function plan(...jsNames: string[]): CodeModeBindingPlan {
return {
bindings: jsNames.map((jsName) => ({
name: jsName,
jsName,
description: `${jsName} description`,
parametersJsonSchema: { type: 'object' },
deferred: false,
})),
collisions: [],
};
}
function runtime(
dispatch: ToolCallRuntimeContext['dispatch'],
): ToolCallRuntimeContext {
return { parentCallId: 'parent', dispatch };
}
describe('CodeModeOnly exposure', () => {
const directTools = [
'ask_user_question',
'agent',
'enter_plan_mode',
'exit_plan_mode',
'structured_output',
'create_sub_session',
'enter_worktree',
'exit_worktree',
'send_message',
'speak_to_user',
'wait_threads',
];
const migratedTools = [
'get_goal',
'list_agents',
'task_create',
'task_update',
'task_list',
'task_stop',
'team_create',
'team_delete',
'team_plan_approval',
'request_shutdown',
'list_threads',
'read_thread',
'send_message_to_thread',
'create_thread',
'skill',
'update_goal',
'capture_screen_context',
'todo_write',
'report_findings',
'cron_create',
'cron_list',
'cron_delete',
'loop_wakeup',
'monitor',
'workflow',
];
it.each(directTools)('keeps %s directly callable only', (name) => {
expect(getToolExposure(name)).toBe('direct-only');
expect(isCodeModeToolCallAllowed(name, 'model')).toBe(true);
expect(isCodeModeToolCallAllowed(name, 'code_mode')).toBe(false);
});
it.each(migratedTools)(
'exposes %s through exec with scoped permissions',
(name) => {
expect(getToolExposure(name)).toBe('code-mode-callable');
expect(isCodeModeToolCallAllowed(name, 'model')).toBe(false);
expect(isCodeModeToolCallAllowed(name, 'code_mode')).toBe(true);
expect(isCodeModeToolCallAllowed(name, 'code_mode', new Set())).toBe(
false,
);
expect(
isCodeModeToolCallAllowed(name, 'code_mode', new Set([name])),
).toBe(true);
},
);
it('keeps discovery hidden and exec non-nestable', () => {
for (const name of ['tool_search', 'tool_call']) {
expect(getToolExposure(name)).toBe('hidden');
expect(isCodeModeToolCallAllowed(name, 'model')).toBe(false);
expect(isCodeModeToolCallAllowed(name, 'code_mode')).toBe(false);
}
expect(getToolExposure('exec')).toBe('exec');
expect(isCodeModeToolCallAllowed('exec', 'code_mode')).toBe(false);
});
it('moves management and context tools out of top-level declarations', () => {
const registry = new ToolRegistry(makeFakeConfig({ codeModeOnly: true }));
for (const name of [...directTools, ...migratedTools, 'exec']) {
registry.registerTool(new MockTool({ name }));
}
const declarations = registry.getFunctionDeclarations();
expect(declarations.map((declaration) => declaration.name).sort()).toEqual(
[...directTools, 'exec'].sort(),
);
const description = declarations.find(
(declaration) => declaration.name === 'exec',
)?.description;
for (const name of migratedTools)
expect(description).toContain(`tools.${name}(args:`);
expect(description).toContain('automatically retained');
expect(description).toContain('terminal update_goal');
});
it('registers exec only when CodeModeOnly is enabled', async () => {
const direct = makeFakeConfig();
const directRegistry = await direct.createToolRegistry(undefined, {
skipDiscovery: true,
});
const codeMode = makeFakeConfig({ codeModeOnly: true });
const codeModeRegistry = await codeMode.createToolRegistry(undefined, {
skipDiscovery: true,
});
expect(directRegistry.getAllToolNames()).not.toContain('exec');
expect(codeModeRegistry.getAllToolNames()).toContain('exec');
expect(codeModeRegistry.getAllToolNames()).toContain('tool_search');
});
it('keeps Direct declarations unchanged', () => {
const registry = new ToolRegistry(makeFakeConfig());
for (const name of ['read_file', 'tool_search', 'agent']) {
registry.registerTool(new MockTool({ name }));
}
expect(registry.getFunctionDeclarations().map((item) => item.name)).toEqual(
['agent', 'read_file', 'tool_search'],
);
});
it('exposes exec and direct controls while retaining ordinary and hidden tools', () => {
const registry = new ToolRegistry(makeFakeConfig({ codeModeOnly: true }));
for (const name of [
'read_file',
'tool_search',
'ask_user_question',
'agent',
'exec',
]) {
registry.registerTool(new MockTool({ name }));
}
const declarations = registry.getFunctionDeclarations();
expect(declarations.map((item) => item.name)).toEqual([
'agent',
'ask_user_question',
'exec',
]);
expect(
declarations.find((item) => item.name === 'exec')?.description,
).toContain('tools.read_file');
expect(
declarations.find((item) => item.name === 'exec')?.description,
).not.toContain('tools.tool_search');
expect(registry.getAllToolNames()).toEqual(
expect.arrayContaining(['read_file', 'tool_search']),
);
});
it('narrows nested tools for filtered subagent declarations', () => {
const registry = new ToolRegistry(makeFakeConfig({ codeModeOnly: true }));
for (const name of ['read_file', 'write_file', 'agent', 'exec']) {
registry.registerTool(new MockTool({ name }));
}
const declarations = registry.getFunctionDeclarationsFiltered([
'read_file',
]);
expect(declarations.map((item) => item.name)).toEqual(['exec']);
expect(declarations[0]?.description).toContain('tools.read_file');
expect(declarations[0]?.description).not.toContain('tools.write_file');
});
it('keeps exec structured across Gemini, OpenAI, and Anthropic tool conversion', async () => {
const registry = new ToolRegistry(makeFakeConfig({ codeModeOnly: true }));
for (const name of ['read_file', 'agent', 'exec']) {
registry.registerTool(new MockTool({ name, params: { type: 'object' } }));
}
const declarations = registry.getFunctionDeclarations();
const tools = [{ functionDeclarations: declarations }] as Tool[];
expect(declarations.map((item) => item.name)).toEqual(['agent', 'exec']);
const openai = await convertLlmToolsToOpenAI(tools);
expect(openai.map((item) => item.function.name)).toEqual(['agent', 'exec']);
const anthropic = await new AnthropicContentConverter(
'test-model',
).convertLlmToolsToAnthropic(tools);
expect(anthropic.map((item) => item.name)).toEqual(['agent', 'exec']);
expect(openai[1]?.function.description).toContain('tools.read_file');
expect(anthropic[1]?.description).toContain('tools.read_file');
});
it('builds stable declarations and resolves normalized-name collisions first-wins', () => {
const tools = [
new MockTool({
name: 'z-tool',
params: {
type: 'object',
properties: { count: { type: 'integer' } },
required: ['count'],
},
}),
new MockTool({ name: 'z_tool' }),
new MockTool({
name: 'a-tool',
shouldDefer: true,
params: {
type: 'object',
properties: { query: { type: 'string' } },
required: ['query'],
},
}),
];
const first = planCodeModeBindings(tools, (name) => name === 'a-tool');
const second = planCodeModeBindings(
[...tools].reverse(),
(name) => name === 'a-tool',
);
expect(first).toEqual(second);
expect(first.bindings.map((item) => item.name)).toEqual([
'a-tool',
'z-tool',
]);
expect(first.collisions).toEqual([
{ jsName: 'z_tool', kept: 'z-tool', omitted: 'z_tool' },
]);
expect(buildExecDescription(first)).toContain(
'tools.z_tool(args: { "count": number })',
);
expect(buildExecDescription(first)).toContain(
'tools.a_tool(args: { "query": string })',
);
expect(buildExecDescription(first)).toContain('ImageContent');
expect(buildExecDescription(first)).toContain('generatedImage');
expect(buildExecDescription(first)).toContain(
'setTimeout(callback: () => void, delayMs?: number)',
);
expect(buildExecDescription(first)).toContain(
'Pending timeouts do not keep exec alive by themselves',
);
expect(buildExecDescription(first)).toContain(
'clearTimeout(timeoutId?: number)',
);
});
it('expands deferred tool schemas because nothing can reveal them later', () => {
const deferredPlan = planCodeModeBindings(
[
new MockTool({
name: 'mcp__server__fetch',
shouldDefer: true,
params: {
type: 'object',
properties: {
url: { type: 'string' },
depth: { type: 'integer' },
},
required: ['url'],
},
}),
],
() => true,
);
const description = buildExecDescription(deferredPlan);
expect(deferredPlan.bindings[0]?.deferred).toBe(true);
expect(description).toContain(
'tools.mcp__server__fetch(args: { "depth"?: number; "url": string })',
);
expect(description).not.toContain('mcp__server__fetch(args: Record');
expect(description).toContain('"deferred":true');
});
it('keeps numeric schema limits visible in nested tool declarations', () => {
const boundedPlan = planCodeModeBindings(
[
new MockTool({
name: 'run_shell_command',
params: {
type: 'object',
properties: {
timeout: { type: 'integer', minimum: 1, maximum: 600000 },
},
},
}),
],
() => false,
);
expect(buildExecDescription(boundedPlan)).toContain(
'"timeout"?: number /* min 1, max 600000 */',
);
});
});
describe('code mode protocol', () => {
it('allows large completion media without widening control messages', () => {
const data = 'A'.repeat(CODE_MODE_MAX_CONTROL_FRAME_BYTES);
const complete: HostMessage = {
type: 'complete',
output: '',
content: [{ type: 'image', mimeType: 'image/png', data }],
};
const toolResult: ParentMessage = {
type: 'tool_result',
id: 'large-media-result',
ok: true,
result: {
callId: 'image-gen',
name: 'image_gen',
status: 'success',
output: 'generated',
content: [{ type: 'image', mimeType: 'image/png', data }],
},
};
const frame = encodeFrame(complete);
expect(new FrameDecoder<HostMessage>().push(frame)).toEqual([complete]);
const toolResultFrame = encodeFrame(toolResult);
expect(new FrameDecoder<ParentMessage>().push(toolResultFrame)).toEqual([
toolResult,
]);
expect(() =>
encodeFrame({
type: 'tool_call',
id: 'large-control',
name: 'probe',
args: { data },
}),
).toThrow('frame exceeds the size limit');
expect(() =>
encodeFrame({
type: 'tool_result',
id: 'large-text-result',
ok: true,
result: {
callId: 'large-text',
name: 'probe',
status: 'success',
output: data,
},
}),
).toThrow('frame exceeds the size limit');
});
});
describe('isolated code mode host', () => {
it('runs async tool calls, Promise.all, helpers, and return values', async () => {
const dispatch = vi.fn(async (name, args) => ({
callId: String(args['value']),
name,
status: 'success' as const,
output: String(args['value']),
}));
const result = await executeCodeMode(
`const [a, b] = await Promise.all([
tools.echo({ value: 1 }),
tools.echo({ value: 2 }),
]);
text(a.output);
text('tail');
return { second: b.output, tools: ALL_TOOLS };`,
plan('echo'),
runtime(dispatch),
new AbortController().signal,
);
expect(result.output).toBe('1\ntail');
expect(result.value).toEqual({
second: '2',
tools: [
{
name: 'echo',
jsName: 'echo',
description: 'echo description',
deferred: false,
},
],
});
expect(dispatch).toHaveBeenCalledTimes(2);
});
it('does not charge nested tool wait time against the guest CPU budget', async () => {
const result = await executeCodeMode(
'return (await tools.wait({})).output',
plan('wait'),
runtime(async (name) => {
await new Promise((resolve) => setTimeout(resolve, 6_000));
return {
callId: 'wait',
name,
status: 'success',
output: 'finished',
};
}),
new AbortController().signal,
{ timeoutMs: 50 },
);
expect(result.value).toBe('finished');
});
it('resumes the guest CPU budget after a nested tool settles', async () => {
await expect(
executeCodeMode(
'await tools.wait({}); while (true) {}',
plan('wait'),
runtime(async (name) => {
await new Promise((resolve) => setTimeout(resolve, 100));
return {
callId: 'wait',
name,
status: 'success',
output: 'finished',
};
}),
new AbortController().signal,
{ timeoutMs: 50 },
),
).rejects.toThrow(/interrupted|timed out/);
});
it('calls a deferred MCP-style tool through its normalized JavaScript name', async () => {
const dispatch = vi.fn(async (name: string) => ({
callId: 'mcp-call',
name,
status: 'success' as const,
output: 'mcp output',
}));
const mcpPlan: CodeModeBindingPlan = {
bindings: [
{
name: 'mcp.server/read-resource',
jsName: 'mcp_server_read_resource',
description: 'Read an MCP resource',
parametersJsonSchema: { type: 'object' },
deferred: true,
},
],
collisions: [],
};
const result = await executeCodeMode(
'return (await tools.mcp_server_read_resource({ uri: "test://item" })).output',
mcpPlan,
runtime(dispatch),
new AbortController().signal,
);
expect(result.value).toBe('mcp output');
expect(dispatch).toHaveBeenCalledWith(
'mcp.server/read-resource',
{ uri: 'test://item' },
expect.any(AbortSignal),
);
});
it('reports invalid JavaScript and thrown errors', async () => {
await expect(
executeCodeMode(
'if (',
plan(),
runtime(async () => {
throw new Error('unused');
}),
new AbortController().signal,
),
).rejects.toThrow();
await expect(
executeCodeMode(
'throw new Error("guest failure")',
plan(),
runtime(async () => {
throw new Error('unused');
}),
new AbortController().signal,
),
).rejects.toThrow('guest failure');
await expect(
executeCodeMode(
'await tools.echo({}).then(() => { throw new Error("job failure"); })',
plan('echo'),
runtime(async (name) => ({
callId: 'echo',
name,
status: 'success',
output: 'ok',
})),
new AbortController().signal,
),
).rejects.toThrow('job failure');
});
it('interrupts CPU loops and bounds helper output', async () => {
await expect(
executeCodeMode(
'while (true) {}',
plan(),
runtime(async () => {
throw new Error('unused');
}),
new AbortController().signal,
{ timeoutMs: 50 },
),
).rejects.toThrow(/interrupted|timed out/);
const result = await executeCodeMode(
'text("abcdefgh")',
plan(),
runtime(async () => {
throw new Error('unused');
}),
new AbortController().signal,
{ maxOutputChars: 3 },
);
expect(result.output).toBe('abc');
});
it('bounds return values and oversized nested tool content', async () => {
const result = await executeCodeMode(
`const nested = await tools.large({});
text(typeof nested.content);
try { await tools.fail({}); } catch (error) { text(error.message.length); }
return 'v'.repeat(1000);`,
plan('large', 'fail'),
runtime(async (name) => {
if (name === 'fail') throw new Error('e'.repeat(2_000_000));
return {
callId: 'large',
name,
status: 'success',
output: 'ok',
content: [
{
type: 'image',
mimeType: 'image/png',
data: 'A'.repeat(CODE_MODE_MAX_FRAME_BYTES + 1),
},
],
};
}),
new AbortController().signal,
{ maxOutputChars: 100 },
);
expect(result.output).toBe('undefined\n100000');
expect(result.value).toMatch(/^\[Code mode value truncated\]/);
expect((result.value as string).length).toBeLessThanOrEqual(100);
});
it('preserves media independently of the text output budget', async () => {
const data = 'QUJD'.repeat(2_000);
const result = await executeCodeMode(
`text('before');
image('data:image/png;base64,${data}');
text('after');`,
plan(),
runtime(async () => {
throw new Error('unused');
}),
new AbortController().signal,
{ maxOutputChars: 100 },
);
expect(result.output).toBe('before\nafter');
expect(result.content).toEqual([
{ type: 'image', mimeType: 'image/png', data },
]);
});
it('accepts Qwen MCP ImageContent in image()', async () => {
const result = await executeCodeMode(
`image({
type: 'image',
mimeType: 'image/png',
data: 'QUJD',
});`,
plan(),
runtime(async () => {
throw new Error('unused');
}),
new AbortController().signal,
);
expect(result.content).toEqual([
{ type: 'image', mimeType: 'image/png', data: 'QUJD' },
]);
});
it('accepts the Qwen image_gen result in generatedImage()', async () => {
const result = await executeCodeMode(
`generatedImage({
callId: 'image-gen',
name: 'image_gen',
status: 'success',
output: 'Generated image saved to /workspace/generated.png.',
content: [{
type: 'image',
mimeType: 'image/png',
data: 'QUJD',
}],
});`,
plan(),
runtime(async () => {
throw new Error('unused');
}),
new AbortController().signal,
);
expect(result).toEqual({
output: 'Generated image saved to /workspace/generated.png.',
content: [{ type: 'image', mimeType: 'image/png', data: 'QUJD' }],
});
});
it('preserves generated images larger than the control frame limit', async () => {
const data = 'QUJD'.repeat(350_000);
const result = await executeCodeMode(
`const generated = await tools.image_gen({ prompt: 'large poster' });
generatedImage(generated);`,
plan('image_gen'),
runtime(async (name) => ({
callId: 'large-image-gen',
name,
status: 'success',
output: 'Generated image saved to /workspace/large.png.',
content: [{ type: 'image', mimeType: 'image/png', data }],
})),
new AbortController().signal,
);
expect(result.output).toBe(
'Generated image saved to /workspace/large.png.',
);
expect(result.content).toEqual([
{ type: 'image', mimeType: 'image/png', data },
]);
});
it('rejects malformed Qwen media helper inputs', async () => {
const noTools = runtime(async () => {
throw new Error('unused');
});
await expect(
executeCodeMode(
`image({
type: 'image',
mimeType: 'audio/wav',
data: 'QUJD',
});`,
plan(),
noTools,
new AbortController().signal,
),
).rejects.toThrow('Qwen MCP ImageContent');
await expect(
executeCodeMode(
`generatedImage({
callId: 'other',
name: 'other_tool',
status: 'success',
output: 'not an image generator',
content: [{
type: 'image',
mimeType: 'image/png',
data: 'QUJD',
}],
});`,
plan(),
noTools,
new AbortController().signal,
),
).rejects.toThrow('tools.image_gen()');
});
it('rejects image output that is not a base64 data URL', async () => {
for (const value of [
'https://example.com/image.png',
'data:audio/wav;base64,QUJD',
'data:image/png;base64,==',
]) {
await expect(
executeCodeMode(
`image(${JSON.stringify(value)})`,
plan(),
runtime(async () => {
throw new Error('unused');
}),
new AbortController().signal,
),
).rejects.toThrow('base64 data URL');
}
});
it('enforces the memory limit and rejects unavailable or recursive tools', async () => {
const noTools = runtime(async () => {
throw new Error('unused');
});
await expect(
executeCodeMode(
'return new ArrayBuffer(128 * 1024 * 1024).byteLength',
plan(),
noTools,
new AbortController().signal,
{ timeoutMs: 1000 },
),
).rejects.toThrow('out of memory');
await expect(
executeCodeMode(
'await tools.exec({ source: "" })',
plan(),
noTools,
new AbortController().signal,
),
).rejects.toThrow('Unknown or unavailable code mode tool: exec');
await expect(
executeCodeMode(
'Object.prototype.hasOwnProperty = () => true; await tools.constructor({})',
plan(),
noTools,
new AbortController().signal,
),
).rejects.toThrow('Unknown or unavailable code mode tool: constructor');
const protoDispatch = vi.fn(async (name: string) => ({
callId: 'proto',
name,
status: 'success' as const,
output: 'proto ok',
}));
const proto = await executeCodeMode(
'return (await tools.__proto__({})).output',
plan('__proto__'),
runtime(protoDispatch),
new AbortController().signal,
);
expect(proto.value).toBe('proto ok');
});
it('supports immediate exit without running later statements', async () => {
const result = await executeCodeMode(
'text("before"); exit(); text("after")',
plan(),
runtime(async () => {
throw new Error('unused');
}),
new AbortController().signal,
);
expect(result.output).toBe('before');
});
it('supports cancellable one-shot timers', async () => {
const result = await executeCodeMode(
`const cancelled = setTimeout(() => text('cancelled'), 0);
clearTimeout(cancelled);
await new Promise((resolve) => setTimeout(resolve, 25));
text('timer done');
return [typeof setTimeout, typeof clearTimeout];`,
plan(),
runtime(async () => {
throw new Error('unused');
}),
new AbortController().signal,
);
expect(result.output).toBe('timer done');
expect(result.value).toEqual(['function', 'function']);
});
it('does not keep exec alive for an unawaited timer', async () => {
const result = await executeCodeMode(
`setTimeout(() => text('late'), 60_000);
text('done');`,
plan(),
runtime(async () => {
throw new Error('unused');
}),
new AbortController().signal,
{ timeoutMs: 25 },
);
expect(result.output).toBe('done');
});
it('does not charge timer wait time against the guest CPU budget', async () => {
const result = await executeCodeMode(
`await new Promise((resolve) => setTimeout(resolve, 100));
text('done');`,
plan(),
runtime(async () => {
throw new Error('unused');
}),
new AbortController().signal,
{ timeoutMs: 50 },
);
expect(result.output).toBe('done');
});
it('surfaces errors thrown by timer callbacks', async () => {
await expect(
executeCodeMode(
`await new Promise(() => {
setTimeout(() => { throw new Error('timer failure'); }, 0);
});`,
plan(),
runtime(async () => {
throw new Error('unused');
}),
new AbortController().signal,
),
).rejects.toThrow('timer failure');
});
it('bounds the number of live timers', async () => {
await expect(
executeCodeMode(
`for (let i = 0; i < 1025; i++) {
setTimeout(() => {}, 60_000);
}`,
plan(),
runtime(async () => {
throw new Error('unused');
}),
new AbortController().signal,
),
).rejects.toThrow('at most 1024 live timers');
});
it('does not expose Node, network, console, or WebAssembly', async () => {
const result = await executeCodeMode(
`return [
typeof process, typeof require, typeof fetch, typeof console,
typeof WebAssembly, typeof SharedArrayBuffer,
];`,
plan(),
runtime(async () => {
throw new Error('unused');
}),
new AbortController().signal,
);
expect(result.value).toEqual(Array(6).fill('undefined'));
await expect(
executeCodeMode(
'await import("node:fs")',
plan(),
runtime(async () => {
throw new Error('unused');
}),
new AbortController().signal,
),
).rejects.toThrow();
});
it('uses a fresh global context for every call', async () => {
const noTools = runtime(async () => {
throw new Error('unused');
});
await executeCodeMode(
'globalThis.persisted = 42',
plan(),
noTools,
new AbortController().signal,
);
const result = await executeCodeMode(
'return typeof persisted',
plan(),
noTools,
new AbortController().signal,
);
expect(result.value).toBe('undefined');
});
it('cancels unawaited nested calls when the program settles', async () => {
let aborted = false;
const result = await executeCodeMode(
'tools.wait({}); return "done";',
plan('wait'),
runtime(
(_name, _args, signal) =>
new Promise((_resolve, reject) => {
signal.addEventListener(
'abort',
() => {
aborted = true;
reject(signal.reason);
},
{ once: true },
);
}),
),
new AbortController().signal,
);
expect(result.value).toBe('done');
expect(aborted).toBe(true);
});
it('propagates parent cancellation', async () => {
const controller = new AbortController();
const execution = executeCodeMode(
'await tools.wait({})',
plan('wait'),
runtime(
(_name, _args, signal) =>
new Promise((_resolve, reject) => {
signal.addEventListener('abort', () => reject(signal.reason), {
once: true,
});
}),
),
controller.signal,
);
setTimeout(() => controller.abort(new Error('cancelled by test')), 100);
await expect(execution).rejects.toThrow('cancelled by test');
});
});
+315
View File
@@ -0,0 +1,315 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
import { existsSync } from 'node:fs';
import { createRequire } from 'node:module';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import type { CodeModeBindingPlan } from '../tools/code-mode.js';
import { resolveBundleDir } from '../utils/bundlePaths.js';
import {
CodeModeTurnTerminated,
type CodeModeToolResult,
type ToolCallRuntimeContext,
} from './tool-call-runtime.js';
import {
CODE_MODE_MAX_OUTPUT_CHARS,
CODE_MODE_MAX_SOURCE_CHARS,
CODE_MODE_TIMEOUT_MS,
encodeFrame,
FrameDecoder,
type CompleteMessage,
type CodeModeContentItem,
type HostMessage,
type ParentMessage,
} from './protocol.js';
const CODE_MODE_HOST_STARTUP_GRACE_MS = 5000;
export interface CodeModeExecutionResult {
output: string;
value?: unknown;
content?: CodeModeContentItem[];
}
export class CodeModeExecutionError extends Error {
constructor(
message: string,
readonly result: CodeModeExecutionResult,
) {
super(message);
this.name = 'CodeModeExecutionError';
}
}
function hostCommand(): { command: string; args: string[] } {
const currentFile = fileURLToPath(import.meta.url);
if (currentFile.endsWith('.ts')) {
const require = createRequire(import.meta.url);
return {
command: process.execPath,
args: [
'--import',
require.resolve('tsx'),
path.join(path.dirname(currentFile), 'host.ts'),
],
};
}
const sibling = path.join(path.dirname(currentFile), 'host.js');
if (existsSync(sibling)) {
return { command: process.execPath, args: [sibling] };
}
return {
command: process.execPath,
args: [path.join(resolveBundleDir(import.meta.url), 'codeModeHost.js')],
};
}
function childEnvironment(): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {};
for (const name of [
'PATH',
'SystemRoot',
'WINDIR',
'TMPDIR',
'TEMP',
'TMP',
]) {
const value = process.env[name];
if (value !== undefined) env[name] = value;
}
return env;
}
function terminate(child: ChildProcessWithoutNullStreams): void {
if (child.exitCode !== null || child.signalCode !== null) return;
child.kill('SIGKILL');
}
function boundedToolResult(
id: string,
result: CodeModeToolResult,
): CodeModeToolResult {
const bounded = {
...result,
output: result.output.slice(0, CODE_MODE_MAX_OUTPUT_CHARS),
};
try {
encodeFrame({ type: 'tool_result', id, ok: true, result: bounded });
return bounded;
} catch {
// Fall through to the text-only representation.
}
return { ...bounded, content: undefined };
}
function abortReason(signal: AbortSignal): unknown {
return signal.reason ?? new Error('Code mode execution was cancelled.');
}
export async function executeCodeMode(
source: string,
plan: CodeModeBindingPlan,
runtime: ToolCallRuntimeContext,
signal: AbortSignal,
options: {
timeoutMs?: number;
maxOutputChars?: number;
} = {},
): Promise<CodeModeExecutionResult> {
if (source.length > CODE_MODE_MAX_SOURCE_CHARS) {
throw new Error('JavaScript source exceeds the size limit.');
}
if (signal.aborted) throw abortReason(signal);
const command = hostCommand();
const timeoutMs = Math.min(
Math.max(1, options.timeoutMs ?? CODE_MODE_TIMEOUT_MS),
CODE_MODE_TIMEOUT_MS,
);
const maxOutputChars = Math.min(
Math.max(1, options.maxOutputChars ?? CODE_MODE_MAX_OUTPUT_CHARS),
CODE_MODE_MAX_OUTPUT_CHARS,
);
const child = spawn(command.command, command.args, {
stdio: ['pipe', 'pipe', 'pipe'],
env: childEnvironment(),
});
const byJsName = new Map(
plan.bindings.map((item) => [item.jsName, item.name]),
);
const decoder = new FrameDecoder<HostMessage>();
const nestedControllers = new Map<string, AbortController>();
let stderr = '';
let completed: CompleteMessage | undefined;
let terminating = false;
let protocolError: Error | undefined;
let wallTimer: ReturnType<typeof setTimeout> | undefined;
let wallRemainingMs = timeoutMs + CODE_MODE_HOST_STARTUP_GRACE_MS;
let wallDeadline = Date.now() + wallRemainingMs;
let wallPaused = false;
const send = (message: ParentMessage): void => {
if (!child.stdin.destroyed && !child.stdin.writableEnded) {
child.stdin.write(encodeFrame(message));
}
};
const cancelNested = (reason: unknown): void => {
for (const controller of nestedControllers.values())
controller.abort(reason);
nestedControllers.clear();
};
const onAbort = () => {
cancelNested(abortReason(signal));
terminate(child);
};
const onWallTimeout = () => {
protocolError = new Error(
`JavaScript execution timed out after ${timeoutMs}ms.`,
);
cancelNested(protocolError);
terminate(child);
};
const startWallTimer = (): void => {
wallDeadline = Date.now() + wallRemainingMs;
wallTimer = setTimeout(onWallTimeout, wallRemainingMs);
};
const pauseWallTimer = (): void => {
if (wallPaused) return;
wallRemainingMs = Math.max(1, wallDeadline - Date.now());
if (wallTimer) clearTimeout(wallTimer);
wallTimer = undefined;
wallPaused = true;
};
const resumeWallTimer = (): void => {
if (!wallPaused || completed || protocolError) return;
wallPaused = false;
startWallTimer();
};
signal.addEventListener('abort', onAbort, { once: true });
child.stderr.on('data', (chunk: Buffer) => {
if (stderr.length < 8192)
stderr += chunk.toString('utf8').slice(0, 8192 - stderr.length);
});
child.stdin.on('error', (error) => {
if (!completed && !protocolError) protocolError = error;
});
child.stdout.on('data', (chunk: Buffer) => {
try {
for (const message of decoder.push(chunk)) {
if (message.type === 'complete') {
completed = message;
cancelNested(
new Error(
'The exec program finished before this call was awaited.',
),
);
child.stdin.end();
continue;
}
if (message.type === 'error') {
protocolError = new CodeModeExecutionError(message.error, {
output: message.output ?? '',
...(message.content ? { content: message.content } : {}),
});
cancelNested(protocolError);
child.stdin.end();
continue;
}
if (terminating) continue;
const actualName = byJsName.get(message.name);
if (!actualName) {
send({
type: 'tool_result',
id: message.id,
ok: false,
error: `Tool "${message.name}" is not available in code mode.`,
});
continue;
}
const controller = new AbortController();
if (nestedControllers.size === 0) pauseWallTimer();
nestedControllers.set(message.id, controller);
void runtime
.dispatch(actualName, message.args, controller.signal)
.then((result) =>
send({
type: 'tool_result',
id: message.id,
ok: true,
result: boundedToolResult(message.id, result),
}),
)
.catch((error) => {
if (error instanceof CodeModeTurnTerminated) {
if (!terminating) {
terminating = true;
send({ type: 'terminate' });
}
return;
}
send({
type: 'tool_result',
id: message.id,
ok: false,
error: (error instanceof Error
? error.message
: String(error)
).slice(0, CODE_MODE_MAX_OUTPUT_CHARS),
});
})
.finally(() => {
nestedControllers.delete(message.id);
if (nestedControllers.size === 0) resumeWallTimer();
});
}
} catch (error) {
protocolError = error instanceof Error ? error : new Error(String(error));
terminate(child);
}
});
startWallTimer();
try {
send({
type: 'execute',
source,
tools: plan.bindings.map(({ name, jsName, description, deferred }) => ({
name,
jsName,
description,
deferred,
})),
timeoutMs,
maxOutputChars,
});
await new Promise<void>((resolve, reject) => {
child.once('error', reject);
child.once('close', () => resolve());
});
} finally {
if (wallTimer) clearTimeout(wallTimer);
signal.removeEventListener('abort', onAbort);
cancelNested(new Error('Code mode runtime stopped.'));
terminate(child);
}
if (signal.aborted) throw abortReason(signal);
if (protocolError) throw protocolError;
if (!completed) {
throw new Error(
`Code mode host exited without a result${stderr ? `: ${stderr}` : '.'}`,
);
}
return {
output: completed.output,
...(completed.value === undefined ? {} : { value: completed.value }),
...(completed.content === undefined ? {} : { content: completed.content }),
};
}
+581
View File
@@ -0,0 +1,581 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { boundCodeModeOutput } from './output.js';
import variant from '@jitl/quickjs-singlefile-mjs-release-sync';
import {
newQuickJSWASMModuleFromVariant,
type QuickJSContext,
type QuickJSDeferredPromise,
type QuickJSHandle,
} from 'quickjs-emscripten-core';
import {
CODE_MODE_MAX_MEDIA_BYTES,
CODE_MODE_MAX_MEDIA_ITEMS,
CODE_MODE_MAX_OUTPUT_CHARS,
CODE_MODE_MAX_SOURCE_CHARS,
CODE_MODE_TIMEOUT_MS,
encodeFrame,
FrameDecoder,
type CodeModeContentItem,
type ExecuteMessage,
type HostMessage,
type ParentMessage,
type ToolResultMessage,
} from './protocol.js';
const MEMORY_LIMIT_BYTES = 64 * 1024 * 1024;
const STACK_LIMIT_BYTES = 1024 * 1024;
const MAX_LIVE_TIMERS = 1024;
const MAX_TIMER_DELAY_MS = 2 ** 31 - 1;
function write(message: HostMessage): void {
process.stdout.write(encodeFrame(message));
}
function errorMessage(value: unknown): string {
let message: string;
if (value && typeof value === 'object') {
const error = value as {
name?: unknown;
message?: unknown;
stack?: unknown;
};
try {
message =
typeof error.message === 'string'
? error.message
: JSON.stringify(value) || String(value);
} catch {
message = String(value);
}
message =
typeof error.stack === 'string' ? `${message}\n${error.stack}` : message;
} else {
message = String(value);
}
return boundCodeModeOutput(message, CODE_MODE_MAX_OUTPUT_CHARS);
}
function jsonHandle(vm: QuickJSContext, value: unknown): QuickJSHandle {
const source = `JSON.parse(${JSON.stringify(JSON.stringify(value))})`;
const parsed = vm.evalCode(source, 'host-value.js');
if (parsed.error) {
const dumped = vm.dump(parsed.error);
parsed.error.dispose();
throw new Error(`Cannot marshal host value: ${errorMessage(dumped)}`);
}
return parsed.value;
}
function appendBounded(
output: string,
value: unknown,
maxOutputChars: number,
): string {
const text =
typeof value === 'string'
? value
: value === undefined
? 'undefined'
: JSON.stringify(value);
const next = output.length === 0 ? text : `\n${text}`;
return boundCodeModeOutput(output + next, maxOutputChars);
}
function boundedValue(value: unknown, maxChars: number): unknown {
let serialized: string | undefined;
try {
serialized = JSON.stringify(value);
} catch {
return '[Code mode value could not be serialized]';
}
if (serialized === undefined) return String(value).slice(0, maxChars);
if (serialized.length <= maxChars) return value;
const prefix = '[Code mode value truncated] ';
return (prefix + serialized).slice(0, maxChars);
}
interface NormalizedMedia {
type: 'image' | 'audio';
mimeType: string;
data: string;
bytes: number;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function normalizeBase64Media(
kind: 'image' | 'audio',
mimeTypeValue: unknown,
dataValue: unknown,
): NormalizedMedia | undefined {
if (typeof mimeTypeValue !== 'string' || typeof dataValue !== 'string') {
return undefined;
}
const mimeType = mimeTypeValue.toLowerCase();
const data = dataValue;
const hasPadding = data.endsWith('=');
if (
!mimeType.startsWith(`${kind}/`) ||
!/^[A-Za-z0-9+/]+={0,2}$/.test(data) ||
data.length % 4 === 1 ||
(hasPadding && data.length % 4 !== 0)
) {
return undefined;
}
const padding = data.endsWith('==') ? 2 : data.endsWith('=') ? 1 : 0;
return {
type: kind,
mimeType,
data,
bytes: Math.floor((data.length * 3) / 4) - padding,
};
}
function normalizeMedia(
kind: 'image' | 'audio',
value: unknown,
): NormalizedMedia {
if (kind === 'image' && isRecord(value) && value['type'] === 'image') {
const media = normalizeBase64Media(kind, value['mimeType'], value['data']);
if (media) return media;
throw new Error(
'image() expects Qwen MCP ImageContent with base64 data and an image MIME type.',
);
}
if (typeof value !== 'string') {
throw new Error(
kind === 'image'
? 'image() expects a base64 data URL or Qwen MCP ImageContent.'
: 'audio() expects a base64 data URL.',
);
}
const match =
/^data:([a-z0-9][a-z0-9.+-]*\/[a-z0-9][a-z0-9.+-]*);base64,([A-Za-z0-9+/]+={0,2})$/i.exec(
value,
);
const media = normalizeBase64Media(kind, match?.[1], match?.[2]);
if (!media) {
throw new Error(
`${kind}() expects a base64 data URL with a ${kind} MIME type.`,
);
}
return media;
}
function normalizeGeneratedImage(value: unknown): {
images: NormalizedMedia[];
outputHint: string;
} {
if (
!isRecord(value) ||
typeof value['callId'] !== 'string' ||
value['name'] !== 'image_gen' ||
value['status'] !== 'success' ||
typeof value['output'] !== 'string' ||
!Array.isArray(value['content']) ||
value['content'].length === 0
) {
throw new Error(
'generatedImage() expects the successful result returned by tools.image_gen().',
);
}
return {
images: value['content'].map((item) => normalizeMedia('image', item)),
outputHint: value['output'],
};
}
async function execute(message: ExecuteMessage): Promise<void> {
if (message.source.length > CODE_MODE_MAX_SOURCE_CHARS) {
throw new Error('JavaScript source exceeds the size limit.');
}
const timeoutMs = Math.min(
Math.max(1, message.timeoutMs || CODE_MODE_TIMEOUT_MS),
CODE_MODE_TIMEOUT_MS,
);
const maxOutputChars = Math.min(
Math.max(1, message.maxOutputChars || CODE_MODE_MAX_OUTPUT_CHARS),
CODE_MODE_MAX_OUTPUT_CHARS,
);
const quickjs = await newQuickJSWASMModuleFromVariant(variant);
const runtime = quickjs.newRuntime();
runtime.setMemoryLimit(MEMORY_LIMIT_BYTES);
runtime.setMaxStackSize(STACK_LIMIT_BYTES);
let remainingCpuMs = timeoutMs;
let cpuDeadline = Date.now() + remainingCpuMs;
let cpuPaused = false;
runtime.setInterruptHandler(() => !cpuPaused && Date.now() >= cpuDeadline);
const vm = runtime.newContext();
const pending = new Map<string, QuickJSDeferredPromise>();
const timers = new Map<
number,
{ handle: ReturnType<typeof setTimeout>; callback: QuickJSHandle }
>();
let output = '';
const content: CodeModeContentItem[] = [];
let mediaBytes = 0;
let mediaItems = 0;
const toolNames = message.tools.map((tool) => tool.jsName);
let nextId = 0;
let nextTimerId = 1;
const pauseCpuBudget = (): void => {
if (cpuPaused) return;
remainingCpuMs = Math.max(0, cpuDeadline - Date.now());
cpuPaused = true;
};
const resumeCpuBudget = (): void => {
if (!cpuPaused) return;
cpuDeadline = Date.now() + remainingCpuMs;
cpuPaused = false;
};
const executePendingJobs = (): void => {
resumeCpuBudget();
const jobs = runtime.executePendingJobs();
if (pending.size > 0 || timers.size > 0) pauseCpuBudget();
if (jobs.error) {
const dumped = jobs.error.context.dump(jobs.error);
jobs.error.dispose();
throw new Error(errorMessage(dumped));
}
};
const appendMedia = (items: NormalizedMedia[]): void => {
if (mediaItems + items.length > CODE_MODE_MAX_MEDIA_ITEMS) {
throw new Error(
`Code mode supports at most ${CODE_MODE_MAX_MEDIA_ITEMS} media outputs.`,
);
}
const addedBytes = items.reduce((sum, item) => sum + item.bytes, 0);
if (mediaBytes + addedBytes > CODE_MODE_MAX_MEDIA_BYTES) {
throw new Error(
`Code mode media output exceeds the ${CODE_MODE_MAX_MEDIA_BYTES / (1024 * 1024)}MB limit.`,
);
}
mediaBytes += addedBytes;
mediaItems += items.length;
for (const { bytes: _bytes, ...item } of items) content.push(item);
};
let resolveTermination!: () => void;
const termination = new Promise<undefined>((resolve) => {
resolveTermination = () => resolve(undefined);
});
let rejectHostFailure!: (reason: Error) => void;
const hostFailure = new Promise<never>((_resolve, reject) => {
rejectHostFailure = reject;
});
let hostFailed = false;
const failHost = (error: unknown): void => {
if (hostFailed) return;
hostFailed = true;
rejectHostFailure(
error instanceof Error ? error : new Error(errorMessage(error)),
);
};
const clearTimer = (timerId: number): void => {
const timer = timers.get(timerId);
if (!timer) return;
timers.delete(timerId);
clearTimeout(timer.handle);
timer.callback.dispose();
};
const fireTimer = (timerId: number): void => {
const timer = timers.get(timerId);
if (!timer) return;
timers.delete(timerId);
resumeCpuBudget();
try {
const called = vm.callFunction(timer.callback, vm.undefined);
if (called.error) {
const dumped = vm.dump(called.error);
called.error.dispose();
throw new Error(errorMessage(dumped));
}
called.value.dispose();
executePendingJobs();
} catch (error) {
failHost(error);
} finally {
timer.callback.dispose();
}
};
const settleTool = (toolResult: ToolResultMessage): void => {
const deferred = pending.get(toolResult.id);
if (!deferred) return;
pending.delete(toolResult.id);
try {
if (toolResult.ok && toolResult.result) {
const handle = jsonHandle(vm, toolResult.result);
deferred.resolve(handle);
handle.dispose();
} else {
const handle = vm.newError(
toolResult.error ?? 'Nested tool call failed.',
);
deferred.reject(handle);
handle.dispose();
}
executePendingJobs();
} finally {
deferred.dispose();
}
};
const decoder = new FrameDecoder<ParentMessage>();
const onData = (chunk: Buffer) => {
try {
for (const item of decoder.push(chunk)) {
if (item.type === 'terminate') {
resolveTermination();
break;
}
if (item.type === 'tool_result') settleTool(item);
}
} catch (error) {
failHost(error);
}
};
process.stdin.on('data', onData);
try {
const callTool = vm.newFunction('__callTool', (nameHandle, argsHandle) => {
const name = vm.getString(nameHandle);
if (!toolNames.includes(name)) {
throw new Error(`Tool "${name}" is not available in code mode.`);
}
const args = vm.dump(argsHandle);
if (args === null || typeof args !== 'object' || Array.isArray(args)) {
throw new Error(`tools.${name} expects one object argument.`);
}
const id = String(++nextId);
const deferred = vm.newPromise();
pending.set(id, deferred);
write({
type: 'tool_call',
id,
name,
args: args as Record<string, unknown>,
});
return deferred.handle;
});
vm.setProp(vm.global, '__callTool', callTool);
callTool.dispose();
const appendText = vm.newFunction('__appendText', (valueHandle) => {
output = appendBounded(output, vm.dump(valueHandle), maxOutputChars);
});
vm.setProp(vm.global, '__appendText', appendText);
appendText.dispose();
for (const kind of ['image', 'audio'] as const) {
const appendMediaHelper = vm.newFunction(
`__append_${kind}`,
(valueHandle) => {
appendMedia([normalizeMedia(kind, vm.dump(valueHandle))]);
},
);
vm.setProp(vm.global, `__append_${kind}`, appendMediaHelper);
appendMediaHelper.dispose();
}
const appendGeneratedImage = vm.newFunction(
'__appendGeneratedImage',
(valueHandle) => {
const generated = normalizeGeneratedImage(vm.dump(valueHandle));
appendMedia(generated.images);
if (generated.outputHint) {
output = appendBounded(output, generated.outputHint, maxOutputChars);
}
},
);
vm.setProp(vm.global, '__appendGeneratedImage', appendGeneratedImage);
appendGeneratedImage.dispose();
const setTimeoutHelper = vm.newFunction(
'__setTimeout',
(callbackHandle, delayHandle) => {
if (!callbackHandle || vm.typeof(callbackHandle) !== 'function') {
throw new TypeError('setTimeout expects a function callback.');
}
if (timers.size >= MAX_LIVE_TIMERS) {
throw new Error(
`Code mode supports at most ${MAX_LIVE_TIMERS} live timers.`,
);
}
const delayValue = delayHandle ? vm.getNumber(delayHandle) : 0;
const delayMs =
Number.isFinite(delayValue) && delayValue > 0
? Math.min(Math.trunc(delayValue), MAX_TIMER_DELAY_MS)
: 0;
const timerId = nextTimerId++;
const callback = callbackHandle.dup();
const handle = setTimeout(() => fireTimer(timerId), delayMs);
timers.set(timerId, { handle, callback });
return vm.newNumber(timerId);
},
);
vm.setProp(vm.global, '__setTimeout', setTimeoutHelper);
setTimeoutHelper.dispose();
const clearTimeoutHelper = vm.newFunction(
'__clearTimeout',
(timerIdHandle) => {
if (!timerIdHandle) return;
const timerId = vm.getNumber(timerIdHandle);
if (!Number.isFinite(timerId) || timerId <= 0) return;
clearTimer(Math.trunc(timerId));
},
);
vm.setProp(vm.global, '__clearTimeout', clearTimeoutHelper);
clearTimeoutHelper.dispose();
const toolEntries = toolNames
.map(
(name) =>
`[${JSON.stringify(name)}]: (args) => hostCall(${JSON.stringify(name)}, args)`,
)
.join(',');
const setup = vm.evalCode(
`(() => {
const hostCall = globalThis.__callTool;
const hostText = globalThis.__appendText;
const hostImage = globalThis.__append_image;
const hostAudio = globalThis.__append_audio;
const hostGeneratedImage = globalThis.__appendGeneratedImage;
const hostSetTimeout = globalThis.__setTimeout;
const hostClearTimeout = globalThis.__clearTimeout;
delete globalThis.__callTool;
delete globalThis.__appendText;
delete globalThis.__append_image;
delete globalThis.__append_audio;
delete globalThis.__appendGeneratedImage;
delete globalThis.__setTimeout;
delete globalThis.__clearTimeout;
const toolTarget = Object.freeze(Object.assign(Object.create(null), {${toolEntries}}));
const tools = Object.freeze(new Proxy(toolTarget, {
get(target, property) {
if (typeof property !== 'string' || property in target) return target[property];
throw new Error('Unknown or unavailable code mode tool: ' + property);
},
}));
const ALL_TOOLS = Object.freeze(${JSON.stringify(message.tools)}.map(Object.freeze));
const text = (value) => hostText(value);
const image = (value) => hostImage(value);
const audio = (value) => hostAudio(value);
const generatedImage = (value) => hostGeneratedImage(value);
const setTimeout = (callback, delayMs) => hostSetTimeout(callback, delayMs);
const clearTimeout = (timeoutId) => hostClearTimeout(timeoutId);
const exit = () => { throw Object.freeze({ __codeModeExit: true }); };
Object.defineProperties(globalThis, {
tools: { value: tools, writable: false, configurable: false },
ALL_TOOLS: { value: ALL_TOOLS, writable: false, configurable: false },
text: { value: text, writable: false, configurable: false },
image: { value: image, writable: false, configurable: false },
audio: { value: audio, writable: false, configurable: false },
generatedImage: { value: generatedImage, writable: false, configurable: false },
setTimeout: { value: setTimeout, writable: false, configurable: false },
clearTimeout: { value: clearTimeout, writable: false, configurable: false },
exit: { value: exit, writable: false, configurable: false },
});
for (const name of ['process','require','module','Buffer','console','fetch','XMLHttpRequest','WebSocket','setInterval','setImmediate','queueMicrotask','Atomics','SharedArrayBuffer','WebAssembly']) {
try { delete globalThis[name]; } catch {}
}
})()`,
'setup.js',
);
if (setup.error) {
const dumped = vm.dump(setup.error);
setup.error.dispose();
throw new Error(errorMessage(dumped));
}
setup.value.dispose();
const evaluated = vm.evalCode(
`
(async () => {
try {
return await (async () => {${message.source}\n})();
} catch (error) {
if (error && error.__codeModeExit === true) return undefined;
throw error;
}
})()
`,
'exec.js',
);
if (evaluated.error) {
const dumped = vm.dump(evaluated.error);
evaluated.error.dispose();
throw new Error(errorMessage(dumped));
}
const promiseHandle = evaluated.value;
const nativePromise = vm.resolvePromise(promiseHandle);
promiseHandle.dispose();
executePendingJobs();
const settled = await Promise.race([
nativePromise,
hostFailure,
termination,
]);
if (settled?.error) {
const dumped = vm.dump(settled.error);
settled.error.dispose();
throw new Error(errorMessage(dumped));
}
const value = settled ? vm.dump(settled.value) : undefined;
settled?.value.dispose();
write({
type: 'complete',
output,
...(value === undefined
? {}
: { value: boundedValue(value, maxOutputChars) }),
...(content.length === 0 ? {} : { content }),
});
} catch (error) {
write({
type: 'error',
error: error instanceof Error ? error.message : errorMessage(error),
output,
...(content.length === 0 ? {} : { content }),
});
} finally {
process.stdin.off('data', onData);
for (const timerId of [...timers.keys()]) clearTimer(timerId);
for (const deferred of pending.values()) deferred.dispose();
vm.dispose();
runtime.dispose();
}
}
const decoder = new FrameDecoder<ParentMessage>();
let started = false;
process.stdin.on('data', (chunk: Buffer) => {
try {
for (const message of decoder.push(chunk)) {
if (message.type !== 'execute' || started) continue;
started = true;
void execute(message)
.catch((error) => write({ type: 'error', error: errorMessage(error) }))
.finally(() => {
process.exitCode = 0;
process.stdin.pause();
});
}
} catch (error) {
write({ type: 'error', error: errorMessage(error) });
process.exitCode = 1;
process.stdin.pause();
}
});
+121
View File
@@ -0,0 +1,121 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import { boundCodeModeOutput, EXEC_MAX_OUTPUT_CHARS } from './output.js';
import { executeCodeMode, CodeModeExecutionError } from './host-client.js';
import { ExecTool } from '../tools/exec.js';
import { runWithToolCallRuntime } from './tool-call-runtime.js';
import { ToolRegistry } from '../tools/tool-registry.js';
import { makeFakeConfig } from '../test-utils/config.js';
const plan = { bindings: [], collisions: [] };
const runtime = {
parentCallId: 'exec-test',
dispatch: async () => {
throw new Error('nested failure');
},
};
async function exec(source: string) {
const config = makeFakeConfig();
const registry = new ToolRegistry(config);
config.getToolRegistry = () => registry;
return runWithToolCallRuntime(runtime, () =>
new ExecTool(config)
.build({ source })
.execute(new AbortController().signal),
);
}
describe('CodeMode output recovery', () => {
it('keeps prior text and media with a script failure', async () => {
try {
await executeCodeMode(
"text('COMPLETED'); image('data:image/png;base64,QUJD'); throw new Error('LATER');",
plan,
runtime,
new AbortController().signal,
);
throw new Error('expected rejection');
} catch (error) {
expect(error).toBeInstanceOf(CodeModeExecutionError);
const failure = error as CodeModeExecutionError;
expect(failure.result.output).toBe('COMPLETED');
expect(failure.result.content).toEqual([
{ type: 'image', mimeType: 'image/png', data: 'QUJD' },
]);
expect(failure.message).toContain('LATER');
}
});
it('keeps prior text when a nested tool rejects', async () => {
await expect(
executeCodeMode(
"text('DONE'); await tools.fail({});",
{
bindings: [
{
name: 'fail',
jsName: 'fail',
description: '',
parametersJsonSchema: {},
deferred: false,
},
],
collisions: [],
},
runtime,
new AbortController().signal,
),
).rejects.toMatchObject({
result: { output: 'DONE' },
message: expect.stringContaining('nested failure'),
});
});
it.each([false, true])(
'bounds complete model output inline (failure %s)',
async (failed) => {
const result = await exec(
`text('BEGIN' + 'x'.repeat(110000) + 'END'); ${failed ? "throw new Error('LATER_ERROR');" : ''}`,
);
expect(result.persistedOutputFiles).toEqual([]);
const text = (result.llmContent as Array<{ text: string }>)[0].text;
expect(text.length).toBeLessThanOrEqual(EXEC_MAX_OUTPUT_CHARS);
expect(text).toContain('BEGIN');
expect(text).toContain('END');
expect(text).toContain('code mode output truncated');
if (failed) {
expect(result.error?.message).toBe(text);
expect(text).toContain('Script error:');
expect(text).toContain('LATER_ERROR');
} else expect(result.error).toBeUndefined();
expect(text).not.toMatch(/persisted|saved to|\.output/);
},
);
it('bounds large error and return values with the same output budget', async () => {
const error = await exec(
"text('BEFORE'); throw new Error('FIRST' + 'e'.repeat(100000) + 'LAST');",
);
expect(error.error?.message.length).toBeLessThanOrEqual(
EXEC_MAX_OUTPUT_CHARS,
);
expect(error.error?.message).toContain('BEFORE');
expect(error.error?.message).toContain('LAST');
const value = await exec("text('BEFORE'); return 'v'.repeat(100000);");
expect(String(value.returnDisplay).length).toBeLessThanOrEqual(
EXEC_MAX_OUTPUT_CHARS,
);
});
it('respects small budgets at the marker boundary', () => {
for (let size = 1; size < 40; size++)
expect(
boundCodeModeOutput('x'.repeat(100), size).length,
).toBeLessThanOrEqual(size);
});
});
+16
View File
@@ -0,0 +1,16 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
export const EXEC_MAX_OUTPUT_CHARS = 32_000;
export function boundCodeModeOutput(text: string, maxChars: number): string {
if (text.length <= maxChars) return text;
const marker = '\n… code mode output truncated …\n';
if (maxChars <= marker.length) return text.slice(0, maxChars);
const head = Math.ceil((maxChars - marker.length) / 2);
const tail = maxChars - marker.length - head;
return text.slice(0, head) + marker + (tail > 0 ? text.slice(-tail) : '');
}
+145
View File
@@ -0,0 +1,145 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import type { CodeModeToolResult } from './tool-call-runtime.js';
export const CODE_MODE_MAX_CONTROL_FRAME_BYTES = 1_048_576;
export const CODE_MODE_MAX_FRAME_BYTES = 16 * 1024 * 1024;
export const CODE_MODE_MAX_MEDIA_BYTES = 10 * 1024 * 1024;
export const CODE_MODE_MAX_MEDIA_ITEMS = 16;
export const CODE_MODE_MAX_SOURCE_CHARS = 131_072;
export const CODE_MODE_MAX_OUTPUT_CHARS = 100_000;
export const CODE_MODE_TIMEOUT_MS = 30_000;
export interface CodeModeContentItem {
type: 'image' | 'audio';
mimeType: string;
data: string;
}
export interface ExecuteMessage {
type: 'execute';
source: string;
tools: Array<{
name: string;
jsName: string;
description: string;
deferred: boolean;
}>;
timeoutMs: number;
maxOutputChars: number;
}
export interface ToolResultMessage {
type: 'tool_result';
id: string;
ok: boolean;
result?: CodeModeToolResult;
error?: string;
}
export type ParentMessage =
| ExecuteMessage
| ToolResultMessage
| { type: 'terminate' };
export interface ToolCallMessage {
type: 'tool_call';
id: string;
name: string;
args: Record<string, unknown>;
}
export interface CompleteMessage {
type: 'complete';
output: string;
value?: unknown;
content?: CodeModeContentItem[];
}
export interface ErrorMessage {
type: 'error';
error: string;
output?: string;
content?: CodeModeContentItem[];
}
export type HostMessage = ToolCallMessage | CompleteMessage | ErrorMessage;
function hasBoundedImageContent(message: ParentMessage | HostMessage): boolean {
if (message.type !== 'tool_result') return false;
const content = message.result?.content;
if (
!Array.isArray(content) ||
content.length === 0 ||
content.length > CODE_MODE_MAX_MEDIA_ITEMS
) {
return false;
}
let decodedBytes = 0;
for (const item of content) {
if (
item?.type !== 'image' ||
typeof item.mimeType !== 'string' ||
!item.mimeType.toLowerCase().startsWith('image/') ||
typeof item.data !== 'string' ||
item.data.length === 0
) {
return false;
}
const padding = item.data.endsWith('==')
? 2
: item.data.endsWith('=')
? 1
: 0;
decodedBytes += Math.floor((item.data.length * 3) / 4) - padding;
if (decodedBytes > CODE_MODE_MAX_MEDIA_BYTES) return false;
}
return true;
}
function maxFrameBytes(message: ParentMessage | HostMessage): number {
if (message.type === 'complete' || message.type === 'error') {
return CODE_MODE_MAX_FRAME_BYTES;
}
if (hasBoundedImageContent(message)) return CODE_MODE_MAX_FRAME_BYTES;
return CODE_MODE_MAX_CONTROL_FRAME_BYTES;
}
export function encodeFrame(message: ParentMessage | HostMessage): Buffer {
const body = Buffer.from(JSON.stringify(message));
if (body.byteLength > maxFrameBytes(message)) {
throw new Error('Code mode protocol frame exceeds the size limit.');
}
const frame = Buffer.allocUnsafe(body.byteLength + 4);
frame.writeUInt32BE(body.byteLength, 0);
body.copy(frame, 4);
return frame;
}
export class FrameDecoder<T> {
private buffer = Buffer.alloc(0);
push(chunk: Buffer): T[] {
this.buffer = Buffer.concat([this.buffer, chunk]);
const messages: T[] = [];
while (this.buffer.byteLength >= 4) {
const length = this.buffer.readUInt32BE(0);
if (length > CODE_MODE_MAX_FRAME_BYTES) {
throw new Error('Code mode protocol frame exceeds the size limit.');
}
if (this.buffer.byteLength < length + 4) break;
const body = this.buffer.subarray(4, length + 4);
this.buffer = this.buffer.subarray(length + 4);
const message = JSON.parse(body.toString('utf8')) as T;
if (length > maxFrameBytes(message as ParentMessage | HostMessage)) {
throw new Error('Code mode protocol frame exceeds the size limit.');
}
messages.push(message);
}
return messages;
}
}
@@ -0,0 +1,908 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it, vi } from 'vitest';
import { ApprovalMode } from '../config/approval-mode.js';
import {
CoreToolScheduler,
type ToolCall,
type WaitingToolCall,
} from '../core/coreToolScheduler.js';
import { makeFakeConfig } from '../test-utils/config.js';
import { MockTool } from '../test-utils/mock-tool.js';
import { ExecTool } from '../tools/exec.js';
import { getToolCallRuntime } from './tool-call-runtime.js';
import { Kind, ToolConfirmationOutcome } from '../tools/tools.js';
import { ToolRegistry } from '../tools/tool-registry.js';
import { UpdateGoalTool } from '../goals/goal-tools.js';
import type { GoalRuntime } from '../goals/goal-runtime.js';
import { MessageBusType } from '../confirmation-bus/types.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
const TINY_PNG_BASE64 =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==';
describe('CodeModeOnly scheduler dispatch', () => {
it('keeps the Goal permit and termination metadata for a nested proposal', async () => {
const permit = {
goalId: 'goal-nested',
revision: 2,
turnId: 'turn-nested',
};
const recordTerminalProposal = vi.fn().mockReturnValue({
recorded: true,
readyForVerification: true,
});
const getGoalForWorker = vi.fn().mockResolvedValue({
goalId: permit.goalId,
revision: permit.revision,
evidenceCatalog: { entries: [{ uuid: 'evidence-1' }] },
});
const getSnapshotForPermit = vi.fn().mockReturnValue({
goal: { status: 'active' },
});
const goalTool = new UpdateGoalTool({
getGoalRuntime: () =>
({
getGoalForWorker,
getSnapshotForPermit,
recordTerminalProposal,
}) as unknown as GoalRuntime,
});
const onResult = vi.fn();
const proposal = {
status: 'complete' as const,
reason: 'Verified output',
evidenceRefs: ['evidence-1'],
};
const exec = new MockTool({
name: 'exec',
execute: async (_params, signal) => {
const value = await getToolCallRuntime()!.dispatch(
'update_goal',
proposal,
signal ?? new AbortController().signal,
onResult,
);
return { llmContent: JSON.stringify(value), returnDisplay: '' };
},
});
const config = makeFakeConfig({
codeModeOnly: true,
approvalMode: ApprovalMode.DEFAULT,
targetDir: '/tmp',
cwd: '/tmp',
});
const registry = new ToolRegistry(config);
vi.spyOn(config, 'getToolRegistry').mockReturnValue(registry);
registry.registerTool(exec);
registry.registerTool(goalTool);
const onAllToolCallsComplete = vi.fn();
const scheduler = new CoreToolScheduler({
config,
onAllToolCallsComplete,
onToolCallsUpdate: vi.fn(),
getPreferredEditor: () => undefined,
onEditorClose: vi.fn(),
});
await scheduler.schedule(
{
callId: 'exec-goal',
name: 'exec',
args: {},
isClientInitiated: false,
prompt_id: 'prompt-nested-goal',
goalContext: permit,
},
new AbortController().signal,
);
expect(
onAllToolCallsComplete.mock.calls.at(-1)?.[0]?.[0]?.response.error,
).toBeUndefined();
expect(getGoalForWorker).toHaveBeenCalledWith(permit);
expect(recordTerminalProposal).toHaveBeenCalledWith(permit, proposal);
expect(onResult).toHaveBeenCalledWith(
expect.objectContaining({
terminateTurn: true,
executionStatus: 'success',
}),
);
expect(onAllToolCallsComplete.mock.calls.at(-1)?.[0]?.[0]).toMatchObject({
status: 'success',
});
});
it('returns image() output to the model as inline media', async () => {
const config = makeFakeConfig({
codeModeOnly: true,
approvalMode: ApprovalMode.DEFAULT,
targetDir: '/tmp',
cwd: '/tmp',
});
const registry = new ToolRegistry(config);
vi.spyOn(config, 'getToolRegistry').mockReturnValue(registry);
registry.registerTool(new ExecTool(config));
const completed = vi.fn();
const scheduler = new CoreToolScheduler({
config,
onAllToolCallsComplete: async (calls) => completed(calls),
onToolCallsUpdate: vi.fn(),
getPreferredEditor: () => undefined,
onEditorClose: vi.fn(),
});
await scheduler.schedule(
{
callId: 'exec-image',
name: 'exec',
args: {
source: `text('caption'); image('data:image/png;base64,${TINY_PNG_BASE64}');`,
},
isClientInitiated: false,
prompt_id: 'prompt-image',
},
new AbortController().signal,
);
expect(completed).toHaveBeenCalledOnce();
const completedCall = completed.mock.calls[0]?.[0][0];
const functionResponse =
completedCall.response.responseParts[0].functionResponse;
expect(functionResponse?.response?.['output']).toBe('caption');
expect(functionResponse?.parts).toEqual([
{
inlineData: {
mimeType: 'image/png',
data: TINY_PNG_BASE64,
},
},
]);
expect(completedCall.response.resultDisplay).toBe('caption');
expect(JSON.stringify(functionResponse)).not.toContain('Media output:');
}, 10_000);
it.each(['image', 'audio'] as const)(
'preserves emitted %s and text when exec fails',
async (kind) => {
const config = makeFakeConfig({
codeModeOnly: true,
targetDir: '/tmp',
cwd: '/tmp',
});
vi.spyOn(config, 'getEffectiveInputModalities').mockReturnValue({
image: true,
});
const registry = new ToolRegistry(config);
vi.spyOn(config, 'getToolRegistry').mockReturnValue(registry);
registry.registerTool(new ExecTool(config));
const completed = vi.fn();
const scheduler = new CoreToolScheduler({
config,
onAllToolCallsComplete: async (calls) => completed(calls),
onToolCallsUpdate: vi.fn(),
getPreferredEditor: () => undefined,
onEditorClose: vi.fn(),
});
const mimeType = kind === 'image' ? 'image/png' : 'audio/wav';
const data = kind === 'image' ? TINY_PNG_BASE64 : 'QUJD';
await scheduler.schedule(
{
callId: 'exec-failure-media',
name: 'exec',
args: {
source: `text('DONE'); ${kind}('data:${mimeType};base64,${data}'); throw new Error('LATER');`,
},
isClientInitiated: false,
prompt_id: 'media-failure',
},
new AbortController().signal,
);
const call = completed.mock.calls[0]?.[0][0];
expect(call.status).toBe('error');
const response = call.response.responseParts[0].functionResponse;
expect(response.response.error).toContain('DONE');
expect(response.response.error).toContain('LATER');
expect(response.parts).toEqual([{ inlineData: { mimeType, data } }]);
},
10_000,
);
it('normalizes a nested MCP image for image(result.content[0])', async () => {
const config = makeFakeConfig({
codeModeOnly: true,
approvalMode: ApprovalMode.DEFAULT,
targetDir: '/tmp',
cwd: '/tmp',
});
const registry = new ToolRegistry(config);
vi.spyOn(config, 'getToolRegistry').mockReturnValue(registry);
registry.registerTool(new ExecTool(config));
registry.registerTool(
new MockTool({
name: 'mcp_screenshot',
kind: Kind.Read,
params: { type: 'object', additionalProperties: false },
execute: async () => ({
llmContent: [
{ text: 'MCP screenshot' },
{
inlineData: {
mimeType: 'image/png',
data: TINY_PNG_BASE64,
},
},
],
returnDisplay: 'MCP screenshot',
}),
}),
);
const completed = vi.fn();
const scheduler = new CoreToolScheduler({
config,
onAllToolCallsComplete: async (calls) => completed(calls),
onToolCallsUpdate: vi.fn(),
getPreferredEditor: () => undefined,
onEditorClose: vi.fn(),
});
await scheduler.schedule(
{
callId: 'exec-mcp-image',
name: 'exec',
args: {
source: `const result = await tools.mcp_screenshot({});
image(result.content[0]);`,
},
isClientInitiated: false,
prompt_id: 'prompt-mcp-image',
},
new AbortController().signal,
);
const functionResponse =
completed.mock.calls[0]?.[0][0].response.responseParts[0]
.functionResponse;
expect(functionResponse?.parts).toEqual([
{
inlineData: {
mimeType: 'image/png',
data: TINY_PNG_BASE64,
},
},
]);
}, 10_000);
it('passes the Qwen image_gen result to generatedImage()', async () => {
const config = makeFakeConfig({
codeModeOnly: true,
approvalMode: ApprovalMode.DEFAULT,
targetDir: '/tmp',
cwd: '/tmp',
});
const registry = new ToolRegistry(config);
vi.spyOn(config, 'getToolRegistry').mockReturnValue(registry);
registry.registerTool(new ExecTool(config));
registry.registerTool(
new MockTool({
name: 'image_gen',
kind: Kind.Read,
params: {
type: 'object',
properties: { prompt: { type: 'string' } },
required: ['prompt'],
},
execute: async () => ({
llmContent: [
{ text: 'Generated image saved to /tmp/generated.png.' },
{
inlineData: {
mimeType: 'image/png',
data: TINY_PNG_BASE64,
},
},
],
returnDisplay: 'Generated image saved to /tmp/generated.png.',
}),
}),
);
const completed = vi.fn();
const scheduler = new CoreToolScheduler({
config,
onAllToolCallsComplete: async (calls) => completed(calls),
onToolCallsUpdate: vi.fn(),
getPreferredEditor: () => undefined,
onEditorClose: vi.fn(),
});
await scheduler.schedule(
{
callId: 'exec-generated-image',
name: 'exec',
args: {
source: `const result = await tools.image_gen({ prompt: 'poster' });
generatedImage(result);`,
},
isClientInitiated: false,
prompt_id: 'prompt-generated-image',
},
new AbortController().signal,
);
const functionResponse =
completed.mock.calls[0]?.[0][0].response.responseParts[0]
.functionResponse;
expect(functionResponse?.response?.['output']).toBe(
'Generated image saved to /tmp/generated.png.',
);
expect(functionResponse?.parts).toEqual([
{
inlineData: {
mimeType: 'image/png',
data: TINY_PNG_BASE64,
},
},
]);
}, 10_000);
it('awaits a nested tool without self-queue deadlock and reports the real name', async () => {
const config = makeFakeConfig({
codeModeOnly: true,
approvalMode: ApprovalMode.DEFAULT,
targetDir: '/tmp',
cwd: '/tmp',
});
const registry = new ToolRegistry(config);
vi.spyOn(config, 'getToolRegistry').mockReturnValue(registry);
const nestedExecute = vi.fn().mockImplementation(async () => {
expect(getToolCallRuntime()).toBeUndefined();
return {
llmContent: 'nested output',
returnDisplay: 'nested output',
};
});
registry.registerTool(new ExecTool(config));
registry.registerTool(
new MockTool({
name: 'read_probe',
kind: Kind.Read,
params: { type: 'object', additionalProperties: false },
execute: nestedExecute,
}),
);
const updates: ToolCall[][] = [];
const completed = vi.fn();
const scheduler = new CoreToolScheduler({
config,
onAllToolCallsComplete: async (calls) => completed(calls),
onToolCallsUpdate: (calls) => updates.push(calls),
getPreferredEditor: () => undefined,
onEditorClose: vi.fn(),
});
await scheduler.schedule(
{
callId: 'exec-parent',
name: 'exec',
args: {
source:
'const result = await tools.read_probe({}); return result.output;',
},
isClientInitiated: false,
prompt_id: 'prompt-1',
},
new AbortController().signal,
);
expect(nestedExecute).toHaveBeenCalledOnce();
const nestedCall = updates
.flat()
.find((call) => call.request.name === 'read_probe');
expect(nestedCall?.request).toMatchObject({
callId: 'exec-parent:code:1',
parentCallId: 'exec-parent',
source: 'code_mode',
});
expect(completed).toHaveBeenCalledOnce();
expect(
completed.mock.calls[0]?.[0][0].response.responseParts[0].functionResponse
?.response?.['output'],
).toContain('nested output');
}, 10_000);
it('runs Promise.all reads in one scheduler batch', async () => {
const config = makeFakeConfig({
codeModeOnly: true,
approvalMode: ApprovalMode.DEFAULT,
targetDir: '/tmp',
cwd: '/tmp',
});
const registry = new ToolRegistry(config);
vi.spyOn(config, 'getToolRegistry').mockReturnValue(registry);
registry.registerTool(new ExecTool(config));
let started = 0;
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
registry.registerTool(
new MockTool({
name: 'parallel_read',
kind: Kind.Read,
params: { type: 'object' },
execute: async () => {
started++;
await gate;
return { llmContent: 'ok', returnDisplay: 'ok' };
},
}),
);
const scheduler = new CoreToolScheduler({
config,
onAllToolCallsComplete: vi.fn(),
onToolCallsUpdate: vi.fn(),
getPreferredEditor: () => undefined,
onEditorClose: vi.fn(),
});
const scheduled = scheduler.schedule(
{
callId: 'exec-parallel',
name: 'exec',
args: {
source:
'await Promise.all([tools.parallel_read({ id: 1 }), tools.parallel_read({ id: 2 })])',
},
isClientInitiated: false,
prompt_id: 'prompt-parallel',
},
new AbortController().signal,
);
try {
await vi.waitFor(() => expect(started).toBe(2), { timeout: 4000 });
} finally {
release();
}
await scheduled;
}, 10_000);
it('applies nested tool permission denial before execution', async () => {
const config = makeFakeConfig({
codeModeOnly: true,
approvalMode: ApprovalMode.DEFAULT,
targetDir: '/tmp',
cwd: '/tmp',
});
const registry = new ToolRegistry(config);
vi.spyOn(config, 'getToolRegistry').mockReturnValue(registry);
registry.registerTool(new ExecTool(config));
const nestedExecute = vi.fn();
registry.registerTool(
new MockTool({
name: 'denied_probe',
kind: Kind.Read,
params: { type: 'object' },
getDefaultPermission: async () => 'deny',
execute: nestedExecute,
}),
);
const completed = vi.fn();
const scheduler = new CoreToolScheduler({
config,
onAllToolCallsComplete: async (calls) => completed(calls),
onToolCallsUpdate: vi.fn(),
getPreferredEditor: () => undefined,
onEditorClose: vi.fn(),
});
await scheduler.schedule(
{
callId: 'exec-denied',
name: 'exec',
args: { source: 'await tools.denied_probe({})' },
isClientInitiated: false,
prompt_id: 'prompt-denied',
},
new AbortController().signal,
);
expect(nestedExecute).not.toHaveBeenCalled();
expect(completed.mock.calls[0]?.[0][0].status).toBe('error');
expect(
completed.mock.calls[0]?.[0][0].response.responseParts[0].functionResponse
?.response?.['error'],
).toContain('denied');
}, 10_000);
it('routes nested permission approval through the visible scheduler', async () => {
const config = makeFakeConfig({
codeModeOnly: true,
approvalMode: ApprovalMode.DEFAULT,
targetDir: '/tmp',
cwd: '/tmp',
interactive: true,
});
const registry = new ToolRegistry(config);
vi.spyOn(config, 'getToolRegistry').mockReturnValue(registry);
registry.registerTool(new ExecTool(config));
const execute = vi.fn().mockResolvedValue({
llmContent: 'approved',
returnDisplay: 'approved',
});
registry.registerTool(
new MockTool({
name: 'approval_probe',
kind: Kind.Other,
params: { type: 'object' },
getDefaultPermission: async () => 'ask',
getConfirmationDetails: async () => ({
type: 'info',
title: 'Approve nested tool',
prompt: 'Continue?',
onConfirm: vi.fn().mockResolvedValue(undefined),
}),
execute,
}),
);
const updates: ToolCall[][] = [];
const completed = vi.fn();
const scheduler = new CoreToolScheduler({
config,
onAllToolCallsComplete: async (calls) => completed(calls),
onToolCallsUpdate: (calls) => updates.push(calls),
getPreferredEditor: () => undefined,
onEditorClose: vi.fn(),
});
const controller = new AbortController();
const scheduled = scheduler.schedule(
{
callId: 'exec-approval',
name: 'exec',
args: { source: 'await tools.approval_probe({})' },
isClientInitiated: false,
prompt_id: 'prompt-approval',
},
controller.signal,
);
await vi.waitFor(
() => {
expect(
updates
.flat()
.some(
(call) =>
call.request.name === 'approval_probe' &&
call.status === 'awaiting_approval',
),
).toBe(true);
},
{ timeout: 4000 },
);
const waiting = updates
.flat()
.find(
(call) =>
call.request.name === 'approval_probe' &&
call.status === 'awaiting_approval',
) as WaitingToolCall;
await scheduler.handleConfirmationResponse(
waiting.request.callId,
waiting.confirmationDetails.onConfirm,
ToolConfirmationOutcome.ProceedOnce,
controller.signal,
);
await scheduled;
await vi.waitFor(() => expect(completed).toHaveBeenCalledOnce());
expect(execute).toHaveBeenCalledOnce();
expect(completed.mock.calls[0]?.[0][0].status).toBe('success');
}, 10_000);
it('runs nested hooks with the real tool name', async () => {
const config = makeFakeConfig({
codeModeOnly: true,
approvalMode: ApprovalMode.DEFAULT,
targetDir: '/tmp',
cwd: '/tmp',
});
const messageBus = {
request: vi
.fn()
.mockImplementation(async (request: { eventName: string }) => ({
type: MessageBusType.HOOK_EXECUTION_RESPONSE,
correlationId: `${request.eventName}-code-mode-test`,
success: true,
output: { decision: 'allow' },
})),
};
config.setMessageBus(messageBus as unknown as MessageBus);
const registry = new ToolRegistry(config);
vi.spyOn(config, 'getToolRegistry').mockReturnValue(registry);
registry.registerTool(new ExecTool(config));
registry.registerTool(
new MockTool({
name: 'hook_probe',
kind: Kind.Read,
params: { type: 'object' },
}),
);
const completed = vi.fn();
const scheduler = new CoreToolScheduler({
config,
onAllToolCallsComplete: async (calls) => completed(calls),
onToolCallsUpdate: vi.fn(),
getPreferredEditor: () => undefined,
onEditorClose: vi.fn(),
});
await scheduler.schedule(
{
callId: 'exec-hooks',
name: 'exec',
args: { source: 'await tools.hook_probe({})' },
isClientInitiated: false,
prompt_id: 'prompt-hooks',
},
new AbortController().signal,
);
await vi.waitFor(() => expect(completed).toHaveBeenCalledOnce());
for (const eventName of ['PreToolUse', 'PostToolUse']) {
expect(messageBus.request).toHaveBeenCalledWith(
expect.objectContaining({
eventName,
input: expect.objectContaining({ tool_name: 'hook_probe' }),
}),
MessageBusType.HOOK_EXECUTION_RESPONSE,
);
}
}, 10_000);
it('validates nested arguments before execution', async () => {
const config = makeFakeConfig({
codeModeOnly: true,
approvalMode: ApprovalMode.DEFAULT,
targetDir: '/tmp',
cwd: '/tmp',
});
const registry = new ToolRegistry(config);
vi.spyOn(config, 'getToolRegistry').mockReturnValue(registry);
registry.registerTool(new ExecTool(config));
const execute = vi.fn();
registry.registerTool(
new MockTool({
name: 'validated_probe',
kind: Kind.Read,
params: {
type: 'object',
properties: { value: { type: 'string' } },
required: ['value'],
additionalProperties: false,
},
execute,
}),
);
const completed = vi.fn();
const scheduler = new CoreToolScheduler({
config,
onAllToolCallsComplete: async (calls) => completed(calls),
onToolCallsUpdate: vi.fn(),
getPreferredEditor: () => undefined,
onEditorClose: vi.fn(),
});
await scheduler.schedule(
{
callId: 'exec-invalid',
name: 'exec',
args: { source: 'await tools.validated_probe({})' },
isClientInitiated: false,
prompt_id: 'prompt-invalid',
},
new AbortController().signal,
);
expect(execute).not.toHaveBeenCalled();
expect(completed.mock.calls[0]?.[0][0].status).toBe('error');
expect(JSON.stringify(completed.mock.calls[0]?.[0][0].response)).toContain(
'value',
);
}, 10_000);
it('propagates parent cancellation to a running nested tool', async () => {
const config = makeFakeConfig({
codeModeOnly: true,
approvalMode: ApprovalMode.DEFAULT,
targetDir: '/tmp',
cwd: '/tmp',
});
const registry = new ToolRegistry(config);
vi.spyOn(config, 'getToolRegistry').mockReturnValue(registry);
registry.registerTool(new ExecTool(config));
let nestedStarted!: () => void;
const started = new Promise<void>((resolve) => {
nestedStarted = resolve;
});
let nestedAborted = false;
registry.registerTool(
new MockTool({
name: 'wait_probe',
kind: Kind.Read,
canUpdateOutput: true,
execute: (_params, signal) =>
new Promise((_resolve, reject) => {
nestedStarted();
signal?.addEventListener(
'abort',
() => {
nestedAborted = true;
reject(signal.reason);
},
{ once: true },
);
}),
}),
);
const scheduler = new CoreToolScheduler({
config,
onAllToolCallsComplete: vi.fn(),
onToolCallsUpdate: vi.fn(),
getPreferredEditor: () => undefined,
onEditorClose: vi.fn(),
});
const controller = new AbortController();
const scheduled = scheduler.schedule(
{
callId: 'exec-cancel',
name: 'exec',
args: { source: 'await tools.wait_probe({})' },
isClientInitiated: false,
prompt_id: 'prompt-cancel',
},
controller.signal,
);
await started;
controller.abort(new Error('cancelled by test'));
await scheduled;
expect(nestedAborted).toBe(true);
}, 10_000);
it('rejects an ordinary direct call on the CodeModeOnly surface', async () => {
const config = makeFakeConfig({
codeModeOnly: true,
approvalMode: ApprovalMode.DEFAULT,
targetDir: '/tmp',
cwd: '/tmp',
});
const registry = new ToolRegistry(config);
vi.spyOn(config, 'getToolRegistry').mockReturnValue(registry);
const execute = vi.fn();
registry.registerTool(
new MockTool({ name: 'read_probe', kind: Kind.Read, execute }),
);
const completed = vi.fn();
const scheduler = new CoreToolScheduler({
config,
onAllToolCallsComplete: async (calls) => completed(calls),
onToolCallsUpdate: vi.fn(),
getPreferredEditor: () => undefined,
onEditorClose: vi.fn(),
});
await scheduler.schedule(
{
callId: 'direct-read',
name: 'read_probe',
args: {},
isClientInitiated: false,
prompt_id: 'prompt-direct-read',
},
new AbortController().signal,
);
expect(execute).not.toHaveBeenCalled();
await vi.waitFor(() => expect(completed).toHaveBeenCalledOnce());
const call = completed.mock.calls[0]?.[0][0];
expect(call?.status).toBe('error');
expect(JSON.stringify(call?.response.responseParts)).toContain(
'unavailable on this CodeModeOnly call surface',
);
});
it('enforces a restricted agent allowlist inside exec', async () => {
const config = makeFakeConfig({
codeModeOnly: true,
approvalMode: ApprovalMode.DEFAULT,
targetDir: '/tmp',
cwd: '/tmp',
});
const registry = new ToolRegistry(config);
vi.spyOn(config, 'getToolRegistry').mockReturnValue(registry);
registry.registerTool(new ExecTool(config));
const read = vi.fn().mockResolvedValue({
llmContent: 'read ok',
returnDisplay: 'read ok',
});
const write = vi.fn();
registry.registerTool(
new MockTool({ name: 'read_probe', kind: Kind.Read, execute: read }),
);
registry.registerTool(
new MockTool({ name: 'write_probe', kind: Kind.Edit, execute: write }),
);
const completed = vi.fn();
const scheduler = new CoreToolScheduler({
config,
onAllToolCallsComplete: async (calls) => completed(calls),
onToolCallsUpdate: vi.fn(),
getPreferredEditor: () => undefined,
onEditorClose: vi.fn(),
});
await expect(
(
scheduler as unknown as {
dispatchCodeModeTool: (
name: string,
args: Record<string, unknown>,
parent: {
callId: string;
name: string;
args: Record<string, unknown>;
isClientInitiated: boolean;
prompt_id: string;
codeModeAllowedToolNames: readonly string[];
},
signal: AbortSignal,
) => Promise<unknown>;
}
).dispatchCodeModeTool(
'write_probe',
{},
{
callId: 'exec-restricted-dispatch',
name: 'exec',
args: {},
isClientInitiated: false,
prompt_id: 'prompt-restricted',
codeModeAllowedToolNames: ['read_probe'],
},
new AbortController().signal,
),
).rejects.toThrow('not callable from exec');
await scheduler.schedule(
{
callId: 'exec-restricted',
name: 'exec',
args: {
source: `
try { await tools.write_probe({}); }
catch (error) { text(error.message); }
return (await tools.read_probe({})).output;
`,
},
isClientInitiated: false,
prompt_id: 'prompt-restricted',
codeModeAllowedToolNames: ['read_probe'],
},
new AbortController().signal,
);
expect(read).toHaveBeenCalledOnce();
expect(write).not.toHaveBeenCalled();
expect(completed.mock.calls[0]?.[0][0].status).toBe('success');
expect(
completed.mock.calls[0]?.[0][0].response.responseParts[0].functionResponse
?.response?.['output'],
).toContain('Unknown or unavailable code mode tool: write_probe');
}, 10_000);
});
@@ -0,0 +1,93 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { AsyncLocalStorage } from 'node:async_hooks';
import type { FunctionResponsePart, Part } from '@google/genai';
import type { ToolCallResponseInfo } from '../core/turn.js';
export class CodeModeTurnTerminated extends Error {}
export interface CodeModeImageContent {
type: 'image';
mimeType: string;
data: string;
}
export interface CodeModeToolResult {
callId: string;
name: string;
status: 'success';
output: string;
content?: CodeModeImageContent[];
}
export function extractCodeModeImageContent(
parts: Part[],
): CodeModeImageContent[] | undefined {
const content: CodeModeImageContent[] = [];
const appendImage = (part: Part | FunctionResponsePart): void => {
const inlineData = part.inlineData;
if (
inlineData?.mimeType?.toLowerCase().startsWith('image/') &&
inlineData.data
) {
content.push({
type: 'image',
mimeType: inlineData.mimeType,
data: inlineData.data,
});
}
};
for (const part of parts) {
appendImage(part);
for (const nested of part.functionResponse?.parts ?? []) {
appendImage(nested);
}
}
return content.length > 0 ? content : undefined;
}
export interface ToolCallRuntimeContext {
parentCallId: string;
allowedToolNames?: readonly string[];
dispatch(
name: string,
args: Record<string, unknown>,
signal: AbortSignal,
onResult?: (response: ToolCallResponseInfo) => void,
): Promise<CodeModeToolResult>;
}
const context = new AsyncLocalStorage<ToolCallRuntimeContext>();
export function runWithToolCallRuntime<T>(
runtime: ToolCallRuntimeContext,
fn: () => T,
): T {
return context.run(runtime, fn);
}
export function runWithoutToolCallRuntime<T>(fn: () => T): T {
return context.exit(fn);
}
export function getToolCallRuntime(): ToolCallRuntimeContext | undefined {
return context.getStore();
}
const sourceStorage = new AsyncLocalStorage<{ kind: 'code_mode' }>();
export function runWithToolCallSource<T>(
source: { kind: 'code_mode' },
callback: () => T,
): T {
return sourceStorage.run(source, callback);
}
export function getCurrentToolCallSource(): { kind: 'code_mode' } | undefined {
return sourceStorage.getStore();
}
+29
View File
@@ -92,6 +92,10 @@ import { recordStartupEvent } from '../utils/startupEventSink.js';
import { ToolRegistry, type ToolFactory } from '../tools/tool-registry.js';
import type { McpBudgetEvent } from '../tools/mcp-client-manager.js';
import { ToolNames } from '../tools/tool-names.js';
import {
ToolMode,
type ToolMode as ToolModeValue,
} from '../tools/code-mode.js';
import type {
ArtifactHostConfig,
ArtifactOssConfig,
@@ -981,6 +985,8 @@ export interface ConfigParameters {
* auto-approval and never affects registration (#10075).
*/
eagerTools?: string[];
/** Replace ordinary model-facing tools with the isolated exec bridge. */
codeModeOnly?: boolean;
/**
* Percentage of the model's context window used as the session-start
* budget for preloading deferred tools. When the combined estimated
@@ -2357,6 +2363,7 @@ export class Config {
private readonly visibleTools: ReadonlySet<string>;
private readonly eagerTools: readonly string[] | undefined;
private readonly toolSearchThreshold: number;
private readonly toolMode: ToolModeValue;
private readonly permissionsAllow: string[];
private readonly permissionsAsk: string[];
private readonly permissionsDeny: string[];
@@ -2943,6 +2950,10 @@ export class Config {
this.skipStartupContext = params.skipStartupContext ?? false;
this.bareMode = params.bareMode ?? false;
this.safeMode = params.safeMode ?? isSafeModeEnv();
this.toolMode =
params.codeModeOnly && !this.bareMode && !this.safeMode
? ToolMode.CodeModeOnly
: ToolMode.Direct;
if (this.safeMode) {
this.debugLogger.info(
'Safe mode active: hooks, extensions, skills, MCP servers, context files, rules disabled',
@@ -6569,6 +6580,14 @@ export class Config {
return this.toolSearchThreshold;
}
getCodeModeOnly(): boolean {
return this.toolMode === ToolMode.CodeModeOnly;
}
getToolMode(): ToolModeValue {
return this.toolMode;
}
/**
* Replace the in-process `disabledTools`
* snapshot with a fresh set sourced from the workspace settings.
@@ -9895,6 +9914,14 @@ export class Config {
}
};
const registerExecIfEnabled = async (): Promise<void> => {
if (this.getToolMode() !== ToolMode.CodeModeOnly) return;
await registerLazy(ToolNames.EXEC, async () => {
const { ExecTool } = await import('../tools/exec.js');
return new ExecTool(this);
});
};
if (this.getBareMode()) {
await registerLazy(ToolNames.READ_FILE, async () => {
const { ReadFileTool } = await import('../tools/read-file.js');
@@ -9914,6 +9941,7 @@ export class Config {
});
await registerGoalWorkerTools();
await registerStructuredOutputIfRequested();
await registerExecIfEnabled();
this.debugLogger.debug(
`ToolRegistry created: ${JSON.stringify(registry.getAllToolNames())} (${registry.getAllToolNames().length} tools)`,
);
@@ -9921,6 +9949,7 @@ export class Config {
}
// --- Core tools (always registered) ---
await registerExecIfEnabled();
await registerGoalWorkerTools();
await registerLazy(ToolNames.TOOL_SEARCH, async () => {
const { ToolSearchTool } = await import('../tools/tool-search.js');
+29
View File
@@ -584,6 +584,7 @@ describe('Gemini Client (client.ts)', () => {
getSystemPrompt: vi.fn().mockReturnValue(undefined),
getAppendSystemPrompt: vi.fn().mockReturnValue(undefined),
getOutputStyle: vi.fn().mockReturnValue(undefined),
getCodeModeOnly: vi.fn().mockReturnValue(false),
isTodoWriteEnabled: vi.fn().mockReturnValue(false),
getStaticSystemPrefix: vi.fn().mockReturnValue(undefined),
setStaticSystemPrefix: vi.fn(),
@@ -14050,6 +14051,7 @@ Other open files:
'headless',
undefined,
false,
false,
);
expect(mockContentGenerator.generateContent).toHaveBeenCalledWith(
expect.objectContaining({
@@ -14083,6 +14085,32 @@ Other open files:
'headless',
concise,
false,
false,
);
});
it('passes the CodeModeOnly flag to the core system prompt', async () => {
const contents = [{ role: 'user', parts: [{ text: 'hello' }] }];
const abortSignal = new AbortController().signal;
vi.mocked(getCoreSystemPrompt).mockClear();
vi.spyOn(client['config'], 'getCodeModeOnly').mockReturnValue(true);
await client.generateContent(
contents,
{},
abortSignal,
DEFAULT_QWEN_FLASH_MODEL,
);
expect(getCoreSystemPrompt).toHaveBeenCalledWith(
undefined,
'test-model',
undefined,
'headless',
undefined,
false,
true,
);
});
@@ -14116,6 +14144,7 @@ Other open files:
mode,
undefined,
false,
false,
);
},
);
+16 -7
View File
@@ -92,6 +92,7 @@ import { buildRelevantAutoMemoryPrompt } from '../memory/recall.js';
import { isManagedMemoryPath } from '../memory/paths.js';
import { isProjectSkillPath } from '../skills/skill-paths.js';
import { ToolNames } from '../tools/tool-names.js';
import { ToolMode } from '../tools/code-mode.js';
// Telemetry
import {
@@ -374,6 +375,7 @@ type MainSessionPromptConfig = Pick<
| 'getSystemPrompt'
| 'getModel'
| 'getOutputStyle'
| 'getCodeModeOnly'
| 'getExperimentalZedIntegration'
| 'getInputFormat'
| 'isInteractive'
@@ -402,6 +404,7 @@ export function getMainSessionBaseSystemPrompt(
// section, and a session must not be reminded of one it lacks.
resolveMainSessionOutputStyle(config),
config.isTodoWriteEnabled(),
config.getCodeModeOnly(),
);
}
@@ -1147,12 +1150,13 @@ export class LlmClient {
const toolRegistry = this.config.getToolRegistry();
await toolRegistry.warmAll();
const codeModeOnly = this.config.getToolMode?.() === ToolMode.CodeModeOnly;
const deferredSummary = toolRegistry.getDeferredToolSummary();
// Progressive MCP discovery registers tools after a resumed chat has
// already been constructed. Re-scan the live history here so historical
// MCP calls reveal their newly registered schemas before declarations are
// refreshed. setTools() is shared by interactive and headless refreshes.
if (!options.skipHistoryReveal) {
if (!codeModeOnly && !options.skipHistoryReveal) {
this.revealDeferredToolsReferencedInHistory(deferredSummary, () =>
this.getHistoryShallow(),
);
@@ -1710,6 +1714,7 @@ export class LlmClient {
* later stay deferred until the next session start.
*/
private preloadDeferredToolsWithinBudget(): void {
if (this.config.getToolMode?.() === ToolMode.CodeModeOnly) return;
const toolRegistry = this.config.getToolRegistry();
// Without ToolSearch, resolveDeferredToolsForReminder() eagerly
// reveals everything — there is no budget decision to make.
@@ -2246,6 +2251,8 @@ export class LlmClient {
// calling us.
const toolRegistry = this.config.getToolRegistry();
await profiler.time('tool_registry_warm', () => toolRegistry.warmAll());
const codeModeOnly =
this.config.getToolMode?.() === ToolMode.CodeModeOnly;
const deferredSummary = toolRegistry.getDeferredToolSummary();
// Resume support: when a transcript contains prior calls to a deferred
// tool, re-reveal that tool so `setTools()` below sends its schema in
@@ -2254,12 +2261,14 @@ export class LlmClient {
// call to foo_tool because the schema is absent. This must happen
// BEFORE `resolveDeferredToolsForReminder()` runs so the resumed tools
// are correctly filtered out of the startup reminder built below.
profiler.timeSync('resume_deferred_tool_reveal', () => {
this.revealDeferredToolsReferencedInHistory(
deferredSummary,
() => extraHistory,
);
});
if (!codeModeOnly) {
profiler.timeSync('resume_deferred_tool_reveal', () => {
this.revealDeferredToolsReferencedInHistory(
deferredSummary,
() => extraHistory,
);
});
}
// Budget-based deferred-tool preload runs BEFORE the deferred
// reminder is resolved so preloaded tools are filtered out of the
// startup reminder and never enter the announced set.
@@ -3628,6 +3628,58 @@ describe('CoreToolScheduler', () => {
expect(output).toBe(content);
});
it.each([false, true])(
'does not persist self-bounded exec output (failure %s)',
async (failed) => {
const content = 'BEGIN-' + 'x'.repeat(31_000) + '-END';
const execute = vi.fn().mockResolvedValue({
llmContent: content,
returnDisplay: content,
persistedOutputFiles: [],
...(failed
? {
error: { message: content, type: ToolErrorType.EXECUTION_FAILED },
}
: {}),
});
const toolsByName = new Map<string, MockTool>([
[
'exec',
new MockTool({
name: 'exec',
execute,
maxOutputChars: Number.POSITIVE_INFINITY,
}),
],
]);
const { scheduler, onAllToolCallsComplete } =
createSchedulerForLegacyToolTests({ toolsByName });
await scheduler.schedule(
[
{
callId: 'exec-inline',
name: 'exec',
args: {},
isClientInitiated: false,
prompt_id: 'p',
},
],
new AbortController().signal,
);
await vi.waitFor(() => expect(onAllToolCallsComplete).toHaveBeenCalled());
const calls = (
onAllToolCallsComplete.mock.calls as unknown as Array<[ToolCall[]]>
)[0][0];
const call = calls[0];
expect(call.status).toBe(failed ? 'error' : 'success');
if (!('response' in call)) throw new Error('missing completed response');
expect(call.response.persistedOutputFiles).toEqual([]);
const response =
call.response.responseParts[0].functionResponse?.response;
expect(response?.[failed ? 'error' : 'output']).toBe(content);
},
);
it('exempts read_mcp_resource from the persistence spill gate', async () => {
// The gate fires above DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD (25k) +
// GATE_HEADROOM (3k) ≈ 28k and is keyed by tool NAME (not maxOutputChars),
+281 -15
View File
@@ -209,6 +209,14 @@ import {
import { evaluateToolInvocationGuard } from './tool-invocation-guard.js';
import { goalTurnContext } from '../goals/goal-turn-context.js';
import { goalToolResultProvenance } from '../goals/goal-tool-result-provenance.js';
import {
extractCodeModeImageContent,
runWithoutToolCallRuntime,
runWithToolCallRuntime,
runWithToolCallSource,
type CodeModeToolResult,
} from '../code-mode/tool-call-runtime.js';
import { isCodeModeToolCallAllowed, ToolMode } from '../tools/code-mode.js';
const debugLogger = createDebugLogger('TOOL_SCHEDULER');
@@ -251,6 +259,7 @@ const GATE_HEADROOM = 3000;
// returns lifecycle policy that must remain inline. This gate runs before
// per-tool limits, so each requires an explicit exemption here.
const GATE_EXEMPT_TOOLS = new Set<string>([
ToolNames.EXEC,
ToolNames.READ_FILE,
ToolNames.READ_MCP_RESOURCE,
ToolNames.ENTER_PLAN_MODE,
@@ -365,6 +374,22 @@ function extractTextFromPartListUnion(c: PartListUnion): string {
return '';
}
function extractCodeModeToolOutput(parts: Part[]): string {
return parts
.map((part) => {
if (typeof part.text === 'string') return part.text;
const response = part.functionResponse?.response;
if (!response) return '';
const value =
response['output'] ?? response['error'] ?? response['content'] ?? '';
return typeof value === 'string' ? value : JSON.stringify(value);
})
.filter(Boolean)
.join('\n');
}
const CODE_MODE_BATCH_WINDOW_MS = 5;
const TOOL_SPAN_STATUS_PRE_HOOK_BLOCKED = 'Tool execution blocked by hook';
const TOOL_SPAN_STATUS_INVOCATION_GUARD_DENIED =
'Tool execution blocked by host policy';
@@ -1491,6 +1516,7 @@ function producerContentEqual(
}
export class CoreToolScheduler {
private readonly schedulerOptions: CoreToolSchedulerOptions;
private toolRegistry: ToolRegistry;
private toolCalls: ToolCall[] = [];
private outputUpdateHandler?: OutputUpdateHandler;
@@ -1562,8 +1588,28 @@ export class CoreToolScheduler {
resolve: () => void;
reject: (reason?: Error) => void;
}> = [];
private nestedToolScheduler?: CoreToolScheduler;
private nestedToolCalls: ToolCall[] = [];
private nestedSequence = 0;
private nestedFlushScheduled = false;
private nestedQueue: Array<{
request: ToolCallRequestInfo;
signal: AbortSignal;
resolve: (result: CodeModeToolResult) => void;
reject: (reason: unknown) => void;
onResult?: (response: ToolCallResponseInfo) => void;
}> = [];
private nestedResolvers = new Map<
string,
{
resolve: (result: CodeModeToolResult) => void;
reject: (reason: unknown) => void;
onResult?: (response: ToolCallResponseInfo) => void;
}
>();
constructor(options: CoreToolSchedulerOptions) {
this.schedulerOptions = options;
this.config = options.config;
this.toolRegistry = options.config.getToolRegistry();
this.outputUpdateHandler = options.outputUpdateHandler;
@@ -1577,6 +1623,128 @@ export class CoreToolScheduler {
this.hasSkillToolOverride = options.hasSkillTool;
}
private getOrCreateNestedToolScheduler(): CoreToolScheduler {
if (this.nestedToolScheduler) return this.nestedToolScheduler;
this.nestedToolScheduler = new CoreToolScheduler({
...this.schedulerOptions,
onAllToolCallsComplete: async (calls) => {
for (const call of calls) {
const resolver = this.nestedResolvers.get(call.request.callId);
if (!resolver) continue;
this.nestedResolvers.delete(call.request.callId);
try {
resolver.onResult?.(call.response);
} catch (error) {
resolver.reject(error);
continue;
}
if (call.status === 'success') {
const content = extractCodeModeImageContent(
call.response.responseParts,
);
resolver.resolve({
callId: call.request.callId,
name: call.request.name,
status: 'success',
output: extractCodeModeToolOutput(call.response.responseParts),
...(content ? { content } : {}),
});
} else {
resolver.reject(
call.response.error ??
new Error(
extractCodeModeToolOutput(call.response.responseParts) ||
`Tool ${call.request.name} failed.`,
),
);
}
}
},
onToolCallsUpdate: (calls) => {
this.nestedToolCalls = calls;
this.notifyToolCallsUpdate();
},
});
return this.nestedToolScheduler;
}
private dispatchCodeModeTool(
name: string,
args: Record<string, unknown>,
parent: ToolCallRequestInfo,
signal: AbortSignal,
onResult?: (response: ToolCallResponseInfo) => void,
): Promise<CodeModeToolResult> {
const allowedNames = parent.codeModeAllowedToolNames
? new Set(parent.codeModeAllowedToolNames)
: undefined;
if (!isCodeModeToolCallAllowed(name, 'code_mode', allowedNames)) {
return Promise.reject(
new Error(`Tool "${name}" is not callable from exec.`),
);
}
const callId = `${parent.callId}:code:${++this.nestedSequence}`;
const request: ToolCallRequestInfo = {
callId,
name,
args,
isClientInitiated: true,
prompt_id: parent.prompt_id,
response_id: parent.response_id,
parentCallId: parent.callId,
source: 'code_mode',
goalContext: parent.goalContext,
};
return new Promise<CodeModeToolResult>((resolve, reject) => {
this.nestedQueue.push({ request, signal, resolve, reject, onResult });
if (this.nestedFlushScheduled) return;
this.nestedFlushScheduled = true;
setTimeout(() => this.flushNestedQueue(), CODE_MODE_BATCH_WINDOW_MS);
});
}
private flushNestedQueue(): void {
this.nestedFlushScheduled = false;
const entries = this.nestedQueue.splice(0);
if (entries.length === 0) return;
const active = entries.filter((entry) => !entry.signal.aborted);
for (const entry of entries) {
if (entry.signal.aborted) entry.reject(entry.signal.reason);
}
if (active.length === 0) return;
const batchController = new AbortController();
const abortBatch = (event: Event) => {
const aborted = event.currentTarget as AbortSignal;
batchController.abort(aborted.reason);
};
for (const entry of active) {
entry.signal.addEventListener('abort', abortBatch, { once: true });
this.nestedResolvers.set(entry.request.callId, {
resolve: entry.resolve,
reject: entry.reject,
onResult: entry.onResult,
});
}
void runWithoutToolCallRuntime(() =>
this.getOrCreateNestedToolScheduler().schedule(
active.map((entry) => entry.request),
batchController.signal,
),
)
.catch((error) => {
for (const entry of active) {
this.nestedResolvers.delete(entry.request.callId);
entry.reject(error);
}
})
.finally(() => {
for (const entry of active) {
entry.signal.removeEventListener('abort', abortBatch);
}
});
}
private get memoryMonitor(): MemoryPressureMonitor | undefined {
return this.config.getMemoryPressureMonitor?.();
}
@@ -2546,6 +2714,25 @@ export class CoreToolScheduler {
}
const canonicalName = canonicalToolName(reqInfo.name);
if (
this.config.getToolMode?.() === ToolMode.CodeModeOnly &&
!isCodeModeToolCallAllowed(canonicalName, reqInfo.source ?? 'model')
) {
newToolCalls.push({
status: 'error',
request: reqInfo,
response: createErrorResponse(
reqInfo,
new Error(
`Tool "${reqInfo.name}" is unavailable on this CodeModeOnly call surface.`,
),
ToolErrorType.EXECUTION_DENIED,
'not_started',
),
durationMs: 0,
});
continue;
}
// Check if the tool is excluded due to permissions/environment restrictions
// This check should happen before registry lookup to provide a clear permission error
@@ -2826,6 +3013,10 @@ export class CoreToolScheduler {
'gen_ai.tool.call.id': reqInfo.providerCallId ?? reqInfo.callId,
call_id: reqInfo.callId,
tool_name: canonicalName,
...(reqInfo.parentCallId
? { 'tool.parent_call_id': reqInfo.parentCallId }
: {}),
...(reqInfo.source ? { 'tool.source': reqInfo.source } : {}),
},
toolCall.tool.description,
reqInfo.prompt_id,
@@ -3891,7 +4082,22 @@ export class CoreToolScheduler {
// Guard: if the tool is no longer awaiting approval (already handled by
// another confirmation path, e.g. IDE vs CLI race), skip to avoid double
// processing and potential re-execution.
if (!toolCall) return;
if (!toolCall) {
if (
this.nestedToolScheduler?.toolCalls.some(
(call) => call.request.callId === callId,
)
) {
return this.nestedToolScheduler.handleConfirmationResponse(
callId,
originalOnConfirm,
outcome,
signal,
payload,
);
}
return;
}
if (goalTurnContext.getStore() !== toolCall.request.goalContext) {
return runInRequestGoalContext(toolCall.request, () =>
@@ -4472,6 +4678,14 @@ export class CoreToolScheduler {
'gen_ai.tool.call.id': scheduledCall.request.providerCallId ?? callId,
call_id: callId, // legacy alias — see _schedule for context
tool_name: canonical, // legacy alias — see _schedule for context
...(scheduledCall.request.parentCallId
? {
'tool.parent_call_id': scheduledCall.request.parentCallId,
}
: {}),
...(scheduledCall.request.source
? { 'tool.source': scheduledCall.request.source }
: {}),
},
scheduledCall.tool.description,
scheduledCall.request.prompt_id,
@@ -5050,14 +5264,35 @@ export class CoreToolScheduler {
callId,
});
executionStatus = 'error';
return invocation.execute(
execSignal,
liveOutputCallback,
shellExecutionConfig,
setPidCallback,
setPromoteAbortControllerCallback,
canPromoteForegroundShell,
);
const execute = () =>
invocation.execute(
execSignal,
liveOutputCallback,
shellExecutionConfig,
setPidCallback,
setPromoteAbortControllerCallback,
canPromoteForegroundShell,
);
return scheduledCall.request.name === ToolNames.EXEC
? runWithToolCallRuntime(
{
parentCallId: callId,
allowedToolNames:
scheduledCall.request.codeModeAllowedToolNames,
dispatch: (name, args, nestedSignal, onResult) =>
this.dispatchCodeModeTool(
name,
args,
scheduledCall.request,
nestedSignal,
onResult,
),
},
execute,
)
: scheduledCall.request.source === 'code_mode'
? runWithToolCallSource({ kind: 'code_mode' }, execute)
: execute();
}),
);
} else {
@@ -5072,11 +5307,32 @@ export class CoreToolScheduler {
callId,
});
executionStatus = 'error';
return invocation.execute(
execSignal,
liveOutputCallback,
shellExecutionConfig,
);
const execute = () =>
invocation.execute(
execSignal,
liveOutputCallback,
shellExecutionConfig,
);
return scheduledCall.request.name === ToolNames.EXEC
? runWithToolCallRuntime(
{
parentCallId: callId,
allowedToolNames:
scheduledCall.request.codeModeAllowedToolNames,
dispatch: (name, args, nestedSignal, onResult) =>
this.dispatchCodeModeTool(
name,
args,
scheduledCall.request,
nestedSignal,
onResult,
),
},
execute,
)
: scheduledCall.request.source === 'code_mode'
? runWithToolCallSource({ kind: 'code_mode' }, execute)
: execute();
}),
);
}
@@ -5916,6 +6172,7 @@ export class CoreToolScheduler {
const errorGateThreshold =
this.config.getTruncateToolOutputThreshold() + GATE_HEADROOM;
if (
canonicalName !== ToolNames.EXEC &&
errorMessage.length > errorGateThreshold &&
!isAlreadyTruncated(errorMessage)
) {
@@ -6000,6 +6257,15 @@ export class CoreToolScheduler {
};
}
}
if (canonicalName === ToolNames.EXEC) {
const audioParts = normalizeParts(toolResult.llmContent).filter(
(part) => part.inlineData?.mimeType?.startsWith('audio/'),
);
const response = errorResponse.responseParts[0]?.functionResponse;
if (response && audioParts.length > 0) {
response.parts = [...(response.parts ?? []), ...audioParts];
}
}
if (
cancelAfterPostProcessing(
[...(toolResult.artifacts ?? []), ...(failureHookArtifacts ?? [])],
@@ -6477,7 +6743,7 @@ export class CoreToolScheduler {
return;
}
try {
this.onToolCallsUpdate([...this.toolCalls]);
this.onToolCallsUpdate([...this.toolCalls, ...this.nestedToolCalls]);
} catch (error) {
debugLogger.error(
`Tool call update observer failed: ${
+109
View File
@@ -833,12 +833,14 @@ describe('main-session style: reminder decision matches prompt section', () => {
const makeConfig = (opts: {
customPrompt?: string;
style?: OutputStyleDefinition;
codeModeOnly?: boolean;
interactive: boolean;
acp: boolean;
}) => ({
getSystemPrompt: () => opts.customPrompt,
getModel: () => 'test-model',
getOutputStyle: () => opts.style,
getCodeModeOnly: () => opts.codeModeOnly ?? false,
getExperimentalZedIntegration: () => opts.acp,
getInputFormat: () => InputFormat.TEXT,
isInteractive: () => opts.interactive,
@@ -939,6 +941,18 @@ describe('main-session style: reminder decision matches prompt section', () => {
);
});
it('forwards CodeModeOnly to the base prompt', () => {
const config = makeConfig({
interactive: true,
acp: false,
codeModeOnly: true,
});
expect(getMainSessionBaseSystemPrompt(config)).toContain(
"Ordinary tools exist only inside 'exec'",
);
});
it('forwards the todo_write setting to the base prompt', () => {
const config = {
...makeConfig({ interactive: false, acp: false }),
@@ -966,6 +980,7 @@ describe('main-session style: project trust gate', () => {
const makeConfig = (style: OutputStyleDefinition, trusted?: boolean) => ({
getSystemPrompt: () => undefined,
getModel: () => 'test-model',
getCodeModeOnly: () => false,
getOutputStyle: () => style,
getExperimentalZedIntegration: () => false,
getInputFormat: () => InputFormat.TEXT,
@@ -1197,6 +1212,100 @@ describe('Model-specific tool call formats', () => {
});
});
describe('CodeModeOnly tool guidance', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.stubEnv('QWEN_SYSTEM_MD', undefined);
vi.stubEnv('QWEN_SYSTEM_IDENTITY_MD', undefined);
vi.stubEnv('QWEN_WRITE_SYSTEM_MD', undefined);
vi.stubEnv('QWEN_CODE_TOOL_CALL_STYLE', undefined);
vi.stubEnv('SANDBOX', undefined);
vi.mocked(isGitRepository).mockReturnValue(false);
});
const codeModePrompt = (model = 'gpt-4') =>
getCoreSystemPrompt(
undefined,
model,
undefined,
'interactive',
undefined,
false,
true,
);
it('points the dedicated-tool guidance at tools.<name>', () => {
const prompt = codeModePrompt();
expect(prompt).toContain('as `tools.<name>(args)`');
expect(prompt).toContain('To read files use `tools.read_file`');
expect(prompt).not.toContain("To read files use 'read_file'");
});
it('does not advertise todo_write when it is disabled', () => {
expect(codeModePrompt()).not.toContain('todo_write');
});
it('advertises todo_write when it is enabled', () => {
const prompt = getCoreSystemPrompt(
undefined,
'gpt-4',
undefined,
'interactive',
undefined,
true,
true,
);
expect(prompt).toContain('todo_write');
expect(prompt).toContain('# Task Management');
});
it('replaces multi-tool parallelism with batching inside one exec program', () => {
const prompt = codeModePrompt();
expect(prompt).toContain('**Batch Into One Program:**');
expect(prompt).toContain('await them together with `Promise.all`');
expect(prompt).not.toContain(
'You can call multiple tools in a single response',
);
});
it('says the direct controls are not reachable through tools', () => {
const prompt = codeModePrompt();
expect(prompt).toContain(
'is called directly and is not reachable through `tools`',
);
});
it('uses the same exec examples for every model family', () => {
const execExample =
"[tool_call: exec with source: await tools.run_shell_command({ command: 'node server.js', is_background: true });]";
expect(codeModePrompt()).toContain(execExample);
expect(codeModePrompt('qwen3-coder-14b')).toContain(execExample);
expect(codeModePrompt('qwen3-coder-14b')).not.toContain(
'<function=run_shell_command>',
);
expect(codeModePrompt('qwen-vl-plus')).not.toContain(
'{"name": "run_shell_command"',
);
});
it('leaves Direct mode guidance and examples untouched', () => {
const prompt = getCoreSystemPrompt(undefined, 'gpt-4');
expect(prompt).toContain("To read files use 'read_file'");
expect(prompt).toContain(
'You can call multiple tools in a single response',
);
expect(prompt).toContain('[tool_call: run_shell_command for');
expect(prompt).not.toContain('tools.<name>(args)');
expect(prompt).not.toContain('**Batch Into One Program:**');
});
});
describe('getCustomSystemPrompt', () => {
it('should handle string custom instruction without user memory', () => {
const customInstruction =
+178 -24
View File
@@ -289,6 +289,67 @@ When requested to perform tasks like fixing bugs, adding features, refactoring,
`;
}
/**
* Builds the "Using Your Tools" section.
*
* In CodeModeOnly the model can only call `exec`, so the Direct-mode text would
* name a tool surface it does not have. The mechanics of `exec` itself live in
* its tool description; this section carries only policy.
*/
function getToolGuidanceSection(
questions: string,
codeModeOnly: boolean,
todoWriteEnabled: boolean,
): string {
const taskManagementToolGuidance = todoWriteEnabled
? `- **Task Management:** Use '${ToolNames.TODO_WRITE}' only when explicit tracking adds value. Keep plans concise, outcome-oriented, and current; do not create a todo list for simple or single-step work unless the user explicitly requests one.\n`
: '';
const directControls = todoWriteEnabled
? `'${ToolNames.TODO_WRITE}', '${ToolNames.AGENT}' and the other direct controls`
: `'${ToolNames.AGENT}' and the other direct controls`;
if (codeModeOnly) {
return `
## Using Your Tools
- **Calling Convention:** Ordinary tools exist only inside '${ToolNames.EXEC}', as \`tools.<name>(args)\`. Every other tool declared to you — ${directControls} — is called directly and is not reachable through \`tools\`.
- **Prefer Dedicated Tools:** Do NOT use \`tools.${ToolNames.SHELL}\` to run commands when a relevant dedicated tool is provided. Using dedicated tools allows the user to better understand and review your work. This is CRITICAL to assisting the user:
- To read files use \`tools.${ToolNames.READ_FILE}\` instead of cat, head, tail, or sed
- To edit files use \`tools.${ToolNames.EDIT}\` instead of sed or awk
- To create files use \`tools.${ToolNames.WRITE_FILE}\` instead of cat with heredoc or echo redirection
- To search for files use \`tools.${ToolNames.GLOB}\` instead of find or ls
- To search the content of files, use \`tools.${ToolNames.GREP}\` instead of grep or rg
- Reserve \`tools.${ToolNames.SHELL}\` exclusively for system commands and terminal operations that require shell execution. If you are unsure and there is a relevant dedicated tool, default to using the dedicated tool and only fallback on \`tools.${ToolNames.SHELL}\` for these if it is absolutely necessary.
- **Batch Into One Program:** Put independent calls in a single '${ToolNames.EXEC}' program and await them together with \`Promise.all\`. Sequence calls only when a later one needs a value an earlier one produced. Whatever you need to see must be passed to \`text()\` — a result you only assign is never reported back to you. A denied or failed call aborts the whole program, so keep a call that may be refused out of a batch you would then have to repeat.
- **Tool Fallback:** If a tool returns empty, unhelpful, or unexpected results, try an alternative tool that can accomplish the same goal before telling the user it cannot be done. Never give up after a single tool failure.
${taskManagementToolGuidance}- **File Paths:** Always use absolute paths when referring to files with tools like \`tools.${ToolNames.READ_FILE}\` or \`tools.${ToolNames.WRITE_FILE}\`. Relative paths are not supported. You must provide an absolute path.
- **Background Processes:** Use background execution with \`is_background: true\` for commands that are unlikely to stop on their own, e.g. \`node server.js\`. Do not append a trailing \`&\` when using the shell tool's managed background mode. If unsure, follow the active interaction mode's question guidance.
- **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. \`git rebase -i\`). Use non-interactive versions of commands (e.g. \`npm init -y\` instead of \`npm init\`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until canceled by the user.
- **Questions:** ${questions}
- **Subagent Delegation:** Use the '${ToolNames.AGENT}' tool with specialized agents when the task at hand matches the agent's description. Subagents are valuable for parallelizing independent queries or for protecting the main context window from excessive results, but they should not be used excessively when not needed. Importantly, avoid duplicating work that subagents are already doing - if you delegate research to a subagent, do not also perform the same searches yourself. A background subagent's result arrives as a task notification in a later turn; while waiting, do not read its transcript, predict its findings, or launch a replacement for the same task.
- **Codebase Search:** For simple, directed codebase searches (e.g. for a specific file/class/function) call \`tools.${ToolNames.GREP}\` or \`tools.${ToolNames.GLOB}\` yourself. For broader codebase exploration and deep research, use the '${ToolNames.AGENT}' tool with subagent_type=Explore. This is slower than calling them yourself, so use this only when a simple, directed search proves to be insufficient or when your task will clearly require more than 3 queries.
- **Respect Tool Decisions:** Tool permissions are enforced by the runtime. If a call is denied or canceled, respect that decision and do _not_ try the same action through another path. Retry only if the user subsequently requests that action.
`.trim();
}
return `
## Using Your Tools
- **Prefer Dedicated Tools:** Do NOT use the '${ToolNames.SHELL}' to run commands when a relevant dedicated tool is provided. Using dedicated tools allows the user to better understand and review your work. This is CRITICAL to assisting the user:
- To read files use '${ToolNames.READ_FILE}' instead of cat, head, tail, or sed
- To edit files use '${ToolNames.EDIT}' instead of sed or awk
- To create files use '${ToolNames.WRITE_FILE}' instead of cat with heredoc or echo redirection
- To search for files use '${ToolNames.GLOB}' instead of find or ls
- To search the content of files, use '${ToolNames.GREP}' instead of grep or rg
- Reserve using the '${ToolNames.SHELL}' exclusively for system commands and terminal operations that require shell execution. If you are unsure and there is a relevant dedicated tool, default to using the dedicated tool and only fallback on using the '${ToolNames.SHELL}' tool for these if it is absolutely necessary.
- **Tool Fallback:** If a tool returns empty, unhelpful, or unexpected results, try an alternative tool that can accomplish the same goal before telling the user it cannot be done. Never give up after a single tool failure.
${taskManagementToolGuidance}- **Parallel Tool Calls:** You can call multiple tools in a single response. If you intend to call multiple tools and there are no dependencies between them, make all independent tool calls in parallel. Maximize use of parallel tool calls where possible to increase efficiency. However, if some tool calls depend on previous calls to inform dependent values, do NOT call these tools in parallel and instead call them sequentially. For instance, if one operation must complete before another starts, run these operations sequentially instead.
- **File Paths:** Always use absolute paths when referring to files with tools like '${ToolNames.READ_FILE}' or '${ToolNames.WRITE_FILE}'. Relative paths are not supported. You must provide an absolute path.
- **Background Processes:** Use background execution with \`is_background: true\` for commands that are unlikely to stop on their own, e.g. \`node server.js\`. Do not append a trailing \`&\` when using the shell tool's managed background mode. If unsure, follow the active interaction mode's question guidance.
- **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. \`git rebase -i\`). Use non-interactive versions of commands (e.g. \`npm init -y\` instead of \`npm init\`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until canceled by the user.
- **Questions:** ${questions}
- **Subagent Delegation:** Use the '${ToolNames.AGENT}' tool with specialized agents when the task at hand matches the agent's description. Subagents are valuable for parallelizing independent queries or for protecting the main context window from excessive results, but they should not be used excessively when not needed. Importantly, avoid duplicating work that subagents are already doing - if you delegate research to a subagent, do not also perform the same searches yourself. A background subagent's result arrives as a task notification in a later turn; while waiting, do not read its transcript, predict its findings, or launch a replacement for the same task.
- **Codebase Search:** For simple, directed codebase searches (e.g. for a specific file/class/function) use the '${ToolNames.GREP}' or '${ToolNames.GLOB}' tools directly. For broader codebase exploration and deep research, use the '${ToolNames.AGENT}' tool with subagent_type=Explore. This is slower than using '${ToolNames.GREP}' or '${ToolNames.GLOB}' directly, so use this only when a simple, directed search proves to be insufficient or when your task will clearly require more than 3 queries.
- **Respect Tool Decisions:** Tool permissions are enforced by the runtime. If a call is denied or canceled, respect that decision and do _not_ try the same action through another path. Retry only if the user subsequently requests that action.
`.trim();
}
/**
* Builds the default (non-override) base system prompt.
*
@@ -298,8 +359,9 @@ When requested to perform tasks like fixing bugs, adding features, refactoring,
function buildDefaultBasePrompt(
interaction: { role: string; questions: string },
model: string | undefined,
outputStyle?: OutputStyleDefinition | null,
outputStyle: OutputStyleDefinition | null | undefined,
todoWriteEnabled = false,
codeModeOnly = false,
): string {
// A style with `keepCodingInstructions: false` drops exactly the
// software-engineering workflow section; every other section, including the
@@ -321,17 +383,18 @@ When you create a todo list:
`
: '';
const taskManagementToolGuidance = todoWriteEnabled
? `- **Task Management:** Use '${ToolNames.TODO_WRITE}' only when explicit tracking adds value. Keep plans concise, outcome-oriented, and current; do not create a todo list for simple or single-step work unless the user explicitly requests one.
`
: '';
// A QWEN_SYSTEM_IDENTITY_MD override is inserted verbatim, so the styled
// variant applies only to the default identity sentence — the override is
// distributor-owned wording we must not rewrite.
const coreIdentity =
resolveCoreIdentityOverride() ??
getDefaultCoreIdentitySentence(interaction.role, Boolean(outputStyle));
const toolGuidance = getToolGuidanceSection(
interaction.questions,
codeModeOnly,
todoWriteEnabled,
);
return `
${coreIdentity}
@@ -381,23 +444,7 @@ Final responses should be concise by default, but their shape and depth must mat
- **Explain Critical Commands:** Before executing commands with '${ToolNames.SHELL}' that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. Follow the active permission policy and do not assume an interactive confirmation dialog is available.
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Using Your Tools
- **Prefer Dedicated Tools:** Do NOT use the '${ToolNames.SHELL}' to run commands when a relevant dedicated tool is provided. Using dedicated tools allows the user to better understand and review your work. This is CRITICAL to assisting the user:
- To read files use '${ToolNames.READ_FILE}' instead of cat, head, tail, or sed
- To edit files use '${ToolNames.EDIT}' instead of sed or awk
- To create files use '${ToolNames.WRITE_FILE}' instead of cat with heredoc or echo redirection
- To search for files use '${ToolNames.GLOB}' instead of find or ls
- To search the content of files, use '${ToolNames.GREP}' instead of grep or rg
- Reserve using the '${ToolNames.SHELL}' exclusively for system commands and terminal operations that require shell execution. If you are unsure and there is a relevant dedicated tool, default to using the dedicated tool and only fallback on using the '${ToolNames.SHELL}' tool for these if it is absolutely necessary.
- **Tool Fallback:** If a tool returns empty, unhelpful, or unexpected results, try an alternative tool that can accomplish the same goal before telling the user it cannot be done. Never give up after a single tool failure.
${taskManagementToolGuidance}- **Parallel Tool Calls:** You can call multiple tools in a single response. If you intend to call multiple tools and there are no dependencies between them, make all independent tool calls in parallel. Maximize use of parallel tool calls where possible to increase efficiency. However, if some tool calls depend on previous calls to inform dependent values, do NOT call these tools in parallel and instead call them sequentially. For instance, if one operation must complete before another starts, run these operations sequentially instead.
- **File Paths:** Always use absolute paths when referring to files with tools like '${ToolNames.READ_FILE}' or '${ToolNames.WRITE_FILE}'. Relative paths are not supported. You must provide an absolute path.
- **Background Processes:** Use background execution with \`is_background: true\` for commands that are unlikely to stop on their own, e.g. \`node server.js\`. Do not append a trailing \`&\` when using the shell tool's managed background mode. If unsure, follow the active interaction mode's question guidance.
- **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. \`git rebase -i\`). Use non-interactive versions of commands (e.g. \`npm init -y\` instead of \`npm init\`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until canceled by the user.
- **Questions:** ${interaction.questions}
- **Subagent Delegation:** Use the '${ToolNames.AGENT}' tool with specialized agents when the task at hand matches the agent's description. Subagents are valuable for parallelizing independent queries or for protecting the main context window from excessive results, but they should not be used excessively when not needed. Importantly, avoid duplicating work that subagents are already doing - if you delegate research to a subagent, do not also perform the same searches yourself. A background subagent's result arrives as a task notification in a later turn; while waiting, do not read its transcript, predict its findings, or launch a replacement for the same task.
- **Codebase Search:** For simple, directed codebase searches (e.g. for a specific file/class/function) use the '${ToolNames.GREP}' or '${ToolNames.GLOB}' tools directly. For broader codebase exploration and deep research, use the '${ToolNames.AGENT}' tool with subagent_type=Explore. This is slower than using '${ToolNames.GREP}' or '${ToolNames.GLOB}' directly, so use this only when a simple, directed search proves to be insufficient or when your task will clearly require more than 3 queries.
- **Respect Tool Decisions:** Tool permissions are enforced by the runtime. If a call is denied or canceled, respect that decision and do _not_ try the same action through another path. Retry only if the user subsequently requests that action.
${toolGuidance}
## Interaction Details
- **Help Command:** The user can use '/help' to display help information.
@@ -456,7 +503,7 @@ ${(function () {
return '';
})()}
${getToolCallExamples(model || '')}
${codeModeOnly ? codeModeToolCallExamples : getToolCallExamples(model || '')}
# Final Reminder
Your core function is efficient and safe assistance. Balance conciseness with the crucial need for clarity, especially regarding safety and potential system modifications. Always prioritize user control and project conventions. Never make assumptions about the contents of files; instead use '${ToolNames.READ_FILE}' to ensure you aren't making broad assumptions. Finally, you are an agent - please keep going until the user's query is completely resolved.
@@ -512,6 +559,8 @@ export function resolveMainSessionOutputStyle(config: {
* @param outputStyle - Active output style, layered onto the base prompt.
* Ignored when `QWEN_SYSTEM_MD` replaces the base prompt (see below).
* @param todoWriteEnabled - Whether the default prompt may advertise Todo.
* @param codeModeOnly - Whether the model's only callable ordinary tool is
* `exec`, which selects the code-mode tool guidance and worked examples.
*/
export function getCoreSystemPrompt(
userMemory?: string,
@@ -520,6 +569,7 @@ export function getCoreSystemPrompt(
interactionMode: SystemPromptInteractionMode = 'interactive',
outputStyle?: OutputStyleDefinition | null,
todoWriteEnabled = false,
codeModeOnly = false,
): string {
const effectiveOutputStyle = resolveEffectiveOutputStyle(
outputStyle,
@@ -565,6 +615,7 @@ export function getCoreSystemPrompt(
model,
effectiveOutputStyle,
todoWriteEnabled,
codeModeOnly,
);
// if QWEN_WRITE_SYSTEM_MD is set (and not 0|false), write base system prompt to file
@@ -592,6 +643,7 @@ export function getCoreSystemPrompt(
model,
undefined,
todoWriteEnabled,
codeModeOnly,
),
);
}
@@ -847,6 +899,108 @@ To help you check their settings, I can read their contents. Which one would you
</example>
`.trim();
/**
* Worked examples for CodeModeOnly.
*
* The model-family variants below teach a direct tool-call syntax; in
* CodeModeOnly the only surface is one `exec` tool whose argument is a
* JavaScript program, so the syntax differences they exist for do not apply.
*/
const codeModeToolCallExamples = `
# Examples (Illustrating Tone and Workflow)
<example>
user: 1 + 2
model: 3
</example>
<example>
user: is 13 a prime number?
model: true
</example>
<example>
user: start the server implemented in server.js
model: [tool_call: ${ToolNames.EXEC} with source: await tools.${ToolNames.SHELL}({ command: 'node server.js', is_background: true });]
</example>
<example>
user: Refactor the auth logic in src/auth.py to use the requests library instead of urllib.
model: Okay, I can refactor 'src/auth.py'.
First I'll check for a test safety net and confirm 'requests' is a dependency. Those two lookups are independent, so they go in one program.
[tool_call: ${ToolNames.EXEC} with source:
const [tests, requirements] = await Promise.all([
tools.${ToolNames.GLOB}({ pattern: 'tests/test_auth.py' }),
tools.${ToolNames.READ_FILE}({ file_path: '/path/to/requirements.txt' }),
]);
text(tests.output);
text(requirements.output);
]
(After analysis)
Great, 'tests/test_auth.py' covers the core authentication logic and 'requests' is available. With this safety net in place, I can safely plan the refactoring.
Here's the plan:
1. Replace the 'urllib' calls with 'requests'.
2. Add proper 'try...except' error handling for the new network calls.
3. Remove the old 'urllib' import.
4. Run the project's linter and tests to verify the changes.
[tool_call: ${ToolNames.EXEC} with source: await tools.${ToolNames.EDIT}({ file_path: '/path/to/src/auth.py', old_string: '(exact old code)', new_string: '(new code)' });]
Refactoring complete. Running verification...
[tool_call: ${ToolNames.EXEC} with source:
const checks = await tools.${ToolNames.SHELL}({ command: 'ruff check src/auth.py && pytest' });
text(checks.output);
]
(After verification passes)
All checks passed. This is a stable checkpoint.
Would you like me to write a commit message and commit these changes?
</example>
<example>
user: Delete the temp directory.
model: I can run \`rm -rf /path/to/project/temp\`. This will permanently delete the directory and all its contents.
</example>
<example>
user: Write tests for someFile.ts
model:
Okay, I can write those tests. First I'll read the source together with an existing test to pick up the local conventions.
[tool_call: ${ToolNames.EXEC} with source:
const [source, existing] = await Promise.all([
tools.${ToolNames.READ_FILE}({ file_path: '/path/to/someFile.ts' }),
tools.${ToolNames.READ_FILE}({ file_path: '/path/to/existingTest.test.ts' }),
]);
text(source.output);
text(existing.output);
]
(After reviewing both)
Now I'll check whether the intended test file already exists. A failed call aborts the whole program, so this probe stays on its own.
[tool_call: ${ToolNames.EXEC} with source: text((await tools.${ToolNames.READ_FILE}({ file_path: '/path/to/someFile.test.ts' })).output);]
(After ${ToolNames.READ_FILE} reports that /path/to/someFile.test.ts does not exist)
[tool_call: ${ToolNames.EXEC} with source: await tools.${ToolNames.WRITE_FILE}({ file_path: '/path/to/someFile.test.ts', content: '(test code content)' });]
I've written the tests. Now I'll run the project's test command to verify them.
[tool_call: ${ToolNames.EXEC} with source:
const result = await tools.${ToolNames.SHELL}({ command: 'npm run test' });
text(result.output);
]
(After verification passes)
All checks passed. This is a stable checkpoint.
</example>
<example>
user: Where are all the 'app.config' files in this project? I need to check their settings.
model:
[tool_call: ${ToolNames.EXEC} with source:
const found = await tools.${ToolNames.GLOB}({ pattern: './**/app.config' });
text(found.output);
]
(Assuming the glob call returns paths like ['/path/to/moduleA/app.config', '/path/to/moduleB/app.config'])
I found the following 'app.config' files:
- /path/to/moduleA/app.config
- /path/to/moduleB/app.config
To help you check their settings, I can read their contents. Which one would you like to start with, or should I read all of them?
</example>
`.trim();
const qwenCoderToolCallExamples = `
# Examples (Illustrating Tone and Workflow)
<example>
+5
View File
@@ -149,6 +149,11 @@ export interface ToolCallRequestInfo {
/** Set to true when the LLM response was truncated due to max_tokens. */
wasOutputTruncated?: boolean;
goalContext?: GoalTurnPermit;
/** Parent model tool call for a programmatically dispatched child call. */
parentCallId?: string;
source?: 'model' | 'code_mode';
/** Exact tools an exec call may dispatch for a restricted agent. */
codeModeAllowedToolNames?: readonly string[];
}
export type ToolExecutionStatus =
+15
View File
@@ -757,6 +757,21 @@ export { MessageBus } from './confirmation-bus/message-bus.js';
export { makeFakeConfig } from './test-utils/config.js';
export * from './test-utils/index.js';
export {
extractCodeModeImageContent,
getToolCallRuntime,
runWithoutToolCallRuntime,
runWithToolCallRuntime,
type CodeModeImageContent,
type CodeModeToolResult,
type ToolCallRuntimeContext,
} from './code-mode/tool-call-runtime.js';
export {
getToolExposure,
isCodeModeToolCallAllowed,
ToolMode,
type ToolExposure,
} from './tools/code-mode.js';
// ============================================================================
// Hooks
@@ -47,6 +47,11 @@ import type {
* Covers all built-in tools plus common aliases (including Claude Code's "Bash").
*/
export const TOOL_NAME_ALIASES: Readonly<Record<string, string>> = {
// Exec tool
exec: 'exec',
Exec: 'exec',
ExecTool: 'exec',
// Shell tool
run_shell_command: 'run_shell_command',
Shell: 'run_shell_command',
+5
View File
@@ -178,6 +178,8 @@ export class ToolCallEvent implements BaseTelemetryEvent {
'event.name': 'tool_call';
'event.timestamp': string;
call_id?: string;
parent_call_id?: string;
source?: 'model' | 'code_mode';
function_name: string;
function_args: Record<string, unknown>;
duration_ms: number;
@@ -199,6 +201,9 @@ export class ToolCallEvent implements BaseTelemetryEvent {
this['event.name'] = 'tool_call';
this['event.timestamp'] = new Date().toISOString();
this.call_id = call.request.callId;
if (call.request.parentCallId)
this.parent_call_id = call.request.parentCallId;
if (call.request.source) this.source = call.request.source;
this.function_name = call.request.name;
// structured_output args ARE the user's final structured payload (the
// command's actual answer, already emitted in stdout `result` /
@@ -7,7 +7,7 @@ IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO N
**Usage notes**:
- The command argument is required.
- You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will timeout after 120000ms (2 minutes).
- You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will timeout after 120000ms (2 minutes). For longer commands, use \`is_background: true\` and observe the managed task instead of passing a larger timeout.
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
- Avoid using run_shell_command with the \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands:
@@ -69,7 +69,7 @@ IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO N
**Usage notes**:
- The command argument is required.
- You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will timeout after 120000ms (2 minutes).
- You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will timeout after 120000ms (2 minutes). For longer commands, use \`is_background: true\` and observe the managed task instead of passing a larger timeout.
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
- Avoid using run_shell_command with the \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands:
+258
View File
@@ -0,0 +1,258 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import type { FunctionDeclaration } from '@google/genai';
import {
CODE_MODE_MAX_MEDIA_BYTES,
CODE_MODE_MAX_MEDIA_ITEMS,
} from '../code-mode/protocol.js';
import type { AnyDeclarativeTool } from './tools.js';
import { ToolNames } from './tool-names.js';
export type ToolExposure =
| 'exec'
| 'direct-only'
| 'code-mode-callable'
| 'hidden';
export const ToolMode = {
Direct: 'direct',
CodeModeOnly: 'code_mode_only',
} as const;
export type ToolMode = (typeof ToolMode)[keyof typeof ToolMode];
const HIDDEN_TOOLS = new Set<string>([ToolNames.TOOL_SEARCH, 'tool_call']);
const DIRECT_ONLY_TOOLS = new Set<string>([
ToolNames.AGENT,
ToolNames.ASK_USER_QUESTION,
ToolNames.STRUCTURED_OUTPUT,
ToolNames.ENTER_PLAN_MODE,
ToolNames.EXIT_PLAN_MODE,
ToolNames.SEND_MESSAGE,
ToolNames.ENTER_WORKTREE,
ToolNames.EXIT_WORKTREE,
ToolNames.CREATE_SUB_SESSION,
'speak_to_user',
'wait_threads',
]);
export function getToolExposure(name: string): ToolExposure {
if (name === ToolNames.EXEC) return 'exec';
if (HIDDEN_TOOLS.has(name)) return 'hidden';
if (DIRECT_ONLY_TOOLS.has(name)) return 'direct-only';
return 'code-mode-callable';
}
export function isCodeModeToolCallAllowed(
name: string,
source: 'model' | 'code_mode',
allowedNames?: ReadonlySet<string>,
): boolean {
const exposure = getToolExposure(name);
return source === 'code_mode'
? exposure === 'code-mode-callable' &&
(!allowedNames || allowedNames.has(name))
: exposure === 'exec' || exposure === 'direct-only';
}
export interface CodeModeToolBinding {
name: string;
jsName: string;
description: string;
parametersJsonSchema: unknown;
deferred: boolean;
}
export interface CodeModeBindingPlan {
bindings: CodeModeToolBinding[];
collisions: Array<{
jsName: string;
kept: string;
omitted: string;
}>;
}
export function normalizeCodeModeToolName(name: string): string {
const normalized = name.replace(/[^A-Za-z0-9_$]/g, '_');
if (normalized.length === 0) return '_';
return /^[A-Za-z_$]/.test(normalized) ? normalized : `_${normalized}`;
}
export function planCodeModeBindings(
tools: AnyDeclarativeTool[],
isDeferred: (name: string) => boolean,
allowedNames?: ReadonlySet<string>,
): CodeModeBindingPlan {
const bindings: CodeModeToolBinding[] = [];
const collisions: CodeModeBindingPlan['collisions'] = [];
const claimed = new Map<string, string>();
const sorted = [...tools].sort((a, b) =>
a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
);
for (const tool of sorted) {
if (getToolExposure(tool.name) !== 'code-mode-callable') continue;
if (allowedNames && !allowedNames.has(tool.name)) continue;
const jsName = normalizeCodeModeToolName(tool.name);
const kept = claimed.get(jsName);
if (kept) {
collisions.push({ jsName, kept, omitted: tool.name });
continue;
}
claimed.set(jsName, tool.name);
bindings.push({
name: tool.name,
jsName,
description: tool.description,
parametersJsonSchema: tool.schema.parametersJsonSchema,
deferred: isDeferred(tool.name),
});
}
return { bindings, collisions };
}
function asRecord(value: unknown): Record<string, unknown> | undefined {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function literal(value: unknown): string {
return value === undefined ? 'undefined' : JSON.stringify(value);
}
function schemaToType(schema: unknown): string {
const node = asRecord(schema);
if (!node) return 'unknown';
const variants = Array.isArray(node['anyOf'])
? node['anyOf']
: Array.isArray(node['oneOf'])
? node['oneOf']
: undefined;
if (variants) {
return variants.map(schemaToType).join(' | ');
}
if (Array.isArray(node['enum'])) {
return node['enum'].map(literal).join(' | ') || 'unknown';
}
if ('const' in node) return literal(node['const']);
const type = node['type'];
if (Array.isArray(type)) {
return type
.map((item) => schemaToType({ ...node, type: item }))
.join(' | ');
}
if (type === 'string') return 'string';
if (type === 'number' || type === 'integer') {
const bounds: string[] = [];
if (typeof node['minimum'] === 'number') {
bounds.push(`min ${node['minimum']}`);
}
if (typeof node['maximum'] === 'number') {
bounds.push(`max ${node['maximum']}`);
}
return bounds.length > 0 ? `number /* ${bounds.join(', ')} */` : 'number';
}
if (type === 'boolean') return 'boolean';
if (type === 'null') return 'null';
if (type === 'array') return `Array<${schemaToType(node['items'])}>`;
if (type === 'object' || node['properties']) {
const properties = asRecord(node['properties']) ?? {};
const required = new Set(
Array.isArray(node['required'])
? node['required'].filter(
(item): item is string => typeof item === 'string',
)
: [],
);
const fields = Object.keys(properties)
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
.map(
(name) =>
`${JSON.stringify(name)}${required.has(name) ? '' : '?'}: ${schemaToType(properties[name])}`,
);
if (
node['additionalProperties'] &&
node['additionalProperties'] !== false
) {
fields.push(
`[key: string]: ${schemaToType(node['additionalProperties'])}`,
);
}
return `{ ${fields.join('; ')} }`;
}
return 'unknown';
}
function describeBinding(binding: CodeModeToolBinding): string {
// A deferred tool keeps its registry semantics, but CodeModeOnly hides
// tool_search and never surfaces nested calls as history functionCalls, so
// nothing can reveal it later: this description is its only chance to carry
// a schema.
const params = schemaToType(binding.parametersJsonSchema);
return `tools.${binding.jsName}(args: ${params}): Promise<CodeModeToolResult>;`;
}
export function buildExecDescription(plan: CodeModeBindingPlan): string {
const allTools = plan.bindings.map(
({ name, jsName, description, deferred }) => ({
name,
jsName,
description,
deferred,
}),
);
const collisionText = plan.collisions
.map(
({ jsName, kept, omitted }) =>
`- ${omitted} is omitted because it collides with ${kept} as tools.${jsName}.`,
)
.join('\n');
const declarations = plan.bindings.map(describeBinding).join('\n');
return `Execute JavaScript in a fresh isolated runtime and wait for it to finish.
Use async/await and call registered tools through tools.<name>(args). Calls use the same validation, permissions, approvals, hooks, telemetry, cancellation, concurrency, and output limits as direct tool calls. Tool calls can be composed with Promise.all. Await every tool promise; unawaited calls are cancelled when the script finishes. The exec tool, direct control tools, tool_search, and tool_call are not callable through tools.
Results from skill, update_goal, and capture_screen_context are automatically retained in the exec response; text() is not required to preserve their context. Read loaded skill instructions before taking dependent actions in a later exec call. A terminal update_goal result ends the script and prevents further tool calls.
Available globals:
- tools: the code-mode-callable tool functions declared below.
- ALL_TOOLS: frozen metadata for every function in tools.
- text(value): append bounded text output. Non-string values are JSON-stringified when possible.
- image(imageUrlOrItem: string | ImageContent): append an image from a base64 data URL or Qwen MCP ImageContent. To return a nested MCP image, pass an item such as image(result.content[0]).
- audio(dataUrl: string): append audio from a base64 data URL with an audio MIME type.
- generatedImage(result: CodeModeToolResult): append the image and saved-path hint returned by Qwen's built-in image_gen tool, for example generatedImage(await tools.image_gen({ prompt: '...' })).
- setTimeout(callback: () => void, delayMs?: number): schedule a callback to run later and return a timeout id. Pending timeouts do not keep exec alive by themselves; await an explicit promise if you need to wait for one.
- clearTimeout(timeoutId?: number): cancel a timeout created by setTimeout.
- exit(): finish immediately.
Media helpers share a limit of ${CODE_MODE_MAX_MEDIA_ITEMS} items and ${CODE_MODE_MAX_MEDIA_BYTES / (1024 * 1024)} MiB of decoded base64 data per exec call.
There is no Node.js, process, require, filesystem, network, import, console, WebAssembly, Atomics, or persistent state. Static and dynamic imports are unsupported. Every exec call gets a new runtime.
type ImageContent = { type: 'image'; data: string; mimeType: string };
type CodeModeToolResult = { callId: string; name: string; status: 'success'; output: string; content?: ImageContent[] };
${declarations || '// No ordinary tools are available in this context.'}
const ALL_TOOLS = ${JSON.stringify(allTools)} as const;
${collisionText ? `\nName collisions:\n${collisionText}` : ''}`;
}
export function buildExecDeclaration(
execTool: AnyDeclarativeTool,
plan: CodeModeBindingPlan,
): FunctionDeclaration {
return {
...execTool.schema,
description: buildExecDescription(plan),
};
}
@@ -0,0 +1,79 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it, vi } from 'vitest';
import { ApprovalMode, Config } from '../config/config.js';
import {
CoreToolScheduler,
type CompletedToolCall,
} from '../core/coreToolScheduler.js';
import { MockTool } from '../test-utils/mock-tool.js';
import { ExecTool } from './exec.js';
import { ToolNames } from './tool-names.js';
import { ToolRegistry } from './tool-registry.js';
describe('exec context output budget', () => {
it('delivers a complete long skill body through the real scheduler', async () => {
const config = new Config({
cwd: '/tmp',
targetDir: '/tmp',
model: 'test',
embeddingModel: 'test',
sandbox: undefined,
debugMode: false,
userMemory: '',
memoryFileCount: 0,
approvalMode: ApprovalMode.YOLO,
codeModeOnly: true,
disableAllHooks: true,
truncateToolOutputThreshold: 200_000,
toolOutputBatchBudget: 200_000,
});
const registry = new ToolRegistry(config);
vi.spyOn(config, 'getToolRegistry').mockReturnValue(registry);
const body = 'Skill instructions that must reach the model. '.repeat(1400);
registry.registerTool(
new MockTool({
name: ToolNames.SKILL,
execute: async () => ({
llmContent: [{ text: body }],
returnDisplay: 'Loaded skill',
modelOverride: 'skill-model',
}),
}),
);
registry.registerTool(new ExecTool(config));
const completed = vi
.fn<(calls: CompletedToolCall[]) => Promise<void>>()
.mockResolvedValue(undefined);
const scheduler = new CoreToolScheduler({
config,
onAllToolCallsComplete: completed,
getPreferredEditor: () => undefined,
onEditorClose: () => {},
});
await scheduler.schedule(
{
callId: 'exec-long-skill',
name: ToolNames.EXEC,
args: { source: 'await tools.skill({ skill: "long-skill" });' },
isClientInitiated: false,
prompt_id: 'long-skill-prompt',
},
new AbortController().signal,
);
await vi.waitFor(() => expect(completed).toHaveBeenCalledOnce());
const result = completed.mock.calls[0][0][0];
expect(result.status).toBe('success');
expect(result.response.modelOverride).toBe('skill-model');
const output =
result.response.responseParts[0].functionResponse?.response?.['output'];
expect(typeof output).toBe('string');
expect(JSON.parse((output as string).split('\n')[0]).toolResults).toEqual([
{ name: ToolNames.SKILL, args: { skill: 'long-skill' }, output: body },
]);
});
});
@@ -0,0 +1,245 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it, vi } from 'vitest';
import type { Part } from '@google/genai';
import { ApprovalMode, Config } from '../config/config.js';
import { convertToFunctionResponse } from '../core/coreToolScheduler.js';
import type { ToolCallResponseInfo } from '../core/turn.js';
import { MockTool } from '../test-utils/mock-tool.js';
import { ExecTool } from './exec.js';
import { ToolRegistry } from './tool-registry.js';
import { ToolNames } from './tool-names.js';
import {
runWithToolCallRuntime,
type ToolCallRuntimeContext,
} from '../code-mode/tool-call-runtime.js';
import type { ToolResult } from './tools.js';
function setup(
name: string,
result: Partial<ToolCallResponseInfo>,
body: string | Part[],
) {
const config = new Config({
cwd: '/tmp',
targetDir: '/tmp',
model: 'test',
embeddingModel: 'test',
sandbox: undefined,
debugMode: false,
userMemory: '',
memoryFileCount: 0,
approvalMode: ApprovalMode.DEFAULT,
codeModeOnly: true,
});
const registry = new ToolRegistry(config);
vi.spyOn(config, 'getToolRegistry').mockReturnValue(registry);
registry.registerTool(new MockTool({ name }));
registry.registerTool(new MockTool({ name: ToolNames.WRITE_FILE }));
const dispatch = vi.fn<ToolCallRuntimeContext['dispatch']>(
async (name, _args, _signal, onResult) => {
const response: ToolCallResponseInfo = {
callId: 'nested',
responseParts: convertToFunctionResponse(name, 'nested', body),
resultDisplay: '',
error: undefined,
errorType: undefined,
...result,
};
onResult?.(response);
if (response.error) throw response.error;
return {
callId: 'nested',
name,
status: 'success',
output:
typeof body === 'string' ? body : 'Untrusted app screenshot context',
content:
typeof body === 'string'
? undefined
: [
{
type: 'image',
mimeType: 'image/png',
data: 'A'.repeat(100_000),
},
],
};
},
);
const run = (source: string, signal = new AbortController().signal) =>
runWithToolCallRuntime({ parentCallId: 'exec-test', dispatch }, () =>
new ExecTool(config).build({ source }).execute(signal),
);
return { run, dispatch, registry };
}
function payload(result: ToolResult) {
const body = result.llmContent;
const text = typeof body === 'string' ? body : (body as Part[])[0].text!;
return JSON.parse(text.split('\n')[0]) as {
status: string;
outputs: string[];
error?: string;
toolResults: Array<{
name: string;
args: Record<string, unknown>;
output: string;
}>;
};
}
describe('exec context tool results', () => {
it.each([false, true])(
'retains skill instructions without text(), including later failure: %s',
async (fail) => {
const body = 'skill instruction '.repeat(4000);
const { run } = setup(
ToolNames.SKILL,
{ modelOverride: 'skill-model' },
body,
);
const result = await run(
`await tools.skill({skill: 'test'}); ${fail ? 'throw new Error("later failure");' : ''}`,
);
expect(payload(result).toolResults).toEqual([
{ name: 'skill', args: { skill: 'test' }, output: body },
]);
expect(JSON.stringify(result.llmContent)).toContain(
fail ? 'later failure' : 'completed successfully',
);
expect(result.modelOverride).toBe('skill-model');
expect(result.error).toBeUndefined();
},
);
it('preserves an explicit undefined model override', async () => {
const { run } = setup(
ToolNames.SKILL,
{ modelOverride: undefined },
'skill body',
);
const result = await run("await tools.skill({skill: 'test'});");
expect(Object.hasOwn(result, 'modelOverride')).toBe(true);
expect(result.modelOverride).toBeUndefined();
});
it('retains screenshot images as native parts outside JavaScript text output', async () => {
const data = 'A'.repeat(100_000);
const { run } = setup('capture_screen_context', {}, [
{ text: 'Untrusted app screenshot context' },
{ inlineData: { mimeType: 'image/png', data } },
]);
const result = await run('text(await tools.capture_screen_context({}));');
expect(payload(result).toolResults[0].output).toBe(
'Untrusted app screenshot context',
);
expect(result.llmContent).toEqual([
expect.objectContaining({ text: expect.not.stringContaining(data) }),
{ inlineData: { mimeType: 'image/png', data } },
]);
expect(JSON.stringify(result.llmContent)).toContain(
'Untrusted app screenshot context',
);
});
it.each([
'await tools.update_goal({}); await tools.write_file({});',
'text("before goal"); try { await tools.update_goal({}); } catch {} text("after goal"); await tools.write_file({});',
'await Promise.all([tools.update_goal({}), tools.write_file({})]);',
])('ends the script after a terminal goal result: %s', async (source) => {
const { run, dispatch } = setup(
ToolNames.UPDATE_GOAL,
{ terminateTurn: true },
'goal proposal recorded',
);
const result = await run(source);
expect(dispatch).toHaveBeenCalledTimes(1);
expect(dispatch.mock.calls[0][0]).toBe(ToolNames.UPDATE_GOAL);
expect(result.terminateTurn).toBe(true);
expect(JSON.stringify(result.llmContent)).not.toContain('after goal');
if (source.includes('before goal'))
expect(JSON.stringify(result.llmContent)).toContain('before goal');
expect(payload(result).toolResults[0].output).toBe(
'goal proposal recorded',
);
});
it('keeps nonterminal goal proposals callable without ending the script', async () => {
const { run, dispatch } = setup(
ToolNames.UPDATE_GOAL,
{},
'proposal pending',
);
const result = await run(
'await tools.update_goal({}); await tools.write_file({});',
);
expect(dispatch).toHaveBeenCalledTimes(2);
expect(result.terminateTurn).toBeUndefined();
});
it('clears loaded skill tracking if cancellation prevents delivery', async () => {
const { run, dispatch, registry } = setup(
ToolNames.SKILL,
{},
'skill body',
);
const clearLoadedSkills = vi.fn();
Object.assign(registry.getTool(ToolNames.SKILL)!, { clearLoadedSkills });
const controller = new AbortController();
const pending = run(
"await tools.skill({skill: 'test'}); await new Promise(() => {});",
controller.signal,
);
await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce());
controller.abort();
await expect(pending).rejects.toThrow();
expect(clearLoadedSkills).toHaveBeenCalledOnce();
});
it('preserves concurrency for ordinary calls before a goal barrier', async () => {
const { run, dispatch } = setup(
ToolNames.UPDATE_GOAL,
{ terminateTurn: true },
'done',
);
const original = dispatch.getMockImplementation()!;
let release!: () => void;
const firstCall = new Promise<void>((resolve) => {
release = resolve;
});
const order: string[] = [];
dispatch.mockImplementation(async (name, args, signal, onResult) => {
if (name === ToolNames.WRITE_FILE) {
order.push(String(args['id']));
if (args['id'] === 1) await firstCall;
else release();
return { callId: 'write', name, status: 'success', output: 'written' };
}
order.push(name);
return original(name, args, signal, onResult);
});
const result = await run(
'await Promise.all([tools.write_file({id:1}), tools.write_file({id:2}), tools.update_goal({}), tools.write_file({id:3})]);',
);
expect(order).toEqual(['1', '2', 'update_goal']);
expect(result.terminateTurn).toBe(true);
});
it('does not retain failed skill loads or adopt their model override', async () => {
const { run } = setup(
ToolNames.SKILL,
{ error: new Error('failed'), modelOverride: 'wrong' },
'not loaded',
);
const result = await run(
"try { await tools.skill({skill: 'test'}); } catch {};",
);
expect(JSON.stringify(result.llmContent)).not.toContain('toolResults');
expect(Object.hasOwn(result, 'modelOverride')).toBe(false);
});
});
+247
View File
@@ -0,0 +1,247 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import type { Part } from '@google/genai';
import type { Config } from '../config/config.js';
import {
executeCodeMode,
CodeModeExecutionError,
type CodeModeExecutionResult,
} from '../code-mode/host-client.js';
import {
boundCodeModeOutput,
EXEC_MAX_OUTPUT_CHARS,
} from '../code-mode/output.js';
import { ToolErrorType } from './tool-error.js';
import {
CodeModeTurnTerminated,
getToolCallRuntime,
type ToolCallRuntimeContext,
} from '../code-mode/tool-call-runtime.js';
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
import type { ToolResult } from './tools.js';
import { ToolDisplayNames, ToolNames } from './tool-names.js';
interface ExecParams {
source: string;
}
class ExecInvocation extends BaseToolInvocation<ExecParams, ToolResult> {
constructor(
private readonly config: Config,
params: ExecParams,
) {
super(params);
}
getDescription(): string {
return 'Execute isolated JavaScript with access to registered tools.';
}
async execute(signal: AbortSignal): Promise<ToolResult> {
const runtime = getToolCallRuntime();
if (!runtime) {
throw new Error(
'exec is unavailable outside the audited tool-call runtime.',
);
}
const plan = this.config
.getToolRegistry()
.getCodeModeBindingPlan(
runtime.allowedToolNames
? new Set(runtime.allowedToolNames)
: undefined,
);
const toolResults: Array<{
name: string;
args: Record<string, unknown>;
output: unknown;
}> = [];
const media: Part[] = [];
const metadata: Pick<ToolResult, 'modelOverride' | 'terminateTurn'> = {};
const retainedTools = new Set<string>([
ToolNames.SKILL,
ToolNames.UPDATE_GOAL,
'capture_screen_context',
]);
let skillAttempted = false;
const clearSkillTracking = () => {
const skill = this.config.getToolRegistry().getTool(ToolNames.SKILL);
if (
skill &&
'clearLoadedSkills' in skill &&
typeof skill.clearLoadedSkills === 'function'
) {
skill.clearLoadedSkills();
}
};
const active = new Set<Promise<unknown>>();
let goalBarrier: Promise<unknown> = Promise.resolve();
const contextRuntime: ToolCallRuntimeContext = {
...runtime,
dispatch: (name, args, nestedSignal, onResult) => {
const predecessors =
name === ToolNames.UPDATE_GOAL
? Promise.allSettled([...active])
: goalBarrier;
const task = (async () => {
await predecessors;
if (metadata.terminateTurn) throw new CodeModeTurnTerminated();
if (nestedSignal.aborted) throw nestedSignal.reason;
if (name === ToolNames.SKILL) skillAttempted = true;
const result = await runtime.dispatch(
name,
args,
nestedSignal,
(response) => {
onResult?.(response);
if (
name === ToolNames.SKILL &&
(signal.aborted ||
nestedSignal.aborted ||
response.executionStatus === 'cancelled')
) {
clearSkillTracking();
}
const native = response.responseParts.find(
(part) => part.functionResponse,
)?.functionResponse;
if (
signal.aborted ||
nestedSignal.aborted ||
response.error ||
response.executionStatus === 'cancelled' ||
response.executionStatus === 'error' ||
native?.response?.['error']
)
return;
if ('modelOverride' in response)
metadata.modelOverride = response.modelOverride;
if (response.terminateTurn) metadata.terminateTurn = true;
if (retainedTools.has(name)) {
toolResults.push({
name,
args,
output: native?.response?.['output'],
});
for (const part of native?.parts ?? []) {
if (part.inlineData)
media.push({ inlineData: part.inlineData });
if (part.fileData) media.push({ fileData: part.fileData });
}
}
},
);
if (metadata.terminateTurn) throw new CodeModeTurnTerminated();
if (name === 'capture_screen_context') {
const { content: _content, ...textResult } = result;
return textResult;
}
return result;
})();
active.add(task);
const settled = task.then(
() => {
active.delete(task);
},
() => {
active.delete(task);
},
);
if (name === ToolNames.UPDATE_GOAL) goalBarrier = settled;
return task;
},
};
let result: CodeModeExecutionResult;
let failure: string | undefined;
try {
result = await executeCodeMode(
this.params.source,
plan,
contextRuntime,
signal,
);
} catch (error) {
if (
skillAttempted &&
(signal.aborted ||
!toolResults.some((result) => result.name === ToolNames.SKILL))
) {
clearSkillTracking();
}
if (signal.aborted) throw error;
result =
error instanceof CodeModeExecutionError ? error.result : { output: '' };
failure = error instanceof Error ? error.message : String(error);
}
const sections: string[] = [];
if (result.output) sections.push(result.output);
if (result.value !== undefined)
sections.push(`Return value: ${JSON.stringify(result.value)}`);
if (failure !== undefined) sections.push(`Script error:\n${failure}`);
const output = boundCodeModeOutput(
sections.join('\n') || 'JavaScript completed successfully.',
EXEC_MAX_OUTPUT_CHARS,
);
// Keep required context outside the script's output cap and before any reminders.
const retained = toolResults.length ? JSON.stringify({ toolResults }) : '';
const display = retained ? `${retained}\n${output}` : output;
const llmContent: Part[] = [{ text: display }, ...media];
for (const item of result.content ?? []) {
llmContent.push({
inlineData: {
mimeType: item.mimeType,
data: item.data,
},
});
}
return {
llmContent,
returnDisplay: display,
...metadata,
persistedOutputFiles: [],
...(failure === undefined || toolResults.length > 0
? {}
: {
error: { message: output, type: ToolErrorType.EXECUTION_FAILED },
}),
};
}
}
export class ExecTool extends BaseDeclarativeTool<ExecParams, ToolResult> {
constructor(private readonly config: Config) {
super(
ToolNames.EXEC,
ToolDisplayNames.EXEC,
'Execute JavaScript in an isolated runtime.',
Kind.Other,
{
type: 'object',
properties: {
source: {
type: 'string',
description: 'JavaScript source to execute.',
},
},
required: ['source'],
additionalProperties: false,
},
false,
false,
false,
true,
);
}
override get maxOutputChars(): number {
return Number.POSITIVE_INFINITY;
}
protected createInvocation(params: ExecParams): ExecInvocation {
return new ExecInvocation(this.config, params);
}
}
+36 -3
View File
@@ -148,6 +148,36 @@ describe('DiscoveredMCPTool', () => {
})),
}) satisfies McpDirectClient;
it.each(['summary', 'json', 'empty'] as const)(
'preserves CUA action handles with %s content',
async (kind) => {
const structuredContent = {
snapshot_id: 's00000001',
elements: [{ element_token: 's00000001:8', label: 'View' }],
};
const serialized = JSON.stringify(structuredContent);
const content =
kind === 'empty'
? []
: [
{
type: 'text' as const,
text: kind === 'json' ? serialized : 'View menu',
},
];
const mcpClient: McpDirectClient = {
callTool: vi.fn(async () => ({ content, structuredContent })),
};
const result = await createDirectTool(mcpClient, false)
.build({ param: 'test' })
.execute(new AbortController().signal);
expect(result.llmContent).toEqual([
{ text: serialized },
...(kind === 'summary' ? [{ text: 'View menu' }] : []),
]);
},
);
it('injects trusted request metadata for an allowed stdio tool', async () => {
const mcpClient = successfulClient();
const modelArguments = {
@@ -1175,7 +1205,7 @@ describe('DiscoveredMCPTool', () => {
appResourceUi,
);
it('loads an MCP App resource without changing model-visible content', async () => {
it('loads an MCP App resource while preserving structured tool output', async () => {
const mcpClient: McpDirectClient = {
callTool: vi.fn(async () => ({
content: [{ type: 'text', text: 'Dashboard ready' }],
@@ -1202,13 +1232,16 @@ describe('DiscoveredMCPTool', () => {
.build({ param: 'test' })
.execute(new AbortController().signal);
expect(result.llmContent).toEqual([{ text: 'Dashboard ready' }]);
expect(result.llmContent).toEqual([
{ text: '{"revenue":42}' },
{ text: 'Dashboard ready' },
]);
expect(result.returnDisplay).toMatchObject({
type: 'mcp_app',
resourceUri: 'ui://demo/dashboard',
html: '<main>Revenue</main>',
toolArguments: { param: 'test' },
fallbackText: 'Dashboard ready',
fallbackText: '{"revenue":42}\nDashboard ready',
csp: { connectDomains: ['https://api.example.com'] },
permissions: { clipboardWrite: {} },
});
+17 -2
View File
@@ -1284,9 +1284,17 @@ function transformMcpContentToParts(sdkResponse: Part[]): Part[] {
const funcResponse = sdkResponse?.[0]?.functionResponse;
const mcpContent = funcResponse?.response?.['content'] as McpContentBlock[];
const toolName = funcResponse?.name || 'unknown tool';
// Structured MCP output can contain required follow-up arguments (for
// example CUA snapshot IDs and element tokens) absent from the text summary.
// Preserve it for both ordinary tool turns and nested exec calls.
const structured = funcResponse?.response?.['structuredContent'];
const structuredParts: Part[] =
structured !== undefined ? [{ text: JSON.stringify(structured) }] : [];
if (!Array.isArray(mcpContent)) {
return [{ text: '[Error: Could not parse tool response]' }];
return structuredParts.length
? structuredParts
: [{ text: '[Error: Could not parse tool response]' }];
}
const transformed = mcpContent.flatMap(
@@ -1307,7 +1315,14 @@ function transformMcpContentToParts(sdkResponse: Part[]): Part[] {
},
);
return transformed.filter((part): part is Part => part !== null);
const contentParts = transformed.filter(
(part): part is Part => part !== null,
);
// Servers may already provide the compatibility JSON block recommended by
// MCP. Do not double it when the exact serialized payload is already present.
return contentParts.some((part) => part.text === structuredParts[0]?.text)
? contentParts
: [...structuredParts, ...contentParts];
}
function getMcpErrorImageContent(rawResponseParts: Part[]): Part[] | undefined {
+29
View File
@@ -17,6 +17,7 @@ import type { Config } from '../config/config.js';
import { Storage } from '../config/storage.js';
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
import { FileReadCache } from '../services/fileReadCache.js';
import { runWithToolCallSource } from '../code-mode/tool-call-runtime.js';
import { StandardFileSystemService } from '../services/fileSystemService.js';
import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js';
import type { ToolInvocation, ToolResult } from './tools.js';
@@ -1215,6 +1216,34 @@ describe('ReadFileTool', () => {
expect(result.llmContent).toContain("Use the 'pages' parameter");
});
it('keeps nested reads usable without claiming their bytes reached history', async () => {
const filePath = path.join(tempRootDir, 'program-input.txt');
await fsp.writeFile(filePath, 'program input', 'utf-8');
const source = {
kind: 'code_mode' as const,
};
await read({ file_path: filePath });
for (let i = 0; i < 2; i++) {
const result = await runWithToolCallSource(source, () =>
read({ file_path: filePath }),
);
expect(result.llmContent).toBe('program input');
}
const stats = await fsp.stat(filePath);
const cached = fileReadCache.check(stats);
expect(cached.state).toBe('fresh');
if (cached.state !== 'fresh') throw new Error('missing read record');
expect(cached.entry.lastReadWasFull).toBe(true);
expect(cached.entry.lastReadCacheable).toBe(true);
expect(cached.entry.readResidentInHistory).toBe(false);
expect((await read({ file_path: filePath })).llmContent).toBe(
'program input',
);
expect((await read({ file_path: filePath })).llmContent).toMatch(
/unchanged since/,
);
});
it('returns the file_unchanged placeholder on a second full Read of an unchanged text file', async () => {
const filePath = path.join(tempRootDir, 'note.txt');
await fsp.writeFile(filePath, 'hello world', 'utf-8');
+5 -1
View File
@@ -16,6 +16,7 @@ import type {
} from './tools.js';
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
import { ToolNames, ToolDisplayNames } from './tool-names.js';
import { getCurrentToolCallSource } from '../code-mode/tool-call-runtime.js';
import type { PartListUnion } from '@google/genai';
import type { PermissionDecision } from '../permissions/types.js';
@@ -149,7 +150,8 @@ class ReadFileToolInvocation extends BaseToolInvocation<
// per-read freshness `<system-reminder>` that must be re-emitted
// on every read.
const cacheEnabled = !this.config.getFileReadCacheDisabled();
const useFastPath = cacheEnabled && !isAutoMem;
const nestedRead = getCurrentToolCallSource()?.kind === 'code_mode';
const useFastPath = cacheEnabled && !isAutoMem && !nestedRead;
const cache = this.config.getFileReadCache();
// A request-level "full" Read asks for the whole file: no offset,
// no limit, no PDF page range. The cache entry is only marked as
@@ -275,6 +277,8 @@ class ReadFileToolInvocation extends BaseToolInvocation<
full: isFullRead && !result.isTruncated,
cacheable,
});
// Reading into a program does not prove the full text reached history.
if (nestedRead) cache.markReadEvictedFromHistory(recordStats);
}
let llmContent: PartListUnion;
+52 -5
View File
@@ -79,6 +79,7 @@ import * as os from 'node:os';
import * as crypto from 'node:crypto';
import path from 'node:path';
import { ToolErrorType } from './tool-error.js';
import { runWithToolCallSource } from '../code-mode/tool-call-runtime.js';
import { OUTPUT_UPDATE_INTERVAL_MS, parseNumstat } from './shell.js';
import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js';
import { PermissionManager } from '../permissions/permission-manager.js';
@@ -89,6 +90,11 @@ interface ShellToolParameterJsonSchema {
command: {
description: string;
};
timeout: {
type: string;
minimum: number;
maximum: number;
};
};
}
@@ -245,6 +251,19 @@ describe('ShellTool', () => {
CommitAttributionService.resetInstance();
});
it('exposes the accepted timeout range in its schema', () => {
const schema = shellTool.schema
.parametersJsonSchema as ShellToolParameterJsonSchema;
expect(schema.properties.timeout).toEqual(
expect.objectContaining({
type: 'integer',
minimum: 1,
maximum: 600000,
}),
);
});
describe('gh pr create binding', () => {
const fetchCurrentBranchPullRequestMock = vi.mocked(
fetchCurrentBranchPullRequest,
@@ -3551,6 +3570,34 @@ describe('ShellTool', () => {
});
});
it.each([0, 1])(
'omits repeated command source from nested shell results (exit %i)',
async (exitCode) => {
const command = 'echo ' + 'large-script-source'.repeat(3000);
const invocation = shellTool.build({ command, is_background: false });
const promise = runWithToolCallSource(
{
kind: 'code_mode',
},
() => invocation.execute(mockAbortSignal),
);
resolveShellExecution({
output: 'diagnostic output',
exitCode,
error: null,
});
const result = await promise;
expect(result.llmContent).toContain('diagnostic output');
expect(result.llmContent).toContain(`Exit Code: ${exitCode}`);
expect(result.llmContent).not.toContain('large-script-source');
expect(String(result.llmContent).length).toBeLessThan(1000);
if (exitCode !== 0) {
expect(result.error?.message).toContain('diagnostic output');
expect(result.error?.message).not.toContain('large-script-source');
}
},
);
it('reports a foreground non-zero exit as a tool error', async () => {
const invocation = shellTool.build({
command: 'failing-command',
@@ -8327,7 +8374,7 @@ describe('ShellTool', () => {
is_background: false,
timeout: 0,
});
}).toThrow('Timeout must be a positive number.');
}).toThrow('params/timeout must be >= 1');
// Negative timeout
expect(() => {
@@ -8336,7 +8383,7 @@ describe('ShellTool', () => {
is_background: false,
timeout: -1000,
});
}).toThrow('Timeout must be a positive number.');
}).toThrow('params/timeout must be >= 1');
// Timeout too large
expect(() => {
@@ -8345,7 +8392,7 @@ describe('ShellTool', () => {
is_background: false,
timeout: 700000,
});
}).toThrow('Timeout cannot exceed 600000ms (10 minutes).');
}).toThrow('params/timeout must be <= 600000');
// Non-integer timeout
expect(() => {
@@ -8354,7 +8401,7 @@ describe('ShellTool', () => {
is_background: false,
timeout: 5000.5,
});
}).toThrow('Timeout must be an integer number of milliseconds.');
}).toThrow('params/timeout must be integer');
// Non-number timeout (schema validation catches this first)
expect(() => {
@@ -8363,7 +8410,7 @@ describe('ShellTool', () => {
is_background: false,
timeout: 'invalid' as unknown as number,
});
}).toThrow('params/timeout must be number');
}).toThrow('params/timeout must be integer');
});
it('should include timeout in description for foreground commands', async () => {
+8 -3
View File
@@ -31,6 +31,7 @@ import {
} from './tools.js';
import { getErrorMessage, isNodeError } from '../utils/errors.js';
import { truncateToolOutput } from './truncation.js';
import { getCurrentToolCallSource } from '../code-mode/tool-call-runtime.js';
import {
CommitAttributionService,
type StagedFileInfo,
@@ -2824,7 +2825,9 @@ export class ShellToolInvocation extends BaseToolInvocation<
: '(none)';
llmContent = [
`Command: ${this.params.command}`,
...(getCurrentToolCallSource()?.kind === 'code_mode'
? []
: [`Command: ${this.params.command}`]),
`Directory: ${this.params.directory || '(root)'}`,
`Output: ${result.output || '(empty)'}`,
`Error: ${finalError}`, // Use the cleaned error string.
@@ -5175,7 +5178,7 @@ IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO N
**Usage notes**:
- The command argument is required.
- You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will timeout after 120000ms (2 minutes).
- You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will timeout after 120000ms (2 minutes). For longer commands, use \`is_background: true\` and observe the managed task instead of passing a larger timeout.
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
- Avoid using run_shell_command with the \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands:
@@ -5259,7 +5262,9 @@ export class ShellTool extends BaseDeclarativeTool<
'Optional: Whether to run the command in background. If not specified, defaults to false (foreground execution). Explicitly set to true for long-running processes like development servers, watchers, or daemons that should continue running without blocking further commands.',
},
timeout: {
type: 'number',
type: 'integer',
minimum: 1,
maximum: 600000,
description: 'Optional timeout in milliseconds (max 600000)',
},
description: {
+116
View File
@@ -1366,6 +1366,122 @@ describe('SkillTool', () => {
expect(skillTool.getLoadedSkillContents()).toEqual(new Set([output]));
});
it.each([
[undefined, ''],
['Script failed after loading the skill', ''],
[undefined, '\n<system-reminder>PostToolUse context</system-reminder>'],
])(
'restores preserved nested Skill bodies even when exec fails: %s',
(error, suffix) => {
const output = buildSkillLlmContent(
'/project/.qwen/skills/code-review',
mockSkills[0].body,
);
skillTool.restoreLoadedSkillsFromHistory([
{
role: 'model',
parts: [
{
functionCall: {
id: 'exec-call',
name: ToolNames.EXEC,
args: { code: 'await tools.skill({ skill: "code-review" })' },
},
},
],
},
{
role: 'user',
parts: [
{
functionResponse: {
id: 'exec-call',
name: ToolNames.EXEC,
response: {
output:
JSON.stringify({
error,
toolResults: [
{
name: ToolNames.SKILL,
args: { skill: 'code-review' },
output,
},
],
}) + suffix,
},
},
},
],
},
]);
expect(skillTool.getLoadedSkillNames()).toEqual(
new Set(['code-review']),
);
expect(skillTool.getLoadedSkillContents()).toEqual(new Set([output]));
},
);
it.each([
'not JSON',
JSON.stringify({ toolResults: null }),
JSON.stringify({ toolResults: [null, {}, { name: ToolNames.SKILL }] }),
JSON.stringify({
toolResults: [
{
name: ToolNames.SKILL,
args: { skill: 'code-review' },
output: 'Skill "code-review" is already loaded in context.',
},
],
}),
JSON.stringify({
toolResults: [
{
name: ToolNames.SKILL,
args: { skill: 'other-command' },
output: buildSkillLlmContent(
'/project/.qwen/skills/code-review',
'Review code for quality and best practices.',
),
},
],
}),
])(
'ignores exec results without a matching complete Skill body: %s',
(output) => {
skillTool.restoreLoadedSkillsFromHistory([
{
role: 'model',
parts: [
{
functionCall: {
id: 'exec-call',
name: ToolNames.EXEC,
args: {},
},
},
],
},
{
role: 'user',
parts: [
{
functionResponse: {
id: 'exec-call',
name: ToolNames.EXEC,
response: { output },
},
},
],
},
]);
expect(skillTool.getLoadedSkillNames()).toEqual(new Set());
expect(skillTool.getLoadedSkillContents()).toEqual(new Set());
},
);
it('does not restore command output that matches an unrelated cached Skill', () => {
const output = buildSkillLlmContent(
'/project/.qwen/skills/code-review',
+57 -10
View File
@@ -362,10 +362,28 @@ export class SkillTool extends BaseDeclarativeTool<SkillParams, ToolResult> {
skillByName.set(skill.name.toLowerCase(), { name: skill.name, output });
}
const restoreSkill = (requestedName: unknown, output: unknown) => {
if (typeof requestedName !== 'string' || typeof output !== 'string')
return;
const skill = skillByName.get(requestedName.toLowerCase());
if (
!skill ||
(output !== skill.output && !output.startsWith(`${skill.output}\n`))
) {
return;
}
this.loadedSkillContents.add(skill.output);
this.loadedSkillNames.add(skill.name);
};
const pendingSkillCalls = new Map<string, string>();
const pendingExecCalls = new Set<string>();
for (const content of history) {
for (const part of content.parts ?? []) {
const call = part.functionCall;
if (call?.name === ToolNames.EXEC && typeof call.id === 'string') {
pendingExecCalls.add(call.id);
}
const requestedSkill = call?.args?.['skill'];
if (
call?.name === ToolNames.SKILL &&
@@ -378,6 +396,44 @@ export class SkillTool extends BaseDeclarativeTool<SkillParams, ToolResult> {
const response = part.functionResponse;
const output = response?.response?.['output'];
if (
response?.name === ToolNames.EXEC &&
typeof response.id === 'string' &&
pendingExecCalls.delete(response.id) &&
typeof output === 'string'
) {
let payload: unknown;
try {
payload = JSON.parse(output.split('\n', 1)[0]);
} catch {
continue;
}
if (
!payload ||
typeof payload !== 'object' ||
!('toolResults' in payload) ||
!Array.isArray(payload.toolResults)
) {
continue;
}
const results: unknown[] = payload.toolResults;
for (const result of results) {
if (
result &&
typeof result === 'object' &&
'name' in result &&
result.name === ToolNames.SKILL &&
'args' in result &&
result.args &&
typeof result.args === 'object' &&
'skill' in result.args &&
'output' in result
) {
restoreSkill(result.args.skill, result.output);
}
}
continue;
}
if (
response?.name !== ToolNames.SKILL ||
typeof response.id !== 'string' ||
@@ -389,16 +445,7 @@ export class SkillTool extends BaseDeclarativeTool<SkillParams, ToolResult> {
const requestedName = pendingSkillCalls.get(response.id);
pendingSkillCalls.delete(response.id);
if (requestedName === undefined) continue;
const skill = skillByName.get(requestedName.toLowerCase());
if (
!skill ||
(output !== skill.output && !output.startsWith(`${skill.output}\n`))
) {
continue;
}
this.loadedSkillContents.add(skill.output);
this.loadedSkillNames.add(skill.name);
restoreSkill(requestedName, output);
}
}
}
+2
View File
@@ -18,6 +18,7 @@
* per-declaration `pathFields?: string[]` annotation on the tool class.)
*/
export const ToolNames = {
EXEC: 'exec',
EDIT: 'edit',
WRITE_FILE: 'write_file',
READ_FILE: 'read_file',
@@ -76,6 +77,7 @@ export const ToolNames = {
* with the actual tool display names.
*/
export const ToolDisplayNames = {
EXEC: 'Exec',
EDIT: 'Edit',
WRITE_FILE: 'WriteFile',
READ_FILE: 'ReadFile',
@@ -780,6 +780,32 @@ describe('ToolRegistry', () => {
]);
});
it('getDeferredToolSummary is empty in CodeModeOnly', () => {
// Both consumers of this summary — the startup deferred-tools reminder
// and the added-MCP-tools reminder — tell the model to reach the listed
// tools through ToolSearch, which CodeModeOnly hides. The MCP tool below
// is the one the previous test proves IS reported in Direct mode.
const codeModeConfig = new Config({
...baseConfigParams,
codeModeOnly: true,
});
const registry = new ToolRegistry(codeModeConfig);
registry.registerTool(
new MockTool({ name: 'deferred', shouldDefer: true }),
);
registry.registerTool(
new DiscoveredMCPTool(
{} as CallableTool,
'schedule-server',
'cron_list',
'list scheduled jobs',
{},
),
);
expect(registry.getDeferredToolSummary()).toEqual([]);
});
it('removeMcpToolsByServer also drops revealedDeferred entries', async () => {
// Pin the regression: a server-disconnect-then-reconnect cycle that
// re-registers a tool of the same name must NOT inherit
+80
View File
@@ -30,6 +30,13 @@ import { normalizePathEnvForWindows } from '../utils/windowsPath.js';
import type { ReadResourceResult } from '@modelcontextprotocol/sdk/types.js';
import { normalizeMcpToolName } from '../utils/tool-name-utils.js';
import { CHARS_PER_TOKEN } from '../services/tokenEstimation.js';
import {
buildExecDeclaration,
getToolExposure,
planCodeModeBindings,
ToolMode,
type CodeModeBindingPlan,
} from './code-mode.js';
type ToolParams = Record<string, unknown>;
@@ -209,6 +216,7 @@ export class ToolRegistry {
// pinDeferredToolReveal): they survive the `/clear` reset that
// intentionally drops discovered reveals so the new session starts clean.
private pinnedDeferredReveals: Set<string> = new Set();
private codeModeCollisionWarnings = new Set<string>();
// Built-in tools demoted to deferred by an active `settings.tools.eager`
// allowlist (#9827, #10075). They are fully registered — listed
// in `/tools`, discoverable and loadable via ToolSearch, callable through
@@ -255,6 +263,20 @@ export class ToolRegistry {
return a.displayName.localeCompare(b.displayName);
}
private static compareCodeModeTools(
a: AnyDeclarativeTool,
b: AnyDeclarativeTool,
): number {
const aName = a.schema.name ?? a.name;
const bName = b.schema.name ?? b.name;
if (aName !== bName) return aName < bName ? -1 : 1;
return a.displayName < b.displayName
? -1
: a.displayName > b.displayName
? 1
: 0;
}
/**
* Returns true when `name` is in the Config's `disabledTools` set, in
* which case `registerTool` / `registerFactory` will skip it. This is
@@ -819,6 +841,9 @@ export class ToolRegistry {
getFunctionDeclarations(options?: {
includeDeferred?: boolean;
}): FunctionDeclaration[] {
if (this.config.getToolMode?.() === ToolMode.CodeModeOnly) {
return this.getCodeModeFunctionDeclarations();
}
const includeDeferred = options?.includeDeferred === true;
return Array.from(this.tools.values())
.filter((tool) => this.isToolDeclared(tool.name))
@@ -833,6 +858,51 @@ export class ToolRegistry {
.map((tool) => tool.schema);
}
private getCodeModeFunctionDeclarations(
allowedNames?: ReadonlySet<string>,
): FunctionDeclaration[] {
const plan = this.getCodeModeBindingPlan(allowedNames);
return Array.from(this.tools.values())
.filter((tool) => {
const exposure = getToolExposure(tool.name);
if (exposure === 'exec') return true;
return (
exposure === 'direct-only' &&
(!allowedNames || allowedNames.has(tool.name))
);
})
.sort(ToolRegistry.compareCodeModeTools)
.map((tool) =>
tool.name === ToolNames.EXEC
? buildExecDeclaration(tool, plan)
: tool.schema,
);
}
getCodeModeBindingPlan(
allowedNames?: ReadonlySet<string>,
): CodeModeBindingPlan {
const plan = planCodeModeBindings(
Array.from(this.tools.values()),
(name) => this.isDeferredAndHidden(name),
allowedNames,
);
this.warnCodeModeCollisions(plan);
return plan;
}
private warnCodeModeCollisions(plan: CodeModeBindingPlan): void {
for (const collision of plan.collisions) {
const key = `${collision.jsName}:${collision.kept}:${collision.omitted}`;
if (this.codeModeCollisionWarnings.has(key)) continue;
this.codeModeCollisionWarnings.add(key);
debugLogger.warn(
`Code mode tool "${collision.omitted}" is unavailable because its JavaScript name ` +
`tools.${collision.jsName} collides with "${collision.kept}".`,
);
}
}
/**
* Marks a deferred tool as revealed. Revealed tools are included in
* {@link getFunctionDeclarations} output for the rest of the session, even
@@ -918,8 +988,15 @@ export class ToolRegistry {
* set of on-demand tools in the startup reminder so the model knows what is
* reachable via ToolSearch. `alwaysLoad` tools and tools listed in
* {@link Config.getVisibleTools} are excluded.
*
* Always empty in CodeModeOnly: every schema is already bound into the `exec`
* description and ToolSearch is hidden, so a reminder built from this summary
* would offer a lookup step the model has no way to take.
*/
getDeferredToolSummary(): DeferredToolSummary[] {
if (this.config.getToolMode?.() === ToolMode.CodeModeOnly) {
return [];
}
const summary: DeferredToolSummary[] = [];
this.tools.forEach((tool) => {
if (
@@ -1019,6 +1096,9 @@ export class ToolRegistry {
`tool factories. Call warmAll() first to avoid incomplete results.`,
);
}
if (this.config.getToolMode?.() === ToolMode.CodeModeOnly) {
return this.getCodeModeFunctionDeclarations(new Set(toolNames));
}
const declarations: FunctionDeclaration[] = [];
for (const name of toolNames) {
const tool = this.tools.get(name);
@@ -76,6 +76,33 @@ describe('tool response finalization', () => {
}));
});
it('fits exec output to a batch budget without persisting known-empty artifacts', async () => {
const result = await finalizeToolResponses(config(1000), [
{
...entry(
'exec-inline',
[
{
functionResponse: {
id: 'exec-inline',
name: 'exec',
response: { output: 'x'.repeat(32_000) },
},
},
],
[],
),
toolName: 'exec',
},
]);
expect(persist).not.toHaveBeenCalled();
expect(result[0].persistedOutputFiles).toEqual([]);
expect(toolResponseTextLength(result[0].responseParts)).toBeLessThanOrEqual(
1000,
);
expect(JSON.stringify(result[0].responseParts)).not.toContain('Persisted');
});
it('leaves a batch within budget unchanged', async () => {
const entries = [
entry('small', [
+31
View File
@@ -1340,6 +1340,37 @@ describe('fileUtils', () => {
expect(result.returnDisplay).toContain('Read image file: image.png');
});
it('points the zoom hint at tools.zoom_image in CodeModeOnly', async () => {
await sharp({
create: {
width: 20,
height: 10,
channels: 3,
background: '#306090',
},
})
.png()
.toFile(testImageFilePath);
mockMimeGetType.mockReturnValue('image/png');
const codeModeConfig = {
...mockConfig,
getCodeModeOnly: () => true,
} as unknown as Config;
const result = await processSingleFileContent(
testImageFilePath,
codeModeConfig,
);
const parts = result.llmContent as Part[];
expect(parts[0]).toEqual({
text:
'Image overview: 20x10; oriented source: 20x10. ' +
'If details are too small, call tools.zoom_image with ' +
'coordinates normalized from 0 to 1000.',
});
});
it('returns a bounded overview when a PNG exceeds the old data URI limit', async () => {
const largeImagePath = path.join(tempRootDir, 'large.png');
await sharp({
+8 -2
View File
@@ -1567,14 +1567,20 @@ export async function processSingleFileContent(
filePath,
signal ?? new AbortController().signal,
);
// CodeModeOnly hides tool_search and binds zoom_image into the
// `exec` description, so the discovery step does not exist there.
const zoomHint = config.getCodeModeOnly?.()
? `If details are too small, call tools.zoom_image with ` +
`coordinates normalized from 0 to 1000.`
: `If details are too small, use tool_search for "zoom image", then ` +
`call zoom_image with coordinates normalized from 0 to 1000.`;
return {
llmContent: [
{
text:
`Image overview: ${view.outputWidth}x${view.outputHeight}; ` +
`oriented source: ${view.sourceWidth}x${view.sourceHeight}. ` +
`If details are too small, use tool_search for "zoom image", then ` +
`call zoom_image with coordinates normalized from 0 to 1000.`,
zoomHint,
},
{
inlineData: {
+17 -1
View File
@@ -13,7 +13,14 @@
* into dist/runtime/ here. resolveKernelPath() and the wasm loader probe
* ./runtime/ relative to the compiled module, so this layout works in dist.
*/
import { cpSync, existsSync, mkdirSync, rmSync } from 'node:fs';
import {
chmodSync,
cpSync,
existsSync,
mkdirSync,
rmSync,
statSync,
} from 'node:fs';
import path from 'node:path';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
@@ -77,4 +84,13 @@ for (const required of ['index.js', 'kernel-manager.js', 'mcp-server.js']) {
}
}
// 6. Restore the execute bit on the bin entry. npm's bin-link chmods
// dist/index.js at install time, but step 0 deletes that exact file and tsc
// re-emits it as 0644, so after any rebuild node_modules/.bin/node-repl-mcp
// points at a non-executable target and spawning it fails with EACCES before
// the shebang is ever read. OR the exec bits into the existing mode instead
// of setting 0o755, so a deliberately-private checkout stays private.
const binEntry = path.join(here, 'dist', 'index.js');
chmodSync(binEntry, statSync(binEntry).mode | 0o111);
console.log(`node-repl-mcp: runtime assets copied to ${distRuntime}`);
+107
View File
@@ -3552,6 +3552,86 @@ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
============================================================
@jitl/[email protected]
(https://github.com/justjake/quickjs-emscripten)
quickjs-emscripten:
The MIT License
quickjs-emscripten copyright (c) 2019-2024 Jake Teton-Landis
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
quickjs:
QuickJS Javascript Engine
Copyright (c) 2017-2021 Fabrice Bellard
Copyright (c) 2017-2021 Charlie Gordon
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
============================================================
@jitl/[email protected]
(https://github.com/justjake/quickjs-emscripten)
The MIT License
quickjs-emscripten copyright (c) 2019-2024 Jake Teton-Landis
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
============================================================
@modelcontextprotocol/[email protected]
(git+https://github.com/modelcontextprotocol/typescript-sdk.git)
@@ -15398,6 +15478,33 @@ WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
============================================================
[email protected]
(https://github.com/justjake/quickjs-emscripten)
The MIT License
quickjs-emscripten copyright (c) 2019-2024 Jake Teton-Landis
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
============================================================
[email protected]
(git+https://github.com/lovell/sharp.git)
@@ -486,6 +486,11 @@
"type": "boolean",
"default": true
},
"showToolCallDetails": {
"description": "Show tool arguments and results inline. Disable to render ordinary tool calls as a one-line summary; click a summary in Virtualized History or press Ctrl+O to expand its details. Approval prompts, user-initiated shell commands, and focused interactive shells remain expanded.",
"type": "boolean",
"default": true
},
"showToolCallArgs": {
"description": "Render tool calls on their own line with their raw arguments inline, instead of the type-based compact summary that folds read/search/list batches into \"Read 3 files\". Useful when debugging MCP integrations or tool schemas. Applies wherever the arguments are available: live, resumed, agent-view and speculated turns. The row is capped at 2 wrapped lines (and never more than 1000 characters) and truncated with a `+N chars` marker; press Ctrl+O for the complete payload. Groups of running parallel subagents keep their compact roster, and daemon-attached sessions carry no arguments, so both keep the compact view — press Ctrl+O there. Does not change result-output truncation.",
"type": "boolean",
@@ -1271,6 +1276,11 @@
"description": "Settings for built-in and custom tools.",
"type": "object",
"properties": {
"codeModeOnly": {
"description": "Expose ordinary tools to the model only through the isolated exec JavaScript tool. Direct control tools remain available. Ignored in safe and bare modes.",
"type": "boolean",
"default": false
},
"sandbox": {
"anyOf": [
{
@@ -12,6 +12,7 @@ export { isActiveToolStatus } from '../../adapters/toolClassification';
* write, …) are web-shell-only conveniences with no core equivalent.
*/
export const TOOL_DISPLAY_NAMES: Record<string, string> = {
exec: 'Exec',
edit: 'Edit',
write_file: 'WriteFile',
read_file: 'ReadFile',
+1
View File
@@ -3789,6 +3789,7 @@ const ZH: Messages = {
// Tool display names (chat-stream badge labels). Keyed by `toolName.<wire>`;
// a wire name with no entry here falls back to the English display name via
// `localizeToolDisplayName`. Acronyms and product names stay verbatim.
'toolName.exec': '执行代码',
'toolName.edit': '编辑',
'toolName.write_file': '写入文件',
'toolName.read_file': '读取文件',
+26 -1
View File
@@ -29,7 +29,7 @@ importers:
specifier: ^10.4.1
version: 10.4.1
ink:
specifier: ^7.0.3
specifier: 7.0.3
version: 7.0.3(@types/[email protected])([email protected])([email protected])
simple-git:
specifier: ^3.36.0
@@ -760,6 +760,9 @@ importers:
'@iarna/toml':
specifier: ^2.2.5
version: 2.2.5
'@jitl/quickjs-singlefile-mjs-release-sync':
specifier: 0.32.0
version: 0.32.0
'@modelcontextprotocol/client':
specifier: 2.0.0
version: 2.0.0
@@ -877,6 +880,9 @@ importers:
proper-lockfile:
specifier: ^4.1.2
version: 4.1.2
quickjs-emscripten-core:
specifier: 0.32.0
version: 0.32.0
sharp:
specifier: ^0.35.4
version: 0.35.4(@types/[email protected])
@@ -2594,6 +2600,12 @@ packages:
resolution: {integrity: sha512-+g/1TKjFuGrf1Hh0QPCv0gISwBxJ+MQSNXmG9zjHy7BmFhtoJ9fdNhWJp3qUKRi93AOZHXtdxZgJ1vAtz6z65w==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
'@jitl/[email protected]':
resolution: {integrity: sha512-v9T+GQpmk43VDJ7d72sf0Nexhk+ArvtUihW27dy7lqAl0zBObFKtSBBIm5RBjwIhE8VwsPPm9PNuvPvNqLWUEg==}
'@jitl/[email protected]':
resolution: {integrity: sha512-h9g2Dri6WxKNjX6a1+sFAafOVxj1n5YIA1GAzMoSdIqK1M8cG07tz3GxKVC3Gbdutz14uQ3oc3Q2lll8otInhA==}
'@jridgewell/[email protected]':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@@ -8698,6 +8710,9 @@ packages:
[email protected]:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
[email protected]:
resolution: {integrity: sha512-QFnPfjFey8EqknSrSxe1hZrf1/8z7/6s1QzGOmKo6++02r7QRRX7ZoyNaZh7JuVjWsVW87KnQrbZqnHkOAzUyg==}
[email protected]:
resolution: {integrity: sha512-OwYUjzMwiInCUxgAWpPsavXC3Kh4iyi/49uU1/qZTG3RQDlvegyk1GOMiGvSkjua1RDb3JD3fo3eroL9FV4GQw==}
peerDependencies:
@@ -11323,6 +11338,12 @@ snapshots:
dependencies:
'@sinclair/typebox': 0.34.37
'@jitl/[email protected]': {}
'@jitl/[email protected]':
dependencies:
'@jitl/quickjs-ffi-types': 0.32.0
'@jridgewell/[email protected]':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -18724,6 +18745,10 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
'@jitl/quickjs-ffi-types': 0.32.0
[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected]):
dependencies:
'@radix-ui/primitive': 1.1.5
+2
View File
@@ -72,6 +72,7 @@ const TARGET_OPENTUI_PACKAGES = new Map([
const DIST_REQUIRED_PATHS = [
'cli.js',
'cli-entry.js',
'codeModeHost.js',
'chunks',
'vendor',
'bundled/qc-helper/docs',
@@ -84,6 +85,7 @@ const DIST_ALLOWED_ENTRIES = new Set([
// fzf fuzzy-search worker; esbuild emits it as a standalone entry that must
// sit next to cli.js so `new URL('./fzfWorker.js', ...)` resolves at runtime.
'fzfWorker.js',
'codeModeHost.js',
'chunks',
'vendor',
'bundled',
+2
View File
@@ -177,6 +177,8 @@ const tsxCmd = hasLocalTsxCli
: tsxBinName;
const tsxArgs = [
...(hasLocalTsxCli ? [localTsxCli] : []),
'--tsconfig',
join(cliPackageDir, 'tsconfig.json'),
cliEntry,
...process.argv.slice(2),
];
+1
View File
@@ -321,6 +321,7 @@ function writeDistPackageJson(rootDir, distDir) {
// Must ship in the tarball or the @-picker silently falls back to the
// in-thread AsyncFzf path on big workspaces in npm-installed CLIs.
'fzfWorker.js',
'codeModeHost.js',
'chunks',
'vendor',
'*.sb',
+4
View File
@@ -85,6 +85,8 @@ describe('scripts/dev.js launcher', () => {
expect(command).toBe('C:\\Program Files\\nodejs\\node.exe');
expect(args.map(normalizePath)).toEqual([
expect.stringContaining('node_modules/tsx/dist/cli.mjs'),
'--tsconfig',
expect.stringContaining('packages/cli/tsconfig.json'),
expect.stringContaining('packages/cli/index.ts'),
'--help',
]);
@@ -102,6 +104,8 @@ describe('scripts/dev.js launcher', () => {
const [command, args, options] = spawnMock.mock.calls[0];
expect(normalizePath(command)).toContain('tsx.cmd');
expect(args.map(normalizePath)).toEqual([
'--tsconfig',
expect.stringContaining('packages/cli/tsconfig.json'),
expect.stringContaining('packages/cli/index.ts'),
]);
expect(options).toEqual(expect.objectContaining({ shell: true }));
+1
View File
@@ -4618,6 +4618,7 @@ function ensureMinimalDist({
recursive: true,
});
writeFileSync(path.join(distPath, 'cli.js'), 'console.log("qwen");\n');
writeFileSync(path.join(distPath, 'codeModeHost.js'), 'export {};\n');
if (includeCliEntry) {
writeFileSync(path.join(distPath, 'cli-entry.js'), 'import "./cli.js";\n');
}