Skip to content

fix(core): use consistent error response for plan mode blocked tools - #6667

Merged
wenshao merged 3 commits into
QwenLM:mainfrom
Alex-ai-future:fix/plan-mode-block
Jul 11, 2026
Merged

fix(core): use consistent error response for plan mode blocked tools#6667
wenshao merged 3 commits into
QwenLM:mainfrom
Alex-ai-future:fix/plan-mode-block

Conversation

@Alex-ai-future

Copy link
Copy Markdown
Contributor

What this PR does

Changes the plan mode blocked tool response to use the same error format (createErrorResponse()) as all other tool failures, instead of returning a long system reminder text as an output field.

Why it's needed

When a non-read-only tool is called in plan mode, the tool is correctly blocked, but the LLM receives a { output: "<30-line system reminder text>" } response rather than { error: "..." }. The output key looks like a successful return to the LLM — there is no error key, no Error object, and no errorType. The LLM does not recognize this as a failure signal and may attempt alternative approaches to execute the blocked operation rather than understanding the restriction is plan mode.

The fix uses createErrorResponse() with a concise error message ("Tool blocked by plan mode: ...") so the LLM sees a clear { error: "..." } function response and understands the tool was denied.

Reviewer Test Plan

How to verify

Run the plan mode test suite:

cd packages/core && npx vitest run src/core/coreToolScheduler.test.ts -t "plan mode"

Confirm the blocked response contains { error: "Tool blocked by plan mode: ..." }, an Error object in the error field, and errorType: ToolErrorType.EXECUTION_DENIED.

Evidence (Before & After)

Before: responseParts contained { output: "<system-reminder>Plan mode is active...Iterative Planning Workflow..." } — ~30 lines of duplicate system prompt text.

After: responseParts contains { error: "Tool blocked by plan mode: \"write_file\" is not a read-only tool. Only read-only tools (read_file, grep_search, glob, list_directory, web_fetch, etc.) are allowed in plan mode. Call exit_plan_mode to exit plan mode and execute this tool." } — single clear error message.

Tested on

OS Status
🍏 macOS
🪟 Windows N/A
🐧 Linux N/A

Environment (optional)

N/A — unit test only.

Risk & Scope

  • Main risk or tradeoff: Removes getPlanModeSystemReminder() from the blocked response path. SDK and subagent callers previously received this text; they now get the same concise error message. The system reminder is already in the system prompt, so repeating it in the tool response is redundant.
  • Not validated / out of scope: E2E interactive testing with tmux (unit tests cover the response format).
  • Breaking changes / migration notes: None. The response format change is internal to the tool scheduler.

Linked Issues

N/A

中文说明

修复 plan mode 下工具被阻断时响应格式不一致的问题。

问题原理:非只读工具在 plan mode 下被正确阻断,但 LLM 收到的响应是 { output: "<30行 system reminder 文本>" } 而非 { error: "..." }output key 对 LLM 来说看起来像成功返回,没有 error key、没有 Error 对象、没有 errorType,导致 LLM 无法识别这是一个失败信号,可能会尝试换其他方式绕过阻断。

修复方案:统一使用 createErrorResponse() 返回 { error: "Tool blocked by plan mode: ..." } 格式,让 LLM 明确知道工具被 plan mode 拒绝。

测试packages/core 下 14 个 plan mode 相关测试全部通过,响应现在包含 error key + Error 对象 + EXECUTION_DENIED 类型。

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

Qwen precheck requires maintainer approval before automated triage/review.

Head SHA: 7b2e3e7265323d9c81b1c920d2d7949daf2a7b4b

Reason:

  • prompt_injection:system_prompt

A maintainer with write access can inspect the PR and manually request a run with @qwen-code /triage or @qwen-code /review. A new push requires a fresh precheck.

When a non-read-only tool is called in plan mode, the LLM receives
{ output: "<system reminder text>" } instead of { error: "..." }.
The output key looks like success to the LLM, so it does not recognize
the tool was denied and may try alternative approaches to bypass the
restriction.

