diff --git a/docs/design/code-mode-context-tools.md b/docs/design/code-mode-context-tools.md new file mode 100644 index 00000000000..eb5608b1b78 --- /dev/null +++ b/docs/design/code-mode-context-tools.md @@ -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. diff --git a/docs/design/code-mode-media-helpers.md b/docs/design/code-mode-media-helpers.md new file mode 100644 index 00000000000..a9e2c057da2 --- /dev/null +++ b/docs/design/code-mode-media-helpers.md @@ -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. diff --git a/docs/design/code-mode-only.md b/docs/design/code-mode-only.md new file mode 100644 index 00000000000..7b56e28ac28 --- /dev/null +++ b/docs/design/code-mode-only.md @@ -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.(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.(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. diff --git a/docs/design/codemode-output-recovery.md b/docs/design/codemode-output-recovery.md new file mode 100644 index 00000000000..029301afce1 --- /dev/null +++ b/docs/design/codemode-output-recovery.md @@ -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. diff --git a/docs/design/collapsible-tool-call-details.md b/docs/design/collapsible-tool-call-details.md new file mode 100644 index 00000000000..1012a3a3cef --- /dev/null +++ b/docs/design/collapsible-tool-call-details.md @@ -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. diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index 73e4e309bd6..b31005bab9c 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -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` | diff --git a/esbuild.config.js b/esbuild.config.js index 52da0653109..f9a5ff0d6c6 100644 --- a/esbuild.config.js +++ b/esbuild.config.js @@ -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)); diff --git a/package-lock.json b/package-lock.json index 5cfc9d2edf6..284bdffacf5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index f0bb1f1b369..59d0cde1b6c 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 51dc831badb..84b73674dc5 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -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, diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 82fe75a5a3e..f589eb6031d 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -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 => { - 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 => { + 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 => { + 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, ): Promise { const callId = fc.id ?? generatedCallId ?? `${fc.name}-${Date.now()}`; let args = (fc.args ?? {}) as Record; @@ -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, + nestedSignal: AbortSignal, + onResult?: (response: ToolCallResponseInfo) => void, + ): Promise => { + 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 + | 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' ? [ diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 493fa97fab5..bf88f8ebc18 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -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(); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index b22fc39691f..affdb3b8fc7 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -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). diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index 19441296560..c1adee6bffb 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -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; diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 7d2f9c6cbf6..550293cf4ae 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -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', diff --git a/packages/cli/src/config/settingsUtils.ts b/packages/cli/src/config/settingsUtils.ts index e8e5f730c56..c75fcd59987 100644 --- a/packages/cli/src/config/settingsUtils.ts +++ b/packages/cli/src/config/settingsUtils.ts @@ -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', diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index f8167e7ef98..dda46b54217 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -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', diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index 44c117e1a0f..7b1218a3415 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -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': '終端響鈴通知', diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 00817ceb872..b41b72c8311 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -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': '终端响铃通知', diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index e14e89668a8..3232447aeb5 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -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 diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 8fcb8b90936..c116a780ccd 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -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); } } diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index bcefca9f45b..05e027f40f2 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -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 + >(() => new Set()); + 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 ( @@ -5480,17 +5501,19 @@ export const AppContainer = (props: AppContainerProps) => { }} > - - - - - - - - - + + + + + + + + + + + diff --git a/packages/cli/src/ui/commands/contextCommand.test.ts b/packages/cli/src/ui/commands/contextCommand.test.ts index a1ca70a4aa8..9d5b713a6bd 100644 --- a/packages/cli/src/ui/commands/contextCommand.test.ts +++ b/packages/cli/src/ui/commands/contextCommand.test.ts @@ -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({ diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index 92c2ef7c497..0466e601938 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -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, +): 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(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 ; + + return ( + + ); +}; + function getHistoryItemMarginTop(item: HistoryItem): number { switch (item.type) { case 'gemini': @@ -417,7 +532,7 @@ const HistoryItemDisplayComponent: React.FC = ({ /> )} {itemForDisplay.type === 'tool_group' && ( - = ({ memoryReadCount={itemForDisplay.memoryReadCount} isUserInitiated={itemForDisplay.isUserInitiated} fullDetail={fullDetail} + expansionKey={itemForDisplay.batchId} /> )} {itemForDisplay.type === 'tool_use_summary' && ( diff --git a/packages/cli/src/ui/components/SettingsDialog.test.tsx b/packages/cli/src/ui/components/SettingsDialog.test.tsx index 12c98774de1..b9486119531 100644 --- a/packages/cli/src/ui/components/SettingsDialog.test.tsx +++ b/packages/cli/src/ui/components/SettingsDialog.test.tsx @@ -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( diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap index 1317ad23fa3..5338e261363 100644 --- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap @@ -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. │ diff --git a/packages/cli/src/ui/components/collapsible-tool-group-message.test.tsx b/packages/cli/src/ui/components/collapsible-tool-group-message.test.tsx new file mode 100644 index 00000000000..5a53bc0bf45 --- /dev/null +++ b/packages/cli/src/ui/components/collapsible-tool-group-message.test.tsx @@ -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( + + + + + , + { settings: collapsedSettings, config: {} as Config }, + ); + return { + ...view, + handler: vi.mocked(useMouseEvents).mock.calls.at(-1)?.[0], + }; +} + +describe('', () => { + it('renders the underlying hidden-details row', () => { + const { lastFrame } = renderWithProviders( + , + { 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( + (), expandBatch }} + > + + + + , + { 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( + + + + + , + { 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( + , + { 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( + , + { 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( + , + { 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( + , + { settings: collapsedSettings, config: {} as Config }, + ); + + expect(lastFrame()).not.toContain('click to expand'); + expect(vi.mocked(useMouseEvents).mock.calls.at(-1)?.[1]).toMatchObject({ + isActive: false, + }); + }); +}); diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx index e1a2582eff8..868d11184d2 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx @@ -406,6 +406,34 @@ describe('', () => { 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( + , + ); + + 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' }), diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index 12f70a6aa27..1bc3b8c4e6a 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx @@ -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; } +const HiddenToolDetailsSummary: React.FC<{ + toolCalls: IndividualToolCallDisplay[]; + contentWidth: number; + expandHint?: string; + summaryRef?: React.Ref; +}> = ({ 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 ( + + + + + {label} + {expandHint && ( + + {' '} + · {expandHint} + + )} + + + + + ); +}; + // Main component maps the tools using ToolMessage export const ToolGroupMessage: React.FC = ({ toolCalls, @@ -177,6 +232,9 @@ export const ToolGroupMessage: React.FC = ({ memoryReadCount, isUserInitiated, fullDetail = false, + hideDetails = false, + expandHint, + summaryRef, }) => { const config = useConfig(); const showToolCallArgs = useShowToolCallArgs(); @@ -294,6 +352,18 @@ export const ToolGroupMessage: React.FC = ({ const hasSubagentPendingConfirmation = subagentsAwaitingApproval.length > 0; + if (hideDetails) { + if (isPending && inlineToolCalls.length === 0) return null; + return ( + + ); + } + // Pure parallel agent group (≥2 agents, nothing else). // // Render through the SAME `inlineToolCalls` hand-off as every other group: diff --git a/packages/cli/src/ui/contexts/ToolDetailsExpandedContext.tsx b/packages/cli/src/ui/contexts/ToolDetailsExpandedContext.tsx new file mode 100644 index 00000000000..96652f2c940 --- /dev/null +++ b/packages/cli/src/ui/contexts/ToolDetailsExpandedContext.tsx @@ -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; + expandBatch: (batchId: string) => void; +} + +const EMPTY_IDS: ReadonlySet = new Set(); + +const ToolDetailsExpandedContext = createContext({ + expandedBatchIds: EMPTY_IDS, + expandBatch: () => {}, +}); + +export const useToolDetailsExpanded = (): ToolDetailsExpandedValue => + useContext(ToolDetailsExpandedContext); + +export const ToolDetailsExpandedProvider = ToolDetailsExpandedContext.Provider; diff --git a/packages/cli/src/ui/opentui/dialogs-settings.test.ts b/packages/cli/src/ui/opentui/dialogs-settings.test.ts index cb0e7290a1e..3de7cdcefe2 100644 --- a/packages/cli/src/ui/opentui/dialogs-settings.test.ts +++ b/packages/cli/src/ui/opentui/dialogs-settings.test.ts @@ -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(); diff --git a/packages/cli/src/utils/errors.ts b/packages/cli/src/utils/errors.ts index 82afca4a64b..fa2708918eb 100644 --- a/packages/cli/src/utils/errors.ts +++ b/packages/cli/src/utils/errors.ts @@ -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 { - // 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 diff --git a/packages/core/package.json b/packages/core/package.json index 8ab7dbdee04..0415a3d11b4 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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", diff --git a/packages/core/src/agents/arena/ArenaManager.test.ts b/packages/core/src/agents/arena/ArenaManager.test.ts index 4acfc07d229..c2d78ec0ad3 100644 --- a/packages/core/src/agents/arena/ArenaManager.test.ts +++ b/packages/core/src/agents/arena/ArenaManager.test.ts @@ -74,6 +74,7 @@ const createMockConfig = ( getSessionId: () => 'test-session', getUserMemory: () => '', getOutputStyle: (): ReturnType => 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 diff --git a/packages/core/src/agents/arena/ArenaManager.ts b/packages/core/src/agents/arena/ArenaManager.ts index 29fa3760d66..069532a1918 100644 --- a/packages/core/src/agents/arena/ArenaManager.ts +++ b/packages/core/src/agents/arena/ArenaManager.ts @@ -1112,6 +1112,7 @@ export class ArenaManager { 'headless', this.resolvePeerOutputStyle(), this.config.isTodoWriteEnabled(), + this.config.getCodeModeOnly(), ), contextFiles: this.config.getUserMemory(), }), diff --git a/packages/core/src/agents/runtime/agent-core.skill-gate.test.ts b/packages/core/src/agents/runtime/agent-core.skill-gate.test.ts index 493fa363395..b5e3122a9f4 100644 --- a/packages/core/src/agents/runtime/agent-core.skill-gate.test.ts +++ b/packages/core/src/agents/runtime/agent-core.skill-gate.test.ts @@ -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[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]); + }); }); }); diff --git a/packages/core/src/agents/runtime/agent-core.ts b/packages/core/src/agents/runtime/agent-core.ts index dd024d8ed33..44a4b1f40cd 100644 --- a/packages/core/src/agents/runtime/agent-core.ts +++ b/packages/core/src/agents/runtime/agent-core.ts @@ -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; 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, ): 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); diff --git a/packages/core/src/code-mode/code-mode.test.ts b/packages/core/src/code-mode/code-mode.test.ts new file mode 100644 index 00000000000..b077ea21e91 --- /dev/null +++ b/packages/core/src/code-mode/code-mode.test.ts @@ -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().push(frame)).toEqual([complete]); + const toolResultFrame = encodeFrame(toolResult); + expect(new FrameDecoder().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'); + }); +}); diff --git a/packages/core/src/code-mode/host-client.ts b/packages/core/src/code-mode/host-client.ts new file mode 100644 index 00000000000..5c12aecc48b --- /dev/null +++ b/packages/core/src/code-mode/host-client.ts @@ -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 { + 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(); + const nestedControllers = new Map(); + let stderr = ''; + let completed: CompleteMessage | undefined; + let terminating = false; + let protocolError: Error | undefined; + let wallTimer: ReturnType | 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((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 }), + }; +} diff --git a/packages/core/src/code-mode/host.ts b/packages/core/src/code-mode/host.ts new file mode 100644 index 00000000000..2cb6a50e891 --- /dev/null +++ b/packages/core/src/code-mode/host.ts @@ -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 { + 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 { + 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(); + const timers = new Map< + number, + { handle: ReturnType; 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((resolve) => { + resolveTermination = () => resolve(undefined); + }); + let rejectHostFailure!: (reason: Error) => void; + const hostFailure = new Promise((_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(); + 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, + }); + 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(); +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(); + } +}); diff --git a/packages/core/src/code-mode/output.test.ts b/packages/core/src/code-mode/output.test.ts new file mode 100644 index 00000000000..b838c966626 --- /dev/null +++ b/packages/core/src/code-mode/output.test.ts @@ -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); + }); +}); diff --git a/packages/core/src/code-mode/output.ts b/packages/core/src/code-mode/output.ts new file mode 100644 index 00000000000..d52ec0ec826 --- /dev/null +++ b/packages/core/src/code-mode/output.ts @@ -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) : ''); +} diff --git a/packages/core/src/code-mode/protocol.ts b/packages/core/src/code-mode/protocol.ts new file mode 100644 index 00000000000..f9957c55590 --- /dev/null +++ b/packages/core/src/code-mode/protocol.ts @@ -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; +} + +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 { + 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; + } +} diff --git a/packages/core/src/code-mode/scheduler.test.ts b/packages/core/src/code-mode/scheduler.test.ts new file mode 100644 index 00000000000..1af82a12b7a --- /dev/null +++ b/packages/core/src/code-mode/scheduler.test.ts @@ -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((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((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, + parent: { + callId: string; + name: string; + args: Record; + isClientInitiated: boolean; + prompt_id: string; + codeModeAllowedToolNames: readonly string[]; + }, + signal: AbortSignal, + ) => Promise; + } + ).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); +}); diff --git a/packages/core/src/code-mode/tool-call-runtime.ts b/packages/core/src/code-mode/tool-call-runtime.ts new file mode 100644 index 00000000000..f575112514d --- /dev/null +++ b/packages/core/src/code-mode/tool-call-runtime.ts @@ -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, + signal: AbortSignal, + onResult?: (response: ToolCallResponseInfo) => void, + ): Promise; +} + +const context = new AsyncLocalStorage(); + +export function runWithToolCallRuntime( + runtime: ToolCallRuntimeContext, + fn: () => T, +): T { + return context.run(runtime, fn); +} + +export function runWithoutToolCallRuntime(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( + source: { kind: 'code_mode' }, + callback: () => T, +): T { + return sourceStorage.run(source, callback); +} + +export function getCurrentToolCallSource(): { kind: 'code_mode' } | undefined { + return sourceStorage.getStore(); +} diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 07bd77b885e..fb7b2338ab9 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -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; 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 => { + 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'); diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index 0eeb4bde22a..3238f68fbec 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -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, ); }, ); diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index d41c0bc8acf..328b7047e39 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -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. diff --git a/packages/core/src/core/coreToolScheduler.test.ts b/packages/core/src/core/coreToolScheduler.test.ts index 3e52240eb6e..d2c3393958b 100644 --- a/packages/core/src/core/coreToolScheduler.test.ts +++ b/packages/core/src/core/coreToolScheduler.test.ts @@ -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([ + [ + '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), diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index b98aefe96fd..29ffb36091e 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -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([ + 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, + parent: ToolCallRequestInfo, + signal: AbortSignal, + onResult?: (response: ToolCallResponseInfo) => void, + ): Promise { + 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((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: ${ diff --git a/packages/core/src/core/prompts.test.ts b/packages/core/src/core/prompts.test.ts index cd33e625ba5..239873ac2a3 100644 --- a/packages/core/src/core/prompts.test.ts +++ b/packages/core/src/core/prompts.test.ts @@ -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.', () => { + const prompt = codeModePrompt(); + + expect(prompt).toContain('as `tools.(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( + '', + ); + 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.(args)'); + expect(prompt).not.toContain('**Batch Into One Program:**'); + }); +}); + describe('getCustomSystemPrompt', () => { it('should handle string custom instruction without user memory', () => { const customInstruction = diff --git a/packages/core/src/core/prompts.ts b/packages/core/src/core/prompts.ts index 683d66663a9..b29c04c067c 100644 --- a/packages/core/src/core/prompts.ts +++ b/packages/core/src/core/prompts.ts @@ -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.(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 `.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) + +user: 1 + 2 +model: 3 + + + +user: is 13 a prime number? +model: true + + + +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 });] + + + +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? + + + +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. + + + +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. + + + +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? + +`.trim(); + const qwenCoderToolCallExamples = ` # Examples (Illustrating Tone and Workflow) diff --git a/packages/core/src/core/turn.ts b/packages/core/src/core/turn.ts index eb7353d6d8a..8f284b2fe8d 100644 --- a/packages/core/src/core/turn.ts +++ b/packages/core/src/core/turn.ts @@ -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 = diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 41c83381a71..56cfc9e6bd4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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 diff --git a/packages/core/src/permissions/rule-parser.ts b/packages/core/src/permissions/rule-parser.ts index 34c86f93bc5..9907978965d 100644 --- a/packages/core/src/permissions/rule-parser.ts +++ b/packages/core/src/permissions/rule-parser.ts @@ -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> = { + // Exec tool + exec: 'exec', + Exec: 'exec', + ExecTool: 'exec', + // Shell tool run_shell_command: 'run_shell_command', Shell: 'run_shell_command', diff --git a/packages/core/src/telemetry/types.ts b/packages/core/src/telemetry/types.ts index fea74c6adb7..4fdb421d5a9 100644 --- a/packages/core/src/telemetry/types.ts +++ b/packages/core/src/telemetry/types.ts @@ -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; 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` / diff --git a/packages/core/src/tools/__snapshots__/shell.test.ts.snap b/packages/core/src/tools/__snapshots__/shell.test.ts.snap index ecd911768de..96095ef06b3 100644 --- a/packages/core/src/tools/__snapshots__/shell.test.ts.snap +++ b/packages/core/src/tools/__snapshots__/shell.test.ts.snap @@ -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: diff --git a/packages/core/src/tools/code-mode.ts b/packages/core/src/tools/code-mode.ts new file mode 100644 index 00000000000..95c2a18214d --- /dev/null +++ b/packages/core/src/tools/code-mode.ts @@ -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([ToolNames.TOOL_SEARCH, 'tool_call']); + +const DIRECT_ONLY_TOOLS = new Set([ + 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, +): 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, +): CodeModeBindingPlan { + const bindings: CodeModeToolBinding[] = []; + const collisions: CodeModeBindingPlan['collisions'] = []; + const claimed = new Map(); + 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 | undefined { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : 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;`; +} + +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.(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), + }; +} diff --git a/packages/core/src/tools/exec-context-budget.test.ts b/packages/core/src/tools/exec-context-budget.test.ts new file mode 100644 index 00000000000..81447443cfc --- /dev/null +++ b/packages/core/src/tools/exec-context-budget.test.ts @@ -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>() + .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 }, + ]); + }); +}); diff --git a/packages/core/src/tools/exec-context-tools.test.ts b/packages/core/src/tools/exec-context-tools.test.ts new file mode 100644 index 00000000000..3ecd555530d --- /dev/null +++ b/packages/core/src/tools/exec-context-tools.test.ts @@ -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, + 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( + 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; + 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((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); + }); +}); diff --git a/packages/core/src/tools/exec.ts b/packages/core/src/tools/exec.ts new file mode 100644 index 00000000000..a4f9d887fdb --- /dev/null +++ b/packages/core/src/tools/exec.ts @@ -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 { + 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 { + 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; + output: unknown; + }> = []; + const media: Part[] = []; + const metadata: Pick = {}; + const retainedTools = new Set([ + 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>(); + let goalBarrier: Promise = 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 { + 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); + } +} diff --git a/packages/core/src/tools/mcp-tool.test.ts b/packages/core/src/tools/mcp-tool.test.ts index a21215a67f2..63b472ce33a 100644 --- a/packages/core/src/tools/mcp-tool.test.ts +++ b/packages/core/src/tools/mcp-tool.test.ts @@ -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: '
Revenue
', toolArguments: { param: 'test' }, - fallbackText: 'Dashboard ready', + fallbackText: '{"revenue":42}\nDashboard ready', csp: { connectDomains: ['https://api.example.com'] }, permissions: { clipboardWrite: {} }, }); diff --git a/packages/core/src/tools/mcp-tool.ts b/packages/core/src/tools/mcp-tool.ts index 4482edeaf4d..6b6b7b09901 100644 --- a/packages/core/src/tools/mcp-tool.ts +++ b/packages/core/src/tools/mcp-tool.ts @@ -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 { diff --git a/packages/core/src/tools/read-file.test.ts b/packages/core/src/tools/read-file.test.ts index 978d104eb4a..14ffd980c06 100644 --- a/packages/core/src/tools/read-file.test.ts +++ b/packages/core/src/tools/read-file.test.ts @@ -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'); diff --git a/packages/core/src/tools/read-file.ts b/packages/core/src/tools/read-file.ts index 589f3dcfcbc..ba779484ae5 100644 --- a/packages/core/src/tools/read-file.ts +++ b/packages/core/src/tools/read-file.ts @@ -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 `` 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; diff --git a/packages/core/src/tools/shell.test.ts b/packages/core/src/tools/shell.test.ts index 84d48bc714f..0c7346aef59 100644 --- a/packages/core/src/tools/shell.test.ts +++ b/packages/core/src/tools/shell.test.ts @@ -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 () => { diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index 5594226fa47..f03177c5164 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -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: { diff --git a/packages/core/src/tools/skill.test.ts b/packages/core/src/tools/skill.test.ts index 19f85e7f796..ed7374ba2e1 100644 --- a/packages/core/src/tools/skill.test.ts +++ b/packages/core/src/tools/skill.test.ts @@ -1366,6 +1366,122 @@ describe('SkillTool', () => { expect(skillTool.getLoadedSkillContents()).toEqual(new Set([output])); }); + it.each([ + [undefined, ''], + ['Script failed after loading the skill', ''], + [undefined, '\nPostToolUse context'], + ])( + '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', diff --git a/packages/core/src/tools/skill.ts b/packages/core/src/tools/skill.ts index c6b14830a02..2e8cc9d9e90 100644 --- a/packages/core/src/tools/skill.ts +++ b/packages/core/src/tools/skill.ts @@ -362,10 +362,28 @@ export class SkillTool extends BaseDeclarativeTool { 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(); + const pendingExecCalls = new Set(); 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 { 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 { 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); } } } diff --git a/packages/core/src/tools/tool-names.ts b/packages/core/src/tools/tool-names.ts index 6afddbe7b05..10396d94d15 100644 --- a/packages/core/src/tools/tool-names.ts +++ b/packages/core/src/tools/tool-names.ts @@ -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', diff --git a/packages/core/src/tools/tool-registry.test.ts b/packages/core/src/tools/tool-registry.test.ts index 8e78ed79831..ce5ccebc56d 100644 --- a/packages/core/src/tools/tool-registry.test.ts +++ b/packages/core/src/tools/tool-registry.test.ts @@ -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 diff --git a/packages/core/src/tools/tool-registry.ts b/packages/core/src/tools/tool-registry.ts index 3510e9fa92e..c67f10a8e82 100644 --- a/packages/core/src/tools/tool-registry.ts +++ b/packages/core/src/tools/tool-registry.ts @@ -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; @@ -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 = new Set(); + private codeModeCollisionWarnings = new Set(); // 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, + ): 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, + ): 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); diff --git a/packages/core/src/tools/tool-response-finalizer.test.ts b/packages/core/src/tools/tool-response-finalizer.test.ts index edaec147854..1677bc920ad 100644 --- a/packages/core/src/tools/tool-response-finalizer.test.ts +++ b/packages/core/src/tools/tool-response-finalizer.test.ts @@ -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', [ diff --git a/packages/core/src/utils/fileUtils.test.ts b/packages/core/src/utils/fileUtils.test.ts index c3c9218c681..397537ce8e8 100644 --- a/packages/core/src/utils/fileUtils.test.ts +++ b/packages/core/src/utils/fileUtils.test.ts @@ -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({ diff --git a/packages/core/src/utils/fileUtils.ts b/packages/core/src/utils/fileUtils.ts index 1560e83381c..80e62a74a8a 100644 --- a/packages/core/src/utils/fileUtils.ts +++ b/packages/core/src/utils/fileUtils.ts @@ -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: { diff --git a/packages/node-repl/build.mjs b/packages/node-repl/build.mjs index 496f1d9c855..1be1fc3998d 100644 --- a/packages/node-repl/build.mjs +++ b/packages/node-repl/build.mjs @@ -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}`); diff --git a/packages/vscode-ide-companion/NOTICES.txt b/packages/vscode-ide-companion/NOTICES.txt index d0ee2bcf900..a08a8988ded 100644 --- a/packages/vscode-ide-companion/NOTICES.txt +++ b/packages/vscode-ide-companion/NOTICES.txt @@ -3552,6 +3552,86 @@ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +============================================================ +@jitl/quickjs-singlefile-mjs-release-sync@0.32.0 +(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/quickjs-ffi-types@0.32.0 +(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/client@2.0.0 (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. +============================================================ +quickjs-emscripten-core@0.32.0 +(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. + + ============================================================ sharp@0.35.4 (git+https://github.com/lovell/sharp.git) diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index d9a49cf0957..e7604e72889 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -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": [ { diff --git a/packages/web-shell/client/components/messages/toolFormatting.ts b/packages/web-shell/client/components/messages/toolFormatting.ts index 7455dd0e2ca..99fef5a9d11 100644 --- a/packages/web-shell/client/components/messages/toolFormatting.ts +++ b/packages/web-shell/client/components/messages/toolFormatting.ts @@ -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 = { + exec: 'Exec', edit: 'Edit', write_file: 'WriteFile', read_file: 'ReadFile', diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index 9f289274eaf..2ccf863c213 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -3789,6 +3789,7 @@ const ZH: Messages = { // Tool display names (chat-stream badge labels). Keyed by `toolName.`; // 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': '读取文件', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f20f87f71cf..684fca68648 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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/react@19.2.10)(react-devtools-core@6.1.5)(react@19.2.4) 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/node@20.19.1) @@ -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/quickjs-ffi-types@0.32.0': + resolution: {integrity: sha512-v9T+GQpmk43VDJ7d72sf0Nexhk+ArvtUihW27dy7lqAl0zBObFKtSBBIm5RBjwIhE8VwsPPm9PNuvPvNqLWUEg==} + + '@jitl/quickjs-singlefile-mjs-release-sync@0.32.0': + resolution: {integrity: sha512-h9g2Dri6WxKNjX6a1+sFAafOVxj1n5YIA1GAzMoSdIqK1M8cG07tz3GxKVC3Gbdutz14uQ3oc3Q2lll8otInhA==} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -8698,6 +8710,9 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + quickjs-emscripten-core@0.32.0: + resolution: {integrity: sha512-QFnPfjFey8EqknSrSxe1hZrf1/8z7/6s1QzGOmKo6++02r7QRRX7ZoyNaZh7JuVjWsVW87KnQrbZqnHkOAzUyg==} + radix-ui@1.6.2: resolution: {integrity: sha512-OwYUjzMwiInCUxgAWpPsavXC3Kh4iyi/49uU1/qZTG3RQDlvegyk1GOMiGvSkjua1RDb3JD3fo3eroL9FV4GQw==} peerDependencies: @@ -11323,6 +11338,12 @@ snapshots: dependencies: '@sinclair/typebox': 0.34.37 + '@jitl/quickjs-ffi-types@0.32.0': {} + + '@jitl/quickjs-singlefile-mjs-release-sync@0.32.0': + dependencies: + '@jitl/quickjs-ffi-types': 0.32.0 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -18724,6 +18745,10 @@ snapshots: queue-microtask@1.2.3: {} + quickjs-emscripten-core@0.32.0: + dependencies: + '@jitl/quickjs-ffi-types': 0.32.0 + radix-ui@1.6.2(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@radix-ui/primitive': 1.1.5 diff --git a/scripts/create-standalone-package.js b/scripts/create-standalone-package.js index ee33ed8f07b..40411f2eef4 100644 --- a/scripts/create-standalone-package.js +++ b/scripts/create-standalone-package.js @@ -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', diff --git a/scripts/dev.js b/scripts/dev.js index 70eae53595b..473eef5fb80 100755 --- a/scripts/dev.js +++ b/scripts/dev.js @@ -177,6 +177,8 @@ const tsxCmd = hasLocalTsxCli : tsxBinName; const tsxArgs = [ ...(hasLocalTsxCli ? [localTsxCli] : []), + '--tsconfig', + join(cliPackageDir, 'tsconfig.json'), cliEntry, ...process.argv.slice(2), ]; diff --git a/scripts/prepare-package.js b/scripts/prepare-package.js index bafbaa6c0be..0b0facd162d 100644 --- a/scripts/prepare-package.js +++ b/scripts/prepare-package.js @@ -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', diff --git a/scripts/tests/dev.test.js b/scripts/tests/dev.test.js index 894336112c9..5ee9d7e5d58 100644 --- a/scripts/tests/dev.test.js +++ b/scripts/tests/dev.test.js @@ -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 })); diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 9ad3962bcb5..0f8ef251ff5 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -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'); }