fix(core): ignore duplicate provider tool-call ids - #5038
Conversation
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
wenshao
left a comment
There was a problem hiding this comment.
Scope note: The interactive TUI path (packages/cli/src/ui/hooks/useGeminiStream.ts) has no providerCallId or pre-execution duplicate detection logic. If a model re-emits the same provider tool-call ID in the TUI, the tool still executes with full side effects before the post-execution dedup drops the result from the conversation. Consider adding analogous pre-execution dedup to the TUI path, or documenting that it is intentionally out of scope.
| const toolName = String(fc.name); | ||
|
|
||
| if (providerCallId) { | ||
| if (handledProviderToolCallIds.has(providerCallId)) { |
There was a problem hiding this comment.
[Suggestion] Zero debug logging when a duplicate provider tool call is detected and suppressed. If a model repeatedly emits the same tool-call ID, the only observability is via emitted events — no log trail exists in --debug output or log files. This makes production debugging at 3 AM difficult.
Consider adding a debug log at the detection point:
this.runtimeContext.getDebugLogger()?.debug(
`[processFunctionCalls] Suppressing duplicate provider tool-call id: ${providerCallId} (tool: ${toolName}, round: ${currentRound})`,
);Same applies to the CLI path in nonInteractiveCli.ts (around line 763) using debugLogger.debug(...).
— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Addressed in 0972eeaed: duplicate suppression now logs debug messages in AgentCore and in the non-interactive path. I also added the same lightweight debug log to the new TUI pre-schedule suppression path so all three execution entrypoints leave a trace when a provider id is ignored.
qqqys
left a comment
There was a problem hiding this comment.
[Critical] The duplicate-provider-id guard does not cover the interactive TUI path.
This PR preserves providerCallId in Turn and suppresses duplicates in non-interactive/headless flows before execution, but useGeminiStream.ts still collects every ToolCallRequest and calls scheduleToolCalls(toolCallRequests, signal) with no provider-id guard. The existing history/functionResponse dedup in handleCompletedTools only runs after the scheduler has already executed the tool, so an interactive session can still execute the same provider tool-call ID twice and only drop or repair the duplicate result afterward.
Impact: the main TUI path can repeat side-effecting tools such as shell, edit/write, or hook-triggering calls when a provider re-emits the same tool-call ID across rounds. That is the core failure mode this PR is meant to prevent, and it is user-visible/session-corrupting for normal interactive usage.
Please add the same pre-execution handledProviderToolCallIds suppression to the TUI stream path, or commonize the duplicate filtering before any path reaches CoreToolScheduler.schedule, so duplicate provider calls emit a synthetic tool response without scheduling local side effects.
|
Addressed the interactive TUI scope gap in Local verification:
|
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
qqqys
left a comment
There was a problem hiding this comment.
Critical blocker: the latest head does not typecheck in packages/core/src/agents/runtime/agent-core.ts. The new emitSyntheticToolError parameter type declares errorMessage twice and no longer declares responseParts, but the implementation still reads params.responseParts and both call sites pass responseParts. That leaves the core package with hard TypeScript errors on the main agent/subagent runtime path, so CI/build cannot pass until the helper parameter shape is restored (for example errorMessage: string plus responseParts: Part[]).
|
@YingchaoX heads up — this PR currently has merge conflicts with Conflicting files:
The rest merges cleanly. Thanks! 中文@YingchaoX 提个醒 —— 这个 PR 目前和 冲突文件:
其余文件可以自动合并。谢谢! |
qqqys
left a comment
There was a problem hiding this comment.
Prior critical issues are resolved in the latest head: the interactive TUI path now suppresses duplicate provider tool-call IDs before scheduling, duplicate-only TUI batches submit a synthetic tool response, and the AgentCore synthetic error parameter shape/typecheck blocker has been fixed. CI is green, and I did not find any remaining critical issue in this pass.
✅ Verification report — deterministic mock-provider E2E + completeness auditI validated this PR by building How it was validated
E2E evidence —
|
| 范围 | 方法 | 结果 |
|---|---|---|
| 聚焦单测 | vitest(PR 列出的文件) |
通过 —— core turn/agent-headless/toolCallIdUtils 63 过(2 个既有 skip);cli nonInteractiveCli 51(1 skip)、useGeminiStream 110 |
| E2E — 重复抑制 | 构建 dist/cli.js + mock 两次返回 shell_1;--approval-mode yolo |
第一次执行,第二次被抑制(synthetic error),副作用恰好写入 1 次,tools.totalCalls=1,exit 0 |
| E2E — 预算 | --max-tool-calls 1 + 重复 |
运行成功,totalCalls=1,写入 1 次(被忽略的 duplicate 不消耗预算) |
| E2E — 精确性对照 | mock 返回 shell_1 再返回 shell_2(不同 id) |
都执行,写入 2 次,totalCalls=2,无误抑制 |
| E2E — 无 provider id | mock 两轮都不带 id |
都执行(生成的本地 id 唯一),写入 2 次 —— 不去重,符合声明 |
| 完整性审计 | qwen serve daemon 跑同一个 mock |
E2E 证据 —— qwen -p 跑重复 mock(本 PR 的主修复)
真实 --output-format json transcript(节选):
// 第 1 轮:shell_1 执行
{"role":"assistant","content":[{"type":"tool_use","id":"shell_1","name":"run_shell_command", ...}]}
{"role":"user","content":[{"type":"tool_result","tool_use_id":"shell_1","is_error":false,"content":"...Exit Code: 0..."}]}
// 第 2 轮:provider 再次发 shell_1 → 规范化 id 被加后缀、provider id 被保留 → 抑制
{"role":"assistant","content":[{"type":"tool_use","id":"shell_1__qwen_dup_2","name":"run_shell_command", ...}]}
{"role":"user","content":[{"type":"tool_result","tool_use_id":"shell_1__qwen_dup_2","is_error":true,
"content":"Duplicate provider tool call id \"shell_1\" was already handled. ..."}]}
// 第 3 轮:成功
{"type":"result","is_error":false,"result":"All done. ...","stats":{"tools":{"totalCalls":1,...}}}副作用文件恰好只有一个 HIT。机制是可靠的:normalizeModelToolCallIds 给跨轮重复的 callId 加后缀(shell_1__qwen_dup_2,正是从前导致重复执行的根源),同时把原始 provider id 存到一个不可枚举的 Symbol 上;agent-core / nonInteractiveCli / useGeminiStream 从 getHistoryFunctionResponseIds() 播种 handledProviderToolCallIds,按 provider id 抑制。干净利落,synthetic 结果也正是 PR 描述的 error 形状。
🟠 发现 1 —— qwen serve / ACP daemon 路径未覆盖;#5014 在那里仍复现 · 中
修复接入了三个 agentic loop(agent-core.ts:664、nonInteractiveCli.ts:741、useGeminiStream.ts:1687),但没有接入 ACP session loop。Session.runToolCalls(packages/cli/src/acp-integration/session/Session.ts:2794)仍然只做批内 dedupeToolCallsById(functionCalls)(:2801)—— 没有 getProviderToolCallId、没有 handledProviderToolCallIds、没有 getHistoryFunctionResponseIds 播种。(PR 对该文件的唯一改动是 Session.test.ts 的 prettier 重排。)
我做了端到端确认:把同一个重复 shell_1 的 mock 通过 qwen serve 跑(POST /session → POST /session/:id/prompt)。mock 提供了两轮 shell,两轮都执行 → 副作用写入 2 次,而 qwen -p 只写 1 次。也就是说,通过 web-shell 或 ACP/IDE 客户端(如 Zed)触发 #5014 的用户仍然暴露在该问题下。建议把同样的 provider-id 抑制扩展进 Session.runToolCalls,或在 PR 描述里显式把 daemon 路径列为后续项(目前是无限定的 "Fixes #5014")。
🟡 发现 2 —— 修复 commit 里有未说明的范围蔓延 · 低–中
修复 commit(4fedaa3ba)还改了 packages/core/src/utils/filesearch/crawler.ts(+61:新增 collectDirectoryRows,让目录出现在文件搜索/@ 补全结果里)以及配套的 useAtCompletion.test.ts,外加 truncation.ts / toolResultCleanup.ts 的纯 prettier 重排。这些在 PR(标题/范围仅限重复 tool-call id)里只字未提。它是增量的(没有回退近期 #4596 的子模块递归工作,filesearch 测试通过),所以不是回归 —— 但它扩大了一个 bugfix PR 的评审/风险面。建议把 crawler/@ 补全改动拆出,或加以说明。
⚪ 发现 3 —— 去重逻辑在多个 loop 中重复 · 提示
同样的 provider-id 抑制被手写了三处(按发现 1,第四处缺失)。抽成一个共享 helper 能同时避免 daemon 路径漏改和将来各副本之间的漂移。
建议
修复本身是正确的,就其覆盖的 CLI 路径而言我会赞成合并 —— 确定性 E2E 证明了重复抑制、预算不消耗、以及精确性(无过度抑制)。在认定 #5014 彻底修复前,请把抑制扩展到 Session.runToolCalls(daemon/ACP),或重新限定 PR 范围;并考虑把无关的 crawler 改动拆出。
基于 665014e93 验证(增量 = commit 4fedaa3ba + 665014e93)。core+cli dist 从 PR 源码全新构建;E2E 用 node packages/cli + 仓库 .qwen/skills/e2e-testing/scripts/mock-openai-server.js(重复 shell_1 场景)经 tmux;daemon 路径走 qwen serve HTTP API。daemon 日志里的 craft/drainMidTurnQueue"method not found" 是既有的、无关的 ACP 警告。
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
wenshao
left a comment
There was a problem hiding this comment.
No issues found. LGTM! ✅ — qwen3.7-max via Qwen Code /review
DragonnZhang
left a comment
There was a problem hiding this comment.
Well-designed fix for duplicate provider tool-call IDs. Uses a non-enumerable Symbol (PROVIDER_TOOL_CALL_ID) to preserve the original provider-emitted id alongside the internally-generated normalized id. ToolCallRequestInfo gains providerCallId for dedup at execution time. createDuplicateProviderToolCallResponse returns a clean error response without executing the duplicate. Comprehensive test coverage in toolCallIdUtils.test.ts and turn.test.ts. Also includes doc formatting fixes and filesearch/crawler.ts cleanup. CI green. LGTM ✅ — claude-opus-4-6 via Qwen Code /review
🧪 Local runtime verification (real CLI + mock provider, tmux)I reproduced the PR's described E2E against the locally-built bundle ( TL;DR
Evidence 1 — Real-CLI A/B (side-effect writes are the ground truth)
The neutered row is the clincher: I surgically forced the suppression off and rebuilt — the same duplicate then executed the shell twice, exactly reproducing #5014; restoring the guard returned it to one write. On the wire, the second round's tool result sent back to the model is the synthetic error (note the suffixed callId vs. the raw provider id as the idempotency key): Evidence 2 — Design is sound (no false dedup)The raw provider id is stashed on the function call via a non-enumerable Evidence 3 — All three execution paths + test teethThe same
Scope note (not a correctness issue)
Verdict✅ Approve the fix. It correctly makes exact-duplicate provider tool-call ids idempotent within a loop, stops the #5014 repeated-side-effect amplification (verified: 2 → 1 write), doesn't over-dedup distinct calls, and doesn't waste the tool-call budget. Suggest splitting the unrelated 🇨🇳 中文版(点击展开)🧪 本地运行验证(真实 CLI + mock provider,tmux)我用本地构建的 bundle( 结论速览
证据 1 —— 真实 CLI A/B(副作用写入次数是 ground truth)
“关掉去重”那一行是关键:我手术式把抑制关掉再重建,同一个重复就把 shell 执行了两次,精确复现 #5014;恢复后又回到一次写入。 线上看,第二轮回传给模型的 tool result 是 synthetic error(注意后缀化的 callId 与作为幂等键的 raw provider id): 证据 2 —— 设计正确(不会误去重)raw provider id 通过一个不可枚举的 证据 3 —— 三条执行路径 + 测试牙齿同一套
范围提醒(非正确性问题)
结论✅ 同意合并这个修复。 它正确地让“完全相同的 provider tool-call id”在一个 loop 内幂等,止住了 #5014 的副作用放大(已验证:2 → 1 次写入),不会误去重不同的调用,也不会浪费 tool-call 预算。建议合并前把无关的 Method: locally-built |
|
@copilot resolve the merge conflicts in this pull request |
2 similar comments
|
@copilot resolve the merge conflicts in this pull request |
|
@copilot resolve the merge conflicts in this pull request |
|
@qwen-code /triage |
qqqys
left a comment
There was a problem hiding this comment.
Prior critical blockers remain resolved on the latest head. The duplicate provider tool-call guard now covers the TUI, non-interactive/headless, AgentCore, and ACP session paths before scheduling local side effects; the earlier typecheck blocker is also gone, and current CI is green. I did not find any new critical issue in this pass.
|
Thanks for the PR! Template looks good ✓ — all required sections filled in with bilingual detail. On direction: this is a clear, real bug fix. #5014 shows models returning the same provider tool-call ID across rounds, amplifying one mistake into hundreds of repeated shell executions. That's a safety-relevant issue for any user running with On approach: the Symbol-based provider ID preservation + One concern: the diff carries an unrelated Moving on to code review and testing. 🔍 中文说明感谢贡献! 模板完整 ✓ — 所有必填章节都有双语详细说明。 方向:这是一个明确的、真实的 bug 修复。#5014 显示模型跨轮次返回相同的 provider tool-call ID,把一次错误放大为数百次重复 shell 执行。对于使用 方案:基于 Symbol 的 provider ID 保留 + 一个顾虑:diff 中包含无关的 进入代码审查和测试阶段 🔍 — Qwen Code · qwen3.7-max |
Code ReviewIndependent proposal before reading the diff: I'd track provider tool-call IDs via a non-enumerable Symbol on the FunctionCall object, add The PR's approach matches this exactly. The Symbol-based storage in The Session.ts daemon path is now properly covered (earlier reviewers flagged this gap — it's been closed). The No correctness bugs or security issues found. Tests are thorough — 63 core tests, 158 Session tests, 123 useGeminiStream tests, 51 nonInteractiveCli tests, all passing. Build and typecheck clean. One note on the E2E Test ResultsReal CLI against a mock OpenAI server returning the same provider tool-call ID ( Before (installed qwen v0.18.3, main branch)Full transcript key excerpt: {"type":"tool_use","id":"shell_1","name":"run_shell_command",...}
{"type":"tool_result","tool_use_id":"shell_1","is_error":false,...}
{"type":"tool_use","id":"shell_1__qwen_dup_2","name":"run_shell_command",...}
{"type":"tool_result","tool_use_id":"shell_1__qwen_dup_2","is_error":false,...}Both tool calls executed. The ID was renamed ( After (PR #5038 via
|
ReflectionStepping back: this PR solves a real, safety-relevant bug with a clean mechanism. The Symbol-based provider ID preservation is the right abstraction — it's invisible to JSON serialization, survives object spreads, and has a single read accessor. The suppression logic is consistent across all four execution paths, and the earlier reviewers' concern about the daemon/ACP path has been addressed. The E2E evidence is decisive: before = 2 shell executions / My independent proposal matched the PR's approach almost exactly, which gives me confidence the design is sound. The test suite has real teeth — neutering The one reservation is the Overall: the fix is correct, well-tested, and ships a meaningful safety improvement. The scope creep is minor and non-blocking. Verdict: Approve ✅ — with a request to split the 中文说明反思退一步看:这个 PR 用干净的机制解决了一个真实的、安全相关的 bug。基于 Symbol 的 provider ID 保留是正确的抽象——对 JSON 序列化不可见,在对象展开时存活,并且有单一的读取访问器。抑制逻辑在所有四条执行路径上一致,早期评审者关于 daemon/ACP 路径的顾虑也已解决。 E2E 证据是决定性的:before = 2 次 shell 执行 / 我的独立方案与 PR 的方案几乎完全一致,这给了我对设计正确性的信心。测试套件有真正的牙齿——禁用 唯一的保留意见是 总结:修复正确、测试充分、带来了有意义的安全改进。范围蔓延很小,不阻塞合并。 结论:批准 ✅ — 建议尽可能把 — Qwen Code · qwen3.7-max |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship. ✅ The fix correctly suppresses duplicate provider tool-call IDs across all four execution paths (verified E2E: 2→1 execution, totalCalls=1). Suggest splitting the unrelated crawler.ts change into a separate PR when convenient.
…matching args The duplicate provider tool-call guard (#5038/#5657) keyed on the id alone, so models whose ids are only unique within a single response — e.g. Kimi emits {name}_{index} and the index can restart at 0 on any round — had fresh calls misclassified as replays: the second collision got a synthetic duplicate error and the third tripped the circuit breaker, killing every turn by round three. A handled id now maps to a (name, canonical args) fingerprint — the same sha256 repeat key the loop guards use, moved to a leaf module so toolCallIdUtils can share it without an import cycle. An incoming call is a replay only when its fingerprint matches the call that first executed under that provider id; id collisions with different args execute normally under the unique suffixed id that normalization already assigns. Exact same-args replays keep the unchanged #5014 suppression and #5657 breaker behavior at all four entry points (AgentCore, TUI stream, non-interactive CLI, ACP daemon session). The synthetic duplicate message now tells the model to re-issue with a fresh tool-call id when a new invocation was intended, giving id-emitting models a recovery path.
…n arguments match (QwenLM#9436) * fix(core): treat duplicate provider tool-call ids as replays only on matching args The duplicate provider tool-call guard (QwenLM#5038/QwenLM#5657) keyed on the id alone, so models whose ids are only unique within a single response — e.g. Kimi emits {name}_{index} and the index can restart at 0 on any round — had fresh calls misclassified as replays: the second collision got a synthetic duplicate error and the third tripped the circuit breaker, killing every turn by round three. A handled id now maps to a (name, canonical args) fingerprint — the same sha256 repeat key the loop guards use, moved to a leaf module so toolCallIdUtils can share it without an import cycle. An incoming call is a replay only when its fingerprint matches the call that first executed under that provider id; id collisions with different args execute normally under the unique suffixed id that normalization already assigns. Exact same-args replays keep the unchanged QwenLM#5014 suppression and QwenLM#5657 breaker behavior at all four entry points (AgentCore, TUI stream, non-interactive CLI, ACP daemon session). The synthetic duplicate message now tells the model to re-issue with a fresh tool-call id when a new invocation was intended, giving id-emitting models a recovery path. * qwen: address PR review feedback (QwenLM#9436) - Fingerprint each incoming call once per carrier object: the WeakMap cache now keys on any stable carrier (FunctionCall part or ToolCallRequestInfo), and the replay predicate / recording helpers take the precomputed fingerprint instead of rehashing (name, args) on every breaker scan, admission pass, and record. - Move the getToolCallRepeatKey tests next to the extracted leaf module instead of exercising it through the loop detection service's compatibility re-export. - Restore the assertion pinning that runToolCalls never mutates the history accessor's returned fingerprint map, now that the defensive copy is load-bearing. * qwen: address PR review feedback (QwenLM#9436) Criticals: - Canonicalize onto a null-prototype object so a literal __proto__ own key (preserved by JSON.parse) stays a data property instead of vanishing through the inherited setter — two calls differing only in __proto__ no longer collide on one repeat key, which the replay oracle would have turned into a wrongly suppressed execution. - Clone request args at scheduler intake: callers pass args that can alias the model-emitted functionCall part stored in chat history, and the executor rewrites PATH_ARG_KEYS on request.args in place (a persistence the post-'ask' bounce re-execution relies on). Without the clone those rewrites leak into history and skew the replay fingerprints derived from it, letting genuine replays of path-carrying calls re-execute in multi-round agent runtimes. Suggestions: - Complete the duplicate message with the different-arguments recovery path required by design decision D3, for models whose provider assigns ids. - Fix the copy-convention comments (the accessor returns a fresh map per call; copies are future-proofing) and align all four entry points on copying the accessor result. - Pin the untested branches: history first-occurrence-wins for reused ids and orphan response-id exclusion, the fingerprint cache-hit path, the __proto__ distinction, the caller-args no-mutation invariant, and a cross-round runtime test that a replay of the original call stays suppressed after an id-colliding execution.
What this PR does
This PR makes provider-supplied tool-call IDs idempotent within a single agentic loop. When the model/provider returns the same tool-call ID more than once, the first call executes normally and later duplicates are answered with a synthetic tool result explaining that the duplicate was ignored, without running the tool again.
The fallback/generated local call ID is kept separate from the provider ID, so calls without a provider-supplied ID continue through the existing path and are not deduplicated across rounds by a generated value.
Why it's needed
Issue #5014 shows a model repeatedly returning the same provider tool-call ID across tool-result rounds, causing side-effect tools such as shell commands to execute over and over. This can amplify one model mistake into many repeated writes or shell executions.
This fix addresses only the exact duplicate provider-ID case. Semantically similar calls with different IDs, such as the related loop-breaker cases discussed in #5015 / #4695, remain out of scope for this PR.
Reviewer Test Plan
How to verify
Run the focused regression tests for provider tool-call ID preservation and duplicate suppression:
Run build/typecheck for the touched packages and local bundle path:
For an end-to-end verification, point the locally built CLI at a mock OpenAI-compatible server that returns the same tool-call ID (
shell_1) in two consecutive rounds. Expected behavior: the first shell call executes, the second duplicate receives an error-shaped synthetic tool result, the final answer succeeds, and the side-effect file contains exactly one write. I also verified the same scenario with--max-tool-calls 1; the run still succeeds and reportsstats.tools.totalCalls=1, confirming the ignored duplicate does not consume the tool-call budget.Evidence (Before & After)
Before: the original #5014 reproduction on macOS Docker Desktop repeatedly executed the same
shell_1provider tool call, with the side-effect write occurring hundreds of times.After: deterministic mock-provider E2E against local
dist/cli.jsreturnedshell_1twice, exited successfully, produced exactly one side-effect write, and included this duplicate response in the transcript:Duplicate provider tool call id "shell_1" was already handled. The duplicate tool call was ignored and not executed again.Tested on
Environment (optional)
Local verification used the built bundle (
node dist/cli.js) with a mock OpenAI-compatible chat completions server,--approval-mode yolo, isolatedQWEN_RUNTIME_DIR, and no sandbox.Risk & Scope
Linked Issues
Fixes #5014
中文说明
What this PR does
这个 PR 在单次 agentic loop 内把 provider 原始返回的 tool-call ID 作为幂等键处理。模型或 provider 返回同一个 tool-call ID 多次时,第一次正常执行,后续重复调用会收到一个 synthetic tool result,说明该重复调用已被忽略,并且不会再次执行工具。
本地 fallback/generated call ID 与 provider ID 分开保留,所以没有 provider 原始 ID 的调用仍然走现有路径,不会因为生成出来的 ID 被跨 round 去重。
Why it's needed
#5014 显示模型会跨 tool-result round 反复返回同一个 provider tool-call ID,导致 shell 等有副作用的工具被重复执行。这样会把一次模型错误放大成大量重复写入或 shell 执行。
这个修复只处理 provider ID 完全相同的重复调用。#5015 / #4695 中提到的“语义相同但 ID 不同”的 loop breaker 问题不包含在本 PR 范围内。
Reviewer Test Plan
How to verify
运行 provider tool-call ID 保留和重复调用抑制的聚焦回归测试:
运行 build/typecheck,并验证本地 bundle 路径:
端到端验证可以把本地构建出来的 CLI 指向一个 mock OpenAI-compatible server,让 mock provider 连续两轮返回同一个 tool-call ID(
shell_1)。预期行为是:第一次 shell 调用正常执行,第二次重复调用收到 error-shaped synthetic tool result,最终回答成功,并且副作用文件里只有一次写入。我也用--max-tool-calls 1验证了同一个场景;运行仍然成功,并报告stats.tools.totalCalls=1,说明被忽略的 duplicate 不会消耗工具调用预算。Evidence (Before & After)
Before:#5014 原始复现在 macOS Docker Desktop 上会重复执行同一个
shell_1provider tool call,副作用写入发生数百次。After:使用本地
dist/cli.js和确定性的 mock provider 做 E2E,mock 连续两轮返回shell_1,CLI 成功退出,副作用写入只有一次,并且 transcript 中包含 duplicate response:Duplicate provider tool call id "shell_1" was already handled. The duplicate tool call was ignored and not executed again.Tested on
Environment (optional)
本地验证使用构建后的 bundle(
node dist/cli.js)、mock OpenAI-compatible chat completions server、--approval-mode yolo、隔离的QWEN_RUNTIME_DIR,未启用 sandbox。Risk & Scope
Linked Issues
Fixes #5014