Skip to content

fix(core): always declare exit_plan_mode so plan mode can call it (#5210) - #5251

Merged
yiliang114 merged 2 commits into
QwenLM:mainfrom
yiliang114:fix/exit-plan-mode-deferred-5210
Jun 17, 2026
Merged

fix(core): always declare exit_plan_mode so plan mode can call it (#5210)#5251
yiliang114 merged 2 commits into
QwenLM:mainfrom
yiliang114:fix/exit-plan-mode-deferred-5210

Conversation

@yiliang114

@yiliang114 yiliang114 commented Jun 17, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

Makes exit_plan_mode always part of the tool schema list sent to the model, instead of a deferred tool the model has to load on demand. The one-line change is alwaysLoad: false → true on the ExitPlanModeTool; the rest is regression tests.

Why it's needed

In plan mode the system prompt tells the model to "present your plan by calling the exit_plan_mode tool". But exit_plan_mode was marked shouldDefer, so its schema was excluded from the function-declaration list — a separate prompt section says deferred tools must first be loaded via ToolSearch. So the model is told to call a tool that isn't actually in its tool list, and has to bridge two non-cross-referencing instructions on its own. Stronger models make the ToolSearch select:exit_plan_mode hop and recover; weaker ones (e.g. qwen3.7-max, the model in #5210) don't, and loop trying to call a tool they can't see — the "stuck on ExitPlanMode for hours" symptom. exit_plan_mode is the only deferred tool the prompt tells the model to call directly, so it's precisely the one that shouldn't be deferred. The deferral was introduced in #3589, which lines up with the reporter's "it used to work, recently it gets stuck".

Reviewer Test Plan

How to verify

  • Unit: npx vitest run packages/core/src/tools/exitPlanMode.test.ts packages/core/src/tools/tool-registry.test.ts. The new tests assert the real exit_plan_mode stays in getFunctionDeclarations() and drops out of getDeferredToolSummary().
  • Manual (TUI): run qwen --approval-mode plan -m qwen3.7-max and give a small coding task (e.g. "add a divide() to calc.js"). Expected: the model calls exit_plan_mode directly. Before the fix it first emits a ToolSearch select:exit_plan_mode call, and weaker models may stall there.

Evidence (Before & After)

Manual tmux run, plan mode, qwen3.7-max, same task. Before is the released 0.18.2; after is this branch built locally. Watched the tool calls the model makes between reading the file and presenting the plan.

Before (deferred):  ReadFile calc.js  →  ToolSearch select:exit_plan_mode (Loaded 1 tool)  →  ExitPlanMode
After  (this PR):   ReadFile calc.js  →  ExitPlanMode   (no ToolSearch hop)

Ran 3 cases on the fixed build (divide / power / "add input validation + plan tests"). The ToolSearch select:exit_plan_mode hop appeared 0 times across all three (it appeared on the released build). All three reached the plan-approval dialog directly; the validation case correctly used AskUserQuestion first to resolve an ambiguity, then exit_plan_mode.

Tested on

OS Status
🍏 macOS
🪟 Windows ⚠️ not tested
🐧 Linux ⚠️ not tested

Environment (optional)

Built locally with npm run build, run from packages/cli/dist. Unit tests via vitest.

Risk & Scope

  • Main risk or tradeoff: exit_plan_mode's full schema (including its ~1.4KB description) is now always in the tool list. Previously the deferred-tool summary carried only a truncated ~160-char first line of that description (truncateDeferredToolDescription), which it now drops out of. So the net cost is on the order of a few hundred tokens per request — cheap, and worth it for the one deferred tool the prompt tells the model to call directly.
  • Not validated / out of scope: doesn't touch the plan-mode prompt wording or the ToolSearch indirection for other deferred tools — exit_plan_mode is the only deferred tool the prompt tells the model to call directly, so the fix is scoped to it.
  • Breaking changes / migration notes: none. shouldDefer stays true so the tool is still categorised with the deferred set and ToolSearch select:exit_plan_mode keeps working (it just resolves an already-declared tool).

Linked Issues

Closes #5210

中文说明

这个 PR 做了什么

exit_plan_mode 始终出现在发给模型的工具声明列表里,而不是作为需要按需加载的 deferred 工具。核心改动只有一行:ExitPlanModeToolalwaysLoadfalse 改成 true,其余是回归测试。

为什么需要

在规划模式下,系统提示词让模型"调用 exit_plan_mode 工具来提交计划"。但 exit_plan_mode 被标记为 shouldDefer,导致它的 schema 被排除在 function-declaration 列表之外——另一段提示词又要求 deferred 工具必须先用 ToolSearch 加载。于是模型被要求调用一个根本不在它工具列表里的工具,还得自己把两段互不引用的指令对接起来。强模型会先走 ToolSearch select:exit_plan_mode 这一跳然后恢复;弱模型(比如 #5210 里的 qwen3.7-max)做不到,就会反复尝试调用一个它看不到的工具——这就是"卡在 ExitPlanMode 好几个小时"的症状。exit_plan_mode 是唯一一个被提示词要求"直接调用"的 deferred 工具,所以它恰恰是不该被 defer 的那个。这个 defer 行为是 #3589 引入的,和报告者说的"以前能用、最近卡住"对得上。

复审验证

  • 单测:npx vitest run packages/core/src/tools/exitPlanMode.test.ts packages/core/src/tools/tool-registry.test.ts。新增断言验证真实的 exit_plan_mode 留在 getFunctionDeclarations() 里、并从 getDeferredToolSummary() 中移除。
  • 手动(TUI):运行 qwen --approval-mode plan -m qwen3.7-max,给一个小的写代码任务(比如"给 calc.js 加一个 divide()")。预期模型直接调用 exit_plan_mode;修复前它会先发一个 ToolSearch select:exit_plan_mode,弱模型可能就卡在那里。

证据(前 / 后)

手动 tmux 实跑,规划模式,qwen3.7-max,同一任务。Before 是 released 0.18.2,After 是本分支本地 build:

修复前(deferred):ReadFile calc.js  →  ToolSearch select:exit_plan_mode(Loaded 1 tool)→  ExitPlanMode
修复后(本 PR):   ReadFile calc.js  →  ExitPlanMode   (没有 ToolSearch 那一跳)

在修复后的 build 上跑了 3 个 case(divide / power / "加参数校验并规划测试"),ToolSearch select:exit_plan_mode 这一跳出现 0 次(released build 上会出现)。3 个都直接走到计划确认框;校验那个 case 还正确地先用 AskUserQuestion 澄清了一个歧义再调用 exit_plan_mode

风险与范围

  • 主要权衡:exit_plan_mode 完整的 schema(含约 1.4KB 描述)现在常驻工具列表。此前 deferred-tool 摘要里只放了该描述被截断到约 160 字符的首行(truncateDeferredToolDescription),现在它从摘要里移除了。所以净增成本约为每次请求几百 token——很便宜,对这个唯一被提示词要求直接调用的 deferred 工具来说值得。
  • 未覆盖 / 范围外:没有改规划模式提示词,也没有改其它 deferred 工具的 ToolSearch 间接层——exit_plan_mode 是唯一被要求直接调用的 deferred 工具,所以修复仅限于它。
  • 破坏性改动:无。shouldDefer 仍为 true,工具仍归类在 deferred 集合里,ToolSearch select:exit_plan_mode 也照常工作(只是解析到一个已声明的工具)。

Copilot AI review requested due to automatic review settings June 17, 2026 13:50
@qwen-code-ci-bot

qwen-code-ci-bot commented Jun 17, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the PR @yiliang114!

Template looks good ✓ — all required sections present, bilingual, test plan included.

On direction: this fixes a real and well-documented bug. exit_plan_mode is the one deferred tool the system prompt tells the model to call directly, yet its schema isn't in the tool list — weaker models can't bridge that gap and loop. The diagnosis in #5210 is convincing, and the root cause (introduced in #3589) lines up with the "it used to work" report. Clearly within scope.

On approach: this is as minimal as it gets — one boolean flip (alwaysLoad: false → true), comments updated, and two regression tests. The ~1.4KB token cost per request is well-justified for the one tool the prompt explicitly names. No scope creep, no drive-by refactors. Removing the searchHint keywords is correct since the tool is now always declared.

Moving on to code review. 🔍

中文说明

感谢贡献 @yiliang114

模板完整 ✓ — 所有必需章节齐全,中英双语,包含测试计划。

方向:修复了一个真实且有充分记录的 bug。exit_plan_mode 是唯一一个被系统提示词要求直接调用的 deferred 工具,但它的 schema 却不在工具列表里——弱模型无法弥合这个差距就会陷入循环。#5210 的诊断有说服力,根因(#3589 引入)与"以前能用"的报告吻合。明确在项目范围内。

方案:改动极小——一个布尔值翻转(alwaysLoad: false → true),更新注释,加两个回归测试。每次请求约 1.4KB token 的成本,对这个唯一被提示词点名的工具来说完全合理。没有范围蔓延,没有顺手重构。移除 searchHint 关键词也是对的,因为工具现在始终声明了。

进入代码审查 🔍

Qwen Code · qwen3.7-max

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR fixes plan-mode behavior by ensuring the exit_plan_mode tool is always included in the function-declaration list sent to the model, even though it remains categorized as a deferred tool. This aligns the tool availability with the plan-mode prompt instruction that explicitly tells the model to call exit_plan_mode.

Changes:

  • Set ExitPlanModeTool to alwaysLoad: true while keeping shouldDefer: true, ensuring the schema is declared by default.
  • Add regression coverage to verify exit_plan_mode remains in getFunctionDeclarations() and is excluded from getDeferredToolSummary().
  • Add a focused unit assertion that ExitPlanModeTool is both deferred-category and always-loaded.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.

File Description
packages/core/src/tools/exitPlanMode.ts Makes exit_plan_mode always declared via alwaysLoad: true to unblock plan-mode completion.
packages/core/src/tools/exitPlanMode.test.ts Adds a regression assertion for the tool’s deferred-category + always-declared flags.
packages/core/src/tools/tool-registry.test.ts Adds a registry-level regression test ensuring exit_plan_mode is declared and not listed as deferred-summary.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@yiliang114
yiliang114 force-pushed the fix/exit-plan-mode-deferred-5210 branch from 647979c to 0a1040f Compare June 17, 2026 14:01
…enLM#5210)

exit_plan_mode was marked shouldDefer, so it was excluded from the function-declaration list sent to the model. In plan mode the prompt tells the model to call exit_plan_mode directly, but its schema was withheld until the model first discovered it via ToolSearch. Weaker models (e.g. qwen3.7-max) failed to make that extra ToolSearch hop and stalled indefinitely 'stuck on ExitPlanMode'.

Set alwaysLoad=true so the tool is always declared. Cost: its full description (~1.4KB) now rides in the declaration; the deferred-tool summary previously carried only a truncated ~160-char line of it (and now drops it), so the net add is a few hundred tokens per request — cheap for the one deferred tool the prompt tells the model to call directly. Add regression tests asserting the real tool stays in getFunctionDeclarations() and drops out of getDeferredToolSummary().
@yiliang114
yiliang114 force-pushed the fix/exit-plan-mode-deferred-5210 branch from 0a1040f to 4c6b8d3 Compare June 17, 2026 14:07
@yiliang114
yiliang114 enabled auto-merge (squash) June 17, 2026 14:19
@yiliang114

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@doudouOUC doudouOUC 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✅

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code review

The diff is clean and focused — exactly what the PR description promises:

  • ExitPlanModeTool constructor: alwaysLoad flipped from false to true, with an explanatory comment. The searchHint ('plan mode exit approve') is removed, which is correct — the tool is now always in the declaration list, so keyword-based discovery via ToolSearch is redundant.
  • exitPlanMode.test.ts: adds a regression test asserting shouldDefer === true && alwaysLoad === true. Clear intent, references the issue.
  • tool-registry.test.ts: registers a real ExitPlanModeTool and asserts it appears in getFunctionDeclarations() but not in getDeferredToolSummary(). This is the exact behavioral contract we want.

I verified the mechanism in tool-registry.ts (lines 674–678, 728–733): alwaysLoad tools are included in declarations and excluded from the deferred summary, regardless of shouldDefer. Same pattern used by ToolSearch itself and syntheticOutput. No correctness concerns.

Unit tests

All 64 tests pass (30 exitPlanMode + 34 tool-registry), including the 2 new regression tests:

 ✓ src/tools/exitPlanMode.test.ts (30 tests) 20ms
 ✓ src/tools/tool-registry.test.ts (34 tests) 112ms

 Test Files  2 passed (2)
      Tests  64 passed (64)

Real-scenario testing (tmux)

Before (installed qwen 0.18.2, plan mode, -p):

runner@runnervm1li68:/tmp/triage-5251-143030$ qwen --approval-mode plan -p 'Add a divide(a,b) function to calc.js' 2>&1 | tee /tmp/triage-5251-143030/before.log
Warning: Tool "exit_plan_mode" requires user approval but cannot execute in non-interactive mode.
To enable automatic tool execution, use the -y flag (YOLO mode):
Example: qwen -p 'your prompt' -y

The plan is ready but I'm unable to exit plan mode programmatically (permission declined in non-interactive mode). Here's the plan summary:

**`/tmp/triage-5251-143030/calc.js`** — add a `divide` function matching the existing style:

```js
function add(a, b) { return a + b; }
function divide(a, b) { return a / b; }
module.exports = { add, divide };

Verify: node -e "console.log(require('./calc.js').divide(10, 2))" → expect 5.


**After** (this PR via `npm run dev`, plan mode, `-p`):

runner@runnervm1li68:~/work/qwen-code/qwen-code/.qwen/worktrees/triage$ npm run dev -- --approval-mode plan -p 'Add a divide(a,b) function to calc.js' 2>&1 | tee /tmp/triage-5251-after/after2.log

@qwen-code/qwen-code@0.18.2 dev
node scripts/dev.js --approval-mode plan -p Add a divide(a,b) function to calc.js

DEV is set to true, but the React DevTools server is not running.

Warning: Tool "exit_plan_mode" requires user approval but cannot execute in non-interactive mode.
To enable automatic tool execution, use the -y flag (YOLO mode):
Example: qwen -p 'your prompt' -y

I'm blocked — plan mode requires confirmation to proceed, but non-interactive mode doesn't support the confirmation flow. Here's the plan I'd execute:

Summary: No calc.js exists in the repo. I'd create packages/cli/src/utils/calc.ts (matching the TypeScript project conventions and the existing math.ts style in that directory) with:

  • export const divide = (a: number, b: number): number — throws on division by zero
  • A collocated calc.test.ts with tests for normal division, zero divisor, negative numerator, and zero numerator
  • Same license header and JSDoc style as math.ts

Both runs reach `exit_plan_mode` and get the expected "requires user approval" denial in non-interactive mode — the tool is reachable in both builds. The default model (stronger than `qwen3.7-max`) handles the deferred→ToolSearch hop fine in the before build, so the ToolSearch hop isn't visible in these traces. The fix specifically helps weaker models that can't make that hop, which we can't easily reproduce here without the affected model.

**Verdict:** the mechanism is verified by unit tests and the code review confirms correctness. The E2E test confirms no regression — plan mode still functions normally.

<details>
<summary>中文说明</summary>

## 代码审查

Diff 干净且集中——与 PR 描述完全一致:

- `ExitPlanModeTool` 构造函数:`alwaysLoad` 从 `false` 改为 `true`,附有解释性注释。`searchHint`(`'plan mode exit approve'`)被移除,这是对的——工具现在始终在声明列表中,通过 ToolSearch 的关键词发现机制变得多余。
- `exitPlanMode.test.ts`:新增回归测试断言 `shouldDefer === true && alwaysLoad === true`,引用了 issue 编号。
- `tool-registry.test.ts`:注册真实的 `ExitPlanModeTool`,断言它出现在 `getFunctionDeclarations()` 中但不出现在 `getDeferredToolSummary()` 中。这正是我们想要的行为契约。

在 `tool-registry.ts`(674-678 行、728-733 行)验证了机制:`alwaysLoad` 工具无论 `shouldDefer` 如何都会被包含在声明中、并从 deferred 摘要中排除。与 `ToolSearch` 本身和 `syntheticOutput` 使用相同模式。没有正确性问题。

## 单元测试

全部 64 个测试通过(30 exitPlanMode + 34 tool-registry),包括 2 个新增回归测试。

## 实机测试(tmux)

前后两个版本都在非交互模式下到达了 `exit_plan_mode` 并收到预期的"需要用户批准"拒绝——工具在两个版本中都可访问。默认模型(比 `qwen3.7-max` 更强)在修复前能处理 deferred→ToolSearch 跳转,所以这些 trace 中看不到 ToolSearch 跳转。修复主要帮助无法完成该跳转的弱模型,在没有受影响模型的环境下难以复现。

**结论:** 机制已由单元测试验证,代码审查确认正确性。E2E 测试确认无回归——规划模式功能正常。

</details>

— *Qwen Code · qwen3.7-max*

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Stepping back: this is a textbook one-line fix for a well-understood bug. The diagnosis is sharp — exit_plan_mode is the only deferred tool the prompt tells the model to call directly, so it's precisely the one that shouldn't be deferred. The fix matches the diagnosis exactly: flip alwaysLoad to true, done.

The diff carries nothing beyond the fix and its regression tests. No drive-by refactors, no scope creep. The token cost (~1.4KB per request) is the right tradeoff — cheap insurance against the "stuck for hours" symptom weaker models hit.

My independent proposal would have been the same: flip alwaysLoad, add a test that asserts the tool stays in getFunctionDeclarations() and out of getDeferredToolSummary(). The PR's implementation matches that exactly, and the tests are well-written with clear issue references.

The tmux test confirms no regression. We can't reproduce the exact failure mode (weaker model looping on missing tool) without qwen3.7-max, but the unit tests prove the mechanism and the E2E test proves nothing broke.

Clean, minimal, correct. LGTM. ✅

中文说明

退一步看:这是一个教科书级的单行修复,针对一个理解清楚的 bug。诊断精准——exit_plan_mode 是唯一一个被提示词要求直接调用的 deferred 工具,所以它恰恰不该被 defer。修复与诊断完全一致:把 alwaysLoad 改为 true,搞定。

Diff 除了修复本身和回归测试外没有多余内容。没有顺手重构,没有范围蔓延。token 成本(每次请求约 1.4KB)是正确的权衡——用很小的代价防止弱模型遇到的"卡住好几个小时"问题。

如果我独立提出方案,也会是同样的做法:翻转 alwaysLoad,加一个测试断言工具留在 getFunctionDeclarations() 中并从 getDeferredToolSummary() 中移除。PR 的实现与此完全吻合,测试写得很好,issue 引用清晰。

tmux 测试确认无回归。我们无法在没有 qwen3.7-max 的情况下复现确切的失败模式(弱模型循环调用看不到的工具),但单元测试证明了机制正确,E2E 测试证明了没有破坏任何东西。

干净、精简、正确。LGTM。✅

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. ✅

@yiliang114
yiliang114 merged commit 7520cdd into QwenLM:main Jun 17, 2026
36 of 37 checks passed

@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.

No issues found. LGTM! ✅ — qwen3.7-max via Qwen Code /review

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.

0.18.1-ExitPlanMode卡住

5 participants