Use createErrorResponse() so the LLM sees a clear error signal with
error key, Error object, and errorType: EXECUTION_DENIED.

Preserve differentiated guidance per caller type:
- Plan-required teammates: "Call exit_plan_mode to exit plan mode..."
- SDK / ordinary subagents: "Present your plan directly to the caller..."

Signed-off-by: Alex <alex.tech.lab@outlook.com>
@github-actions

Copy link
Copy Markdown
Contributor

Please do not rebase or force-push to an active PR as it invalidates existing review comments. Note for future reference, the bots always squash all changes into a single commit automatically as part of the integration.

中文

请勿对活跃的 PR 执行 rebase 或 force-push,因为这会使已有的评审评论失效。另外,供日后参考:作为集成流程的一部分,机器人始终会自动将所有改动压缩(squash)为单个提交。

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed — no blockers. Suggestions are inline.

// teammates have a dedicated exit_plan_mode approval path.
const isPlanRequiredTeammate =
!shouldUsePlanOnlyReminderInSubagentContext() &&
!this.config.getSdkMode();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Setting error and errorType here is the correct fix, but it causes a behavioral side effect: BaseJsonOutputAdapter.ts (lines 1039-1049) records into permission_denials whenever response.error is truthy and errorType === EXECUTION_DENIED. Plan-mode-blocked tool calls were previously invisible to that tracker (both fields were undefined). They will now appear alongside actual user-declined permission denials in SDK/non-interactive JSON output, with no discriminator field (e.g., reason: "plan_mode" vs reason: "user_declined").

If any SDK consumer iterates permission_denials to decide whether to re-prompt the user or abort, plan-mode blocks will be misclassified as user rejections.

Consider either filtering plan-mode blocks out of permissionDenials, or adding a discriminator to CLIPermissionDenial.

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jul 11, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓

Problem: observed in code. The old plan mode blocked path returned { output: "<system-reminder text>" } via convertToFunctionResponse with error: undefined and errorType: undefined — the LLM sees an output key with no error signal. The fix replaces this with createErrorResponse which returns { error: "..." } plus an Error object and ToolErrorType.EXECUTION_DENIED, matching every other tool failure path. No linked issue, but the before/after code diff is clear evidence the inconsistency exists.

Direction: aligned. Making plan mode blocks use the same error format as all other denials is a straightforward consistency improvement — the LLM already understands { error: ... } as a failure signal from every other blocked path (permission denied, non-interactive denied, background agent denied, etc.). No reason plan mode should be the exception.

Size: 32 production lines (18 additions + 14 deletions in coreToolScheduler.ts), 30 test lines (23 + 7 in coreToolScheduler.test.ts). Well under any thresholds.

Approach: minimal and focused. The change does one thing — swap convertToFunctionResponse + getPlanModeSystemReminder for createErrorResponse with a concise error message. The error message preserves the context-aware guidance (exit_plan_mode hint for plan-required teammates, "Present your plan directly" for SDK/subagent callers). The getPlanModeSystemReminder import is correctly removed since it's no longer used in this file (still used in client.ts). Tests updated to verify the new format.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓

问题:在代码中已观测到。旧的 plan mode 阻断路径通过 convertToFunctionResponse 返回 { output: "<system-reminder text>" }error: undefinederrorType: undefined——LLM 看到的是 output key,没有错误信号。修复改用 createErrorResponse,返回 { error: "..." } + Error 对象 + ToolErrorType.EXECUTION_DENIED,与其他所有工具失败路径一致。未关联 issue,但 before/after 代码对比已清楚说明不一致的存在。

方向:对齐。让 plan mode 阻断使用与其他所有拒绝路径(权限拒绝、非交互模式拒绝、后台 agent 拒绝等)相同的错误格式——LLM 已能识别 { error: ... } 为失败信号。plan mode 不应成为例外。

规模:32 行生产代码(coreToolScheduler.ts 中 18 增 + 14 删),30 行测试代码。远低于任何阈值。

方案:最小且聚焦。只做一件事——把 convertToFunctionResponse + getPlanModeSystemReminder 替换为 createErrorResponse + 简洁错误消息。错误消息保留了上下文感知引导(plan-required 队友提示 exit_plan_mode,SDK/subagent 提示直接呈现计划)。getPlanModeSystemReminder 导入已正确移除(仍被 client.ts 使用)。测试已更新验证新格式。

进入代码审查 🔍

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code Review

Independent proposal: Replace convertToFunctionResponse + getPlanModeSystemReminder in the isPlanModeBlocked path with createErrorResponse (already used in 22 other call sites in this file). Add a concise error message with context-aware guidance. Update tests.

Diff comparison: The PR matches this proposal exactly. The createErrorResponse call with ToolErrorType.EXECUTION_DENIED is the same pattern used by permission-denied, non-interactive-denied, and background-agent-denied paths. The context-aware branching (plan-required teammate → exit_plan_mode hint, SDK/subagent → "Present your plan directly") preserves the guidance that the old getPlanModeSystemReminder(true/false) provided. The resultDisplay override keeps the user-facing string unchanged.

Reuse check: createErrorResponse is the standard error response builder — the PR correctly reuses it instead of inventing a parallel path. getPlanModeSystemReminder is still used in client.ts (system prompt assembly), so the function itself stays.

No issues found. The change is correct, minimal, and consistent with the codebase.

Testing

Unit Tests

14 plan mode tests pass against the PR code:

 RUN  v3.2.4 packages/core
      Coverage enabled with v8

 ✓ src/core/coreToolScheduler.test.ts (258 tests | 244 skipped) 22ms

 Test Files  1 passed (1)
      Tests  14 passed | 244 skipped (258)
   Start at  13:16:32
   Duration  5.45s

Dev Mode Sanity Check

CLI runs correctly with the PR code — no crashes, tool scheduler operates normally:

> @qwen-code/qwen-code@0.19.8 dev
> node scripts/dev.js -p list files in the current directory using glob, just show the first 5 ts files

First 5 `.ts` files (by newest modification):

1. `vitest.config.ts`
2. `scripts/tests/vitest.config.ts`
3. `scripts/tests/test-setup.ts`
4. `scripts/tests/check-i18n.test.ts`
5. `scripts/sync-computer-use-schemas.ts`

Note on Real-Scenario Testing

This change modifies the internal error response format (what the LLM receives when plan mode blocks a tool), not user-visible terminal output. Plan mode entry requires interactive model decisions that can't be forced via -p prompt. The 14 unit tests comprehensively cover the response structure: error key presence, error message content, Error instance, errorType: EXECUTION_DENIED, and context-specific guidance for both plan-required teammates and SDK/subagent callers.

中文说明

代码审查

独立方案:isPlanModeBlocked 路径中的 convertToFunctionResponse + getPlanModeSystemReminder 替换为 createErrorResponse(该文件中已有 22 处使用)。添加简洁的错误消息和上下文感知引导。更新测试。

Diff 对比: PR 与独立方案完全一致。createErrorResponse + ToolErrorType.EXECUTION_DENIED 与权限拒绝、非交互模式拒绝、后台 agent 拒绝路径使用相同模式。上下文感知分支(plan-required 队友 → exit_plan_mode 提示,SDK/subagent → "直接呈现计划")保留了旧 getPlanModeSystemReminder(true/false) 的引导。resultDisplay 覆盖保持用户可见字符串不变。

复用检查: createErrorResponse 是标准错误响应构建器——PR 正确复用它而非创建并行路径。getPlanModeSystemReminder 仍在 client.ts 中使用(系统提示组装),函数本身保留。

未发现问题。变更正确、最小化,与代码库一致。

测试

单元测试

14 个 plan mode 测试在 PR 代码上全部通过。

Dev 模式健全性检查

CLI 在 PR 代码下正常运行——无崩溃,工具调度器正常工作。

关于真实场景测试的说明

此变更修改的是内部错误响应格式(LLM 在 plan mode 阻断工具时收到的内容),而非用户可见的终端输出。Plan mode 进入需要交互式模型决策,无法通过 -p 提示强制触发。14 个单元测试全面覆盖了响应结构。

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

This is a clean, well-scoped fix. The problem is real — plan mode was the only blocked path that returned { output: "..." } instead of { error: "..." }, making the LLM interpret the block as success and potentially try to work around it. The fix is exactly what I'd write: reuse createErrorResponse (already the standard error builder used 22 other times in this file), add a concise error message with context-aware guidance, update the tests.

The diff is minimal — 32 production lines doing one thing. No drive-by refactors, no scope creep. The getPlanModeSystemReminder import removal is correct (still used in client.ts). Tests verify every aspect of the new response format.

Approving.

中文说明

这是一个干净、范围良好的修复。问题是真实存在的——plan mode 是唯一一个返回 { output: "..." } 而非 { error: "..." } 的阻断路径,导致 LLM 将阻断解读为成功并可能尝试绕过。修复方式与我的独立方案一致:复用 createErrorResponse(该文件中已有 22 处使用的标准错误响应构建器),添加简洁的错误消息和上下文感知引导,更新测试。

Diff 最小化——32 行生产代码只做一件事。没有顺手重构,没有范围蔓延。getPlanModeSystemReminder 导入移除正确(仍在 client.ts 中使用)。测试验证了新响应格式的每个方面。

批准通过。

Qwen Code · qwen3.7-max

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, looks ready to ship. ✅

@wenshao

wenshao commented Jul 11, 2026

Copy link
Copy Markdown
Collaborator

✅ Local verification report (maintainer)

I built and tested this PR locally end-to-end. It behaves exactly as described and I recommend merging. Details below as a merge reference.

PR 6667 local verification report

Environment: macOS (darwin 24.6) · Node v22.23.1 · vitest v3.2.4 · isolated worktree at PR head 7b2e3e726

What I ran

# 1. Cited plan-mode suite
cd packages/core && npx vitest run src/core/coreToolScheduler.test.ts -t "plan mode"
#   → 14 passed | 244 skipped

# 2. Full test file (regression check)
npx vitest run src/core/coreToolScheduler.test.ts
#   → 258 passed (258)

# 3. Typecheck + lint on the change
npm run typecheck --workspace @qwen-code/qwen-code-core   # tsc --noEmit → pass
npx eslint packages/core/src/core/coreToolScheduler.ts \
           packages/core/src/core/coreToolScheduler.test.ts --max-warnings 0   # → 0 warnings

Behavioral evidence (real payload, not just assertions)

I drove the actual CoreToolScheduler.schedule() with a write_file call in plan mode and captured the model-facing responseParts on both the pre-fix source and this PR:

response part error object errorType
Before (main) { output: "<~30-line system reminder>" } undefined undefined
After (PR 6667) { error: "Tool blocked by plan mode: …" } Error(...) execution_denied

Before, the block is delivered under an output key with no error signal — indistinguishable from a successful tool return, which is exactly the "LLM tries to work around the restriction" problem this PR fixes. After, it's a clean { error } with errorType: execution_denied.

I also replayed the new assertion (expect(json).toContain('"error"')) against the pre-fix source and it fails — confirming the test genuinely guards the behavior rather than passing vacuously. The TUI line (resultDisplay: "Plan mode blocked a non-read-only tool call.") is unchanged.

Notes / observations (non-blocking)

  1. No guidance is lost. getPlanModeSystemReminder() is still injected into the system prompt (client.ts, acp .../Session.ts, nonInteractiveHelpers.ts), so removing it from the redundant tool response doesn't strip any instruction the model needs. ✔️ matches the PR's rationale.
  2. errorType: execution_denied now flows to two consumers that previously saw undefined — both traced through code:
    • BaseJsonOutputAdapter.emitToolResult (--output-format json/stream-json): a plan-mode block now correctly reports is_error: true and is recorded in permissionDenials. Previously it was mislabeled is_error: false — so this is an improvement consistent with the fix's intent.
    • handleToolError (nonInteractiveCli, text output): in non-interactive text mode a plan-mode block now emits the pre-existing "requires user approval … use the -y flag (YOLO mode)" warning. It's approval-centric wording rather than plan-mode-specific, but harmless (and -y/YOLO does override plan mode). Only surfaces in the narrow --approval-mode plan + non-interactive + text combo. Worth a glance, not a blocker.

Verdict: correct, well-scoped, well-tested, no regressions. 👍 LGTM.

🇨🇳 中文版本(点击展开)

✅ 本地验证报告(维护者)

我在本地完整构建并测试了这个 PR,行为与描述完全一致,建议合并。以下作为合并参考。

环境: macOS(darwin 24.6)· Node v22.23.1 · vitest v3.2.4 · 在 PR HEAD 7b2e3e726 的独立 worktree 中验证。

执行的命令

# 1. PR 里给出的 plan-mode 测试
cd packages/core && npx vitest run src/core/coreToolScheduler.test.ts -t "plan mode"
#   → 14 passed | 244 skipped

# 2. 整个测试文件(回归检查)
npx vitest run src/core/coreToolScheduler.test.ts
#   → 258 passed (258)

# 3. 改动文件的类型检查 + lint
npm run typecheck --workspace @qwen-code/qwen-code-core   # tsc --noEmit → 通过
npx eslint <两个改动文件> --max-warnings 0                 # → 0 warning

行为层面的证据(真实 payload,不只是断言)

我用一个 plan mode 下的 write_file 调用驱动了真实的 CoreToolScheduler.schedule(),分别在修复前源码和本 PR 上抓取了发给模型的 responseParts

response 内容 error 对象 errorType
修复前 (main) { output: "<约 30 行 system reminder>" } undefined undefined
修复后 (PR 6667) { error: "Tool blocked by plan mode: …" } Error(...) execution_denied

修复前,阻断信息放在 output 字段里、没有任何错误信号,与一次成功的工具返回无法区分 —— 这正是本 PR 要解决的"LLM 以为成功、进而尝试绕过限制"的问题。修复后是干净的 { error },并带有 errorType: execution_denied

我还把新断言(expect(json).toContain('"error"'))回放到修复前源码上,结果失败 —— 说明这个测试确实能守住该行为,而不是空过。TUI 上显示的那一行(resultDisplay)前后没有变化。

补充观察(不影响合并)

  1. 没有丢失任何引导信息。 getPlanModeSystemReminder() 仍然被注入到系统提示词里(client.tsacp .../Session.tsnonInteractiveHelpers.ts),所以把它从冗余的工具响应里移除,不会丢掉模型需要的任何指令。✔️ 与 PR 的说明一致。
  2. errorType: execution_denied 现在会流向两个之前拿到 undefined 的消费方(均已顺代码确认):
    • BaseJsonOutputAdapter.emitToolResult--output-format json/stream-json):plan-mode 阻断现在会正确标记为 is_error: true 并记入 permissionDenials。之前被错误标记为 is_error: false,所以这其实是一个符合本次修复意图的改进
    • handleToolErrornonInteractiveCli,text 输出):在非交互 text 模式下,plan-mode 阻断现在会触发既有的*"需要用户批准……使用 -y (YOLO) 参数"*提示。措辞偏"审批"而非"plan 模式",但无害(且 -y/YOLO 确实会覆盖 plan 模式)。仅在 --approval-mode plan + 非交互 + text 这一狭窄组合下出现。可留意,但不阻塞合并。

结论: 正确、范围清晰、测试充分、无回归。👍 LGTM。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants