Skip to content

fix(core): per-turn tool-call circuit breaker — always-on cap + opt-in loop heuristics (#5234) - #5279

Merged
wenshao merged 4 commits into
QwenLM:mainfrom
wenshao:fix/loop-circuit-breaker-5234
Jun 18, 2026
Merged

fix(core): per-turn tool-call circuit breaker — always-on cap + opt-in loop heuristics (#5234)#5279
wenshao merged 4 commits into
QwenLM:mainfrom
wenshao:fix/loop-circuit-breaker-5234

Conversation

@wenshao

@wenshao wenshao commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

Focused re-scope of #5242 by @aspnmy. That PR bundled the circuit breaker with several unrelated changes (a React #185 fix, a Chinese README, and two misc tweaks) and the author asked maintainers to carry the core forward (comment). This PR cherry-picks only @aspnmy's circuit-breaker commit (authorship preserved) and adds the telemetry/label fixes that review flagged. The unrelated changes are intentionally left out and can be sent as their own PRs.

What this PR does

Adds an always-on safety layer that halts a turn when its tool calls run away, so a stuck model can no longer loop forever. The core of it is a hard per-turn tool-call cap that runs before the existing model.skipLoopDetection gate, which means it cannot be switched off by configuration. Alongside the cap, two new heuristic detectors are added to the existing (opt-in) loop-detection path: a non-consecutive "global duplicate" detector that catches the same (tool, args) call repeating across a turn even when interleaved with other calls, and an "alternating pattern" detector that catches a model flip-flopping between the same two calls (A B A B …). Each detector reports its own telemetry loop type, and the non-interactive (headless) CLI prints a human-readable reason for each when it halts a run.

Why it's needed

Tool calls can get stuck in an infinite loop (#5234). The triage of that issue identified the root cause: model.skipLoopDetection defaults to true, so loop detection is off for most users and the only backstop is MAX_TURNS. This change adds a safety net that does not depend on that opt-in flag — the hard per-turn cap always applies — while the two new heuristics make the opt-in detection smarter about loop shapes the existing consecutive-identical check misses.

Reviewer Test Plan

How to verify

This is internal loop-detection logic plus a headless stderr message — not a TUI change — so verification is via unit tests and typecheck rather than a screen recording. From the repo root:

# Core unit tests (loop detector + client integration)
cd packages/core && npx vitest run src/services/loopDetectionService.test.ts src/core/client.test.ts
# → Test Files 2 passed (2); Tests 258 passed (258)

# Core typecheck
cd packages/core && npx tsc --noEmit   # → exit 0

What a reviewer should confirm: (1) the new circuit-breaker tests pass — turn cap fires on the call that exceeds the cap and reports loop_type: 'turn_tool_call_cap', global-duplicate fires non-consecutively at threshold, alternating-pattern fires on a clean ABAB and resets after a break; (2) the cap fires regardless of disableForSession(); (3) the new LoopType values each map to a label in the non-interactive CLI (LOOP_TYPE_LABELS is Record<LoopType, string>, so a missing one fails typecheck).

Evidence (Before & After)

N/A (non-UI change). Test output:

 ✓ src/services/loopDetectionService.test.ts (56 tests)
 ✓ src/core/client.test.ts (202 tests)
 Test Files  2 passed (2)
      Tests  258 passed (258)

Tested on

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

Environment (optional)

Local unit tests + tsc --noEmit on macOS (Node via repo toolchain). Windows/Linux left to CI.

Risk & Scope

  • Main risk or tradeoff: the always-on per-turn cap (100 tool calls) is the only default-behavior change. It is deliberately high — above typical legitimate turns — but a genuinely long automated turn could be cut short; it mirrors the existing MAX_TURNS backstop philosophy and cannot be disabled by design (that is the point of a circuit breaker).
  • Not validated / out of scope: reproducing a real model loop end-to-end is non-deterministic, so it is covered by unit tests rather than a live run. The two new heuristic detectors remain behind skipLoopDetection (default true), matching the existing opt-in design — so for default users only the hard cap is active.
  • Breaking changes / migration notes: none. The new LoopType values are additive. One pre-existing telemetry mislabel is corrected: the turn cap previously logged consecutive_identical_tool_calls and now logs turn_tool_call_cap, so any dashboard filtering on the old value for cap events should be updated.

Linked Issues

Closes #5234

中文说明

#5242 的聚焦重做,原作者 @aspnmy。那个 PR 把断路器和若干无关改动(React #185 修复、中文 README、两个杂项)打包在一起,作者也表示没时间返工、请维护者接手(评论)。本 PR 只 cherry-pick 了 @aspnmy 的断路器那一个 commit(保留其作者署名),并补上 review 指出的遥测/标签修复。无关改动有意不包含,可各自单独提 PR。

这个 PR 做了什么

新增一个始终生效的安全层:当一个回合内的工具调用失控时直接中断,让卡住的模型不再无限循环。核心是一个单回合工具调用硬上限,它运行在现有 model.skipLoopDetection 门控之前,因此无法被配置关闭。除硬上限外,还在现有(opt-in)的循环检测路径上增加了两个启发式检测器:一个非连续的「全局重复」检测器,能抓到同一个 (tool, args) 调用在一个回合内反复出现(即使中间夹杂了其他调用);以及一个「交替模式」检测器,能抓到模型在同样的两个调用之间来回横跳(A B A B …)。每个检测器都上报各自的遥测 loop type,非交互(headless)CLI 在因此中断时会为每种类型打印一句可读的原因。

为什么需要

工具调用可能陷入死循环(#5234)。该 issue 的 triage 已确认根因:model.skipLoopDetection 默认为 true,所以对多数用户而言循环检测是关闭的,唯一兜底是 MAX_TURNS。本改动加了一个不依赖该 opt-in 开关的安全网——单回合硬上限始终生效;同时两个新启发式让 opt-in 检测能识别现有「连续相同」检测漏掉的循环形态。

审查测试计划

如何验证

这是内部循环检测逻辑加上一条 headless stderr 提示,不是 TUI 改动,所以通过单元测试和类型检查验证,而不是录屏。在仓库根目录:

# 核心单元测试(循环检测器 + client 集成)
cd packages/core && npx vitest run src/services/loopDetectionService.test.ts src/core/client.test.ts
# → Test Files 2 passed (2); Tests 258 passed (258)

# 核心类型检查
cd packages/core && npx tsc --noEmit   # → exit 0

审查者应确认:(1) 新断路器测试通过——超过上限的那一次调用触发,且上报 loop_type: 'turn_tool_call_cap';全局重复在阈值处非连续触发;交替模式在干净的 ABAB 上触发并在被打断后重置;(2) 即使 disableForSession() 后硬上限仍会触发;(3) 每个新 LoopType 都在非交互 CLI 里有对应标签(LOOP_TYPE_LABELSRecord<LoopType, string>,缺一个就会类型检查失败)。

证据(Before & After)

N/A(非 UI 改动)。测试输出:

 ✓ src/services/loopDetectionService.test.ts (56 tests)
 ✓ src/core/client.test.ts (202 tests)
 Test Files  2 passed (2)
      Tests  258 passed (258)

测试平台

系统 状态
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

运行环境(可选)

macOS 上本地单元测试 + tsc --noEmit(Node 走仓库工具链)。Windows/Linux 交给 CI。

风险与范围

  • 主要风险或取舍:始终生效的单回合上限(100 次工具调用)是唯一的默认行为变化。该值刻意取得较高——高于正常回合——但一个确实很长的自动化回合可能被截断;它沿用现有 MAX_TURNS 兜底的思路,且按设计无法关闭(这正是断路器的意义)。
  • 未验证 / 范围之外:端到端复现真实模型循环是非确定性的,因此用单元测试覆盖而非实跑。两个新启发式检测器仍在 skipLoopDetection(默认 true)门控之后,沿用现有 opt-in 设计——所以对默认用户只有硬上限生效。
  • 破坏性变更 / 迁移说明:无。新增的 LoopType 值是增量的。修正了一处既有遥测误标:硬上限此前记为 consecutive_identical_tool_calls,现在记为 turn_tool_call_cap,所以若有看板按旧值过滤上限事件,应更新。

关联 Issue

Closes #5234

aspnmy and others added 2 commits June 18, 2026 09:33
…in CLI

The always-on turn tool-call cap logged LoopType.CONSECUTIVE_IDENTICAL_TOOL_CALLS,
which mislabels telemetry — the cap fires on total per-turn volume, not on
consecutive identical calls. Add a dedicated LoopType.TURN_TOOL_CALL_CAP and use it
in checkTurnToolCallCap (both lastLoopType and the LoopDetectedEvent), with a test
asserting the reported loop_type.

Also add nonInteractiveCli LOOP_TYPE_LABELS entries for the three new loop types
(global duplicate, alternating pattern, turn cap). LOOP_TYPE_LABELS is typed
Record<LoopType, string>, so without these the CLI typecheck breaks once core is
built, and headless TEXT-mode runs would halt with no reason printed.
@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Re-run after author fix-ups (0547a63).

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

On direction: unchanged from the initial triage — runaway tool-call loops are a real reliability problem (#5234), and an always-on circuit breaker is the right shape of fix. Clearly aligned.

On approach: the scope remains tight. The two fix-up commits addressed the review findings (wrong headless hint, double-emit, retry handling) without scope creep. Title now correctly distinguishes the always-on cap from the opt-in heuristics.

Moving on to code review. 🔍

中文说明

作者修复后重跑(0547a63)。

模板完整 ✓ — 所有必需标题齐全,双语,包含审查测试计划。

方向:与初次 triage 一致——工具调用死循环是真实的可靠性问题(#5234),始终生效的断路器是正确的修复形态。明确对齐。

方案:范围仍然紧凑。两个修复 commit 处理了审查发现(错误的 headless 提示、重复打印、retry 处理),没有范围蔓延。标题现在正确区分了始终生效的上限和 opt-in 启发式。

进入代码审查 🔍

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Re-run after author fix-ups (0547a63).

Code Review

The two fix-up commits properly addressed the prior findings:

  • Finding 1 (wrong headless hint): emitLoopDetectedMessage now special-cases TURN_TOOL_CALL_CAP with "This is an always-on per-turn tool-call cap and cannot be disabled via model.skipLoopDetection." — correct, since the cap runs before that gate. ✅
  • Finding 2 (double-emit + wasted request): the always-on halt in client.ts:2135 now clears turn.pendingToolCalls.length = 0, preventing a continuation that would re-trip the cap and double-print the message. ✅
  • Retry handling: the always-on cap uses commit/rollback (turnToolCallTotalCommitted floor on Finished, rollback on Retry). The heuristic detectors (global-duplicate, alternating) clear their counters on Retry. Both approaches are correct and prevent false positives from retried replays. ✅
  • Defensive loopType: the always-on halt now uses ...(loopType && { value: { loopType } }) spread, matching the safer pattern from the gated path. ✅
  • Client test gap: new integration test asserts checkAlwaysOnSafeties → true, getLastLoopType → TURN_TOOL_CALL_CAP, and that pendingToolCalls is cleared (test fails without the production clear). ✅

No new critical blockers or AGENTS.md violations found. The code is clean, well-structured, and the fix-ups are minimal and focused.

Test Results

Unit Tests (worktree, PR branch 0547a63)

$ cd packages/core && npx vitest run src/services/loopDetectionService.test.ts

 ✓ src/services/loopDetectionService.test.ts (60 tests) 275ms

 Test Files  1 passed (1)
      Tests  60 passed (60)

$ cd packages/core && npx vitest run src/core/client.test.ts

 ✓ src/core/client.test.ts (203 tests) 4801ms

 Test Files  1 passed (1)
      Tests  203 passed (203)

$ cd packages/core && npx tsc --noEmit
(exit 0 — no errors)

$ cd packages/cli && npx tsc --noEmit
(exit 0 — no errors)

Total: 263 tests pass (up from 258 at initial review — +3 loop detection tests for retry handling, +1 client integration test).

CI (GitHub Actions, 0547a63)

Lint                     pass    5m39s
CodeQL                   pass    6m50s
Test (macOS, Node 22)    pass    20m16s
Test (Ubuntu, Node 22)   pass    16m53s
Test (Windows, Node 22)  pass    24m39s

All green across all three platforms.

Smoke Test (tmux, PR bundle — no false positives)

Prompt: list 3 files in the current directory

runner@runnervm1li68:~/work/qwen-code/qwen-code$ node .qwen/worktrees/triage/dist/cli.js -p 'list 3 files in the current directory' 2>&1 | tee tmp/triage-rerun-075557/smoke.log
Here are 3 files from the current directory:

1. `vitest.config.ts`
2. `tsconfig.json`
3. `package.json`
runner@runnervm1li68:~/work/qwen-code/qwen-code$

Normal prompt execution, no false loop-detection triggers. The circuit breaker and heuristic detectors don't interfere with typical usage.

中文说明

代码审查

两个修复 commit 正确处理了此前的发现:

  • 发现 1(错误的 headless 提示): emitLoopDetectedMessage 现在对 TURN_TOOL_CALL_CAP 单独出文案——正确,因为上限在该门控之前运行。✅
  • 发现 2(重复打印 + 浪费请求): client.ts:2135 的始终生效中断现在清空 turn.pendingToolCalls,阻止续轮重新触发上限并重复打印。✅
  • Retry 处理: 始终生效上限使用提交/回滚(Finished 时提交基线,Retry 时回滚)。启发式检测器在 Retry 时清空计数器。两种方法都正确,防止重试回放导致的误报。✅
  • 防御性 loopType 始终生效中断现在使用 ...(loopType && { value }) 展开,匹配门控路径更安全模式。✅
  • Client 测试缺口: 新增集成测试断言 checkAlwaysOnSafeties → truegetLastLoopType → TURN_TOOL_CALL_CAP,以及 pendingToolCalls 被清空。✅

未发现新的关键阻塞问题或 AGENTS.md 违规。

测试结果

  • 单元测试:263 通过(loopDetection 60 + client 203)
  • 类型检查:core 与 cli 均干净
  • CI:macOS / Ubuntu / Windows 全绿
  • 冒烟测试:正常提示执行,无误报

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Re-run after author fix-ups (0547a63).

Final Reflection

The two fix-up commits closed every gap the prior review identified. The code is now in better shape than when it was first approved:

  • The headless hint is correct for each loop type (no more telling users to set a flag that can't help).
  • The double-emit / wasted-request issue is gone (pending tool calls are cleared on halt).
  • Retry handling is correct across all three paths (commit/rollback for the cap, counter reset for heuristics, existing resetToolCallCount for the deterministic path).
  • Test count went from 258 → 263, with the new tests covering the exact edge cases the review flagged.
  • The loopType is now defensively spread rather than non-null-asserted.

My independent proposal would still look essentially the same. This PR does one thing well, the fix-ups are minimal and focused, and CI is green on all three platforms.

Verdict: Approve ✅

中文说明

最终评估

两个修复 commit 关闭了此前审查指出的所有缺口。代码现在比初次批准时更好:

  • 每种 loop type 的 headless 提示都正确了。
  • 重复打印/浪费请求的问题消除了。
  • 三条路径的 retry 处理都正确。
  • 测试数从 258 增加到 263,新测试覆盖了审查提出的边界情况。
  • loopType 现在使用安全的展开而非非空断言。

我的独立方案本质上仍然相同。这个 PR 只做一件事、做得很好,修复最小且聚焦,CI 三平台全绿。

结论:批准 ✅

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 Jun 18, 2026

Copy link
Copy Markdown
Collaborator Author

Maintainer verification — real local + tmux E2E

Verified 5e762af52 on Linux (Node v22.22.2) in an isolated worktree. The PR body says this is "internal logic, verify via unit tests, not a screen recording" — but a circuit breaker is exactly the kind of thing worth driving end-to-end, so I stood up a mock OpenAI server that deliberately loops tool calls and ran the real CLI against it under tmux. All three detectors fire live with the correct telemetry label and headless reason. Two findings, both in the headless halt message (the detection logic itself is correct).

Methodology

  1. Unit tests + typecheck (reproduce the PR's claim).
  2. A dist harness driving the compiled LoopDetectionService with synthetic tool-call streams (deterministic threshold/telemetry checks).
  3. Live tmux E2E: a mock /chat/completions SSE server returns looping tool calls; the real dist/cli.js runs headless (-p --yolo) against it, and I capture the halt reason from stderr + the round-trips from the mock log.

1. Live E2E results (tmux + mock model)

Scenario Config Mock pattern Result (stderr)
Turn cap (always-on) skipLoopDetection: true 101 tool calls turn_tool_call_capfired even though loop detection is "off"
Turn cap (realistic) skipLoopDetection: true 7 calls/response × 15 round-trips turn_tool_call_cap at the 101st total call — accumulates across round-trips (mock saw toolResults climb 0→98)
Alternating skipLoopDetection: false ls /A, ls /B, … alternating_tool_call_pattern after 6 calls (A B A B A B)
Global duplicate skipLoopDetection: false ls /stuck interleaved with unique globs global_tool_call_duplicate at the 6th non-consecutive /stuck

Example live halt (the always-on cap, with skipLoopDetection already true):

Loop detection halted the run (turn_tool_call_cap: the model exceeded the maximum number
of tool calls allowed in a single turn). Set the `model.skipLoopDetection` setting to true to disable.

This confirms the core design claims end-to-end:

  • The hard cap runs before the skipLoopDetection gate and cannot be disabled (it fired with the flag set to true).
  • The per-turn counters accumulate across the whole agentic loop — continuations use SendMessageType.ToolResult, which is not a top-level interaction, so loopDetector.reset() is not called between round-trips. Verified live (cap tripped at call 101 across 15 separate model responses).
  • An incidental observation: with skipLoopDetection: false all detectors are live and the earliest-threshold one wins — e.g. a read_file-heavy filler pattern trips the pre-existing read_file_loop before global-dup reaches 6. Expected, just worth keeping in mind when reasoning about which type a real loop reports.

2. Tests, typecheck, dist harness

  • loopDetectionService.test.ts + client.test.ts258 passed (258) — matches the PR claim.
  • tsc --noEmit → clean for core and cli (so the Record<LoopType,string> LOOP_TYPE_LABELS is exhaustive — a missing label would fail here).
  • Dist harness against the compiled service → 11/11: cap fires on the 101st call (turn_tool_call_cap), not within 100, fires after disableForSession(), reset() refreshes the budget; global-dup fires at 6 non-consecutive (global_tool_call_duplicate), silent for distinct pairs; alternating fires on ABABAB (alternating_tool_call_pattern), silent when the partner key varies.

3. Code review

  • Ordering in client.ts is correct: checkAlwaysOnSafeties (cap) runs unconditionally before the !skipLoopDetection gated addAndCheckDeterministicToolCallLoop / addAndCheckHeuristicLoops.
  • getLastLoopType()! (client.ts:2134) is safe — checkTurnToolCallCap sets lastLoopType = TURN_TOOL_CALL_CAP before returning true (loopDetectionService.ts:618). The CI bot's note stands as a style point, not a bug.
  • The client.ts duplication between the always-on and gated blocks (CI bot's note) is acceptable; the gated block additionally does the CONSECUTIVE_IDENTICAL splice.

Findings (both in the headless halt message; detection logic is correct)

🟠 Finding 1 — the halt hint is wrong for turn_tool_call_cap

emitLoopDetectedMessage (nonInteractiveCli.ts:120-133) appends the same remediation to every loop type:

... Set the `model.skipLoopDetection` setting to true to disable.

But the whole point of the cap is that it is always-on and skipLoopDetection does not disable it. Proven live: I ran with skipLoopDetection: true and the cap fired anyway — while telling the user to set the flag they had already set. A user who hits the cap and follows this advice will be confused (and may conclude the setting is broken).

Suggestion: special-case the message for TURN_TOOL_CALL_CAP, e.g. "this is a hard per-turn safety cap (100 tool calls) and cannot be disabled" — and only show the skipLoopDetection hint for the gated detectors (which it actually governs).

🟡 Finding 2 — the cap halt emits the message twice + spends one extra model request

In both turn-cap runs the halt message printed twice and the mock received one extra request after the cap first tripped (101-in-one: 2 requests; batched: tripped on request 15, mock saw a 16th with toolResults unchanged at 98). The two heuristic runs (one tool call per response) each printed it once and stopped cleanly. So when the cap is tripped by a response carrying multiple tool calls (parallel calls — a real scenario), the always-on early-return (client.ts:2143 return turn) doesn't fully short-circuit the non-interactive turn loop: a follow-up turn fires, immediately re-trips the cap (counter is past 100 and isn't reset on a continuation), and re-emits via the second site (nonInteractiveCli.ts:1265).

Not a functional failure — the run still terminates (exit 0), it doesn't loop forever — but it's a duplicated user-facing message plus one wasted model call. Worth a look at how the always-on halt interacts with the main vs. drain emit sites (lines 1042 / 1265).


Bottom line: the detection logic is correct and well-covered — 258 unit + 11/11 dist-harness + all three detectors firing live with the right labels, the always-on cap genuinely un-disableable, and cross-round-trip accumulation working. Both findings are in the headless message path, not the detection: Finding 1 (wrong remediation hint for the cap) is the one I'd fix before/with merge since it's user-facing and cheap; Finding 2 is a minor robustness follow-up.

中文版(点击展开)

维护者验证 —— 真实本地 + tmux 端到端

在隔离 worktree 中验证 5e762af52(Linux,Node v22.22.2)。PR 正文说"这是内部逻辑、用单测验证、非 TUI 改动、不录屏"——但断路器恰恰值得端到端实跑,于是我起了一个故意制造工具调用死循环的 mock OpenAI 服务,在 tmux 下用真实 CLI 打它。三个检测器都能真机触发,遥测标签与 headless 提示均正确。两个发现都在 headless 中断提示文案上(检测逻辑本身正确)。

方法

  1. 单测 + 类型检查(复现 PR 的结论)。
  2. dist 直测:用合成的工具调用事件流驱动编译后的 LoopDetectionService(确定性地验证阈值/遥测)。
  3. tmux 真机端到端:mock /chat/completions SSE 服务返回循环工具调用;真实 dist/cli.js 以 headless(-p --yolo)打它,从 stderr 抓中断原因、从 mock 日志看往返轮次。

1. 真机端到端结果(tmux + mock 模型)

场景 配置 mock 模式 结果(stderr)
回合上限(始终生效) skipLoopDetection: true 101 次工具调用 turn_tool_call_cap —— 即使循环检测"已关闭"仍触发
回合上限(真实多轮) skipLoopDetection: true 每次 7 调用 × 15 轮 ✅ 在第 101 次调用触发 —— 跨轮次累计(mock 看到 toolResults 从 0 涨到 98)
交替模式 skipLoopDetection: false ls /Als /B ✅ 6 次(A B A B A B)后 alternating_tool_call_pattern
全局重复 skipLoopDetection: false ls /stuck 夹杂唯一 glob ✅ 第 6 次非连续 /stuckglobal_tool_call_duplicate

真机中断示例(始终生效的上限,且 skipLoopDetection 已为 true):

Loop detection halted the run (turn_tool_call_cap: the model exceeded the maximum number
of tool calls allowed in a single turn). Set the `model.skipLoopDetection` setting to true to disable.

这端到端印证了核心设计:

  • 硬上限在 skipLoopDetection 门控之前运行、且无法被关闭(flag 设为 true 时仍触发)。
  • 单回合计数器在整个 agentic loop 中累计——续轮用 SendMessageType.ToolResult,非顶层交互,故轮次之间不会调用 loopDetector.reset()。真机验证(上限在跨 15 个模型响应、第 101 次调用时触发)。
  • 顺带观察:skipLoopDetection: false所有检测器都生效,谁先到阈值谁先触发——比如 read_file 填充会让既有的 read_file_loop 先于全局重复(阈值 6)触发。符合预期,只是推断真实循环会报哪种类型时要注意。

2. 测试、类型检查、dist 直测

  • loopDetectionService.test.ts + client.test.ts258 passed (258),与 PR 一致。
  • tsc --noEmit → core 与 cli 均干净(即 Record<LoopType,string>LOOP_TYPE_LABELS 是穷尽的——少一个标签会在此处报错)。
  • 针对编译产物的 dist 直测 → 11/11:上限在第 101 次触发(turn_tool_call_cap)、100 次内不触发、disableForSession() 后仍触发、reset() 刷新预算;全局重复在第 6 次非连续触发(global_tool_call_duplicate)、对不同 (name,args) 不触发;交替在 ABABAB 触发(alternating_tool_call_pattern)、伙伴键变化时不触发。

3. 代码审查

  • client.ts 顺序正确:checkAlwaysOnSafeties(上限)无条件先于 !skipLoopDetection 门控的 addAndCheckDeterministicToolCallLoop / addAndCheckHeuristicLoops
  • getLastLoopType()!(client.ts:2134)是安全的——checkTurnToolCallCap 在返回 true 前已设 lastLoopType = TURN_TOOL_CALL_CAP(loopDetectionService.ts:618)。CI bot 的提示属风格问题、非 bug。
  • 始终生效块与门控块的重复(CI bot 提示)可接受;门控块额外做了 CONSECUTIVE_IDENTICAL 的 splice。

发现(都在 headless 中断文案上;检测逻辑正确)

🟠 发现 1 —— turn_tool_call_cap 的中断提示是错的

emitLoopDetectedMessage(nonInteractiveCli.ts:120-133)对所有 loop type 追加同一句补救提示:

... Set the `model.skipLoopDetection` setting to true to disable.

但上限的全部意义就是始终生效、skipLoopDetection 关不掉它。真机已证:我用 skipLoopDetection: true 跑、上限照样触发——却让用户去设一个他已经设好的开关。命中上限并照此操作的用户会困惑(甚至以为这个设置坏了)。

建议:TURN_TOOL_CALL_CAP 单独出文案,例如*"这是单回合硬性安全上限(100 次工具调用),无法关闭"*;skipLoopDetection 的提示只对它真正管辖的门控检测器显示。

🟡 发现 2 —— 上限中断会重复打印提示 + 多花一次模型请求

两次回合上限实跑里,中断提示都打印了两次,且上限首次触发后 mock 又多收到一次请求(101-合一:2 次请求;分批:在第 15 次请求触发,mock 又看到第 16 次、toolResults 仍为 98)。两个启发式实跑(每响应 1 个工具调用)则各打印一次并干净停止。也就是说,当上限被一个携带多个工具调用(并行调用——真实场景)的响应触发时,始终生效的提前返回(client.ts:2143 return turn)没有完全短路 non-interactive 的回合循环:又起了一轮、立刻再次触发上限(计数已过 100、续轮不会重置),并经第二处(nonInteractiveCli.ts:1265)再次打印。

不是功能性失败——run 仍会终止(exit 0),不会死循环——但确实多了一条重复的用户可见提示和一次浪费的模型调用。值得看看始终生效中断与主/drain 两处打印点(1042 / 1265)的交互。

结论

检测逻辑正确且覆盖充分——258 单测 + dist 直测 11/11 + 三个检测器真机触发且标签正确、始终生效的上限确实关不掉、跨轮次累计正常。两个发现都在 headless 文案路径、非检测逻辑:发现 1(上限的补救提示错误)面向用户且改动很小,建议随合并一起修;发现 2 是次要的健壮性后续项。

Verified by maintainer wenshao: local build + dist harness + live tmux against a mock OpenAI server. HEAD 5e762af52.

Comment thread packages/core/src/services/loopDetectionService.ts
Comment thread packages/cli/src/nonInteractiveCli.ts
Comment thread packages/core/src/telemetry/types.ts
Comment thread packages/core/src/core/client.test.ts
@aspnmy

aspnmy commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

问题解决就行,你们自己维护的代码质量好一点,我们使用的时候就不会浪费太多token,我很抠门的没钱充值token,如果不是严重浪费token的行为,另可重新启动实例也不去修复它,这是真实使用者被逼无奈不得不修复的原因

Comment thread packages/core/src/services/loopDetectionService.ts Outdated
Comment thread packages/core/src/core/client.ts

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

Detection logic and the live E2E verification look right to me — no blockers on the detection side. Left two non-blocking nits inline (both P3).

On the open [Critical] retry thread: the gap is real, but the suggested turnToolCallTotal = 0 over-corrects — the counter is whole-turn cumulative across ToolResult continuations (reset() is gated on isTopLevelInteraction, client.ts:1722), so zeroing on every Retry drops the count from prior completed round-trips, not just the discarded attempt. I left a fuller reply there with the concrete trace and a snapshot-and-roll-back fix that removes only the discarded delta. [P2]

Review on QwenLM#5279 surfaced edge cases in the always-on turn cap:

- Retry handling: turnToolCallTotal counted tool calls from failed/retried
  attempts. Commit the running total on each Finished (round-trip boundary) and
  roll back to that floor on Retry, so a retry discards only the failed attempt
  while prior completed round-trips still accumulate (per-turn totals persist
  across ToolResult continuations). Avoids the naive reset-to-zero that would
  drop earlier round-trips' counts.
- Headless hint: emitLoopDetectedMessage told users to set
  model.skipLoopDetection even for the cap, which runs before that gate and
  cannot be disabled by it. Show a cap-specific message instead.
- Always-on halt now clears turn.pendingToolCalls so a multi-call response that
  trips the cap does not execute the remaining calls, spawn a continuation, and
  re-print the halt message.
- Compute the tool-call key once per heuristic event; drop the dead param on
  checkTurnToolCallCap.
- Add a client-level test for the always-on halt path plus tests for the retry
  rollback and cross-round-trip accumulation.
@wenshao wenshao changed the title fix(core): add always-on tool-call circuit breaker for runaway loops (#5234) fix(core): per-turn tool-call circuit breaker — always-on cap + opt-in loop heuristics (#5234) Jun 18, 2026
@wenshao

wenshao commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the review — pushed 5fe2046. CI was already green on the prior commit; this round handles the edge cases raised (the detection logic itself was confirmed correct by the live E2E above). All six inline threads are replied to and resolved.

Finding Fix
Retry handling (bot, Critical) The cap counted tool calls from failed/retried attempts. checkAlwaysOnSafeties now commits turnToolCallTotal on each Finished (round-trip boundary) and rolls back to that floor on Retry — discarding only the failed attempt while prior completed round-trips keep accumulating. This is the snapshot/rollback @yiliang114 described, not the naive reset-to-zero (which would drop earlier round-trips).
Headless hint (Finding 1) emitLoopDetectedMessage told users to set model.skipLoopDetection even for the cap, which runs before that gate and can't be disabled by it. Now cap-specific: "always-on per-turn cap… cannot be disabled".
Double-emit (Finding 2 / yiliang P3) A multi-call response that tripped the cap executed the remaining calls → continuation → halt printed twice + one wasted request. The always-on halt now clears turn.pendingToolCalls.
Redundant hashing (yiliang P3) The tool-call key was hashed 2–3× per event. Hash once per event; dropped the dead _toolCallKey param.
Client test gap (bot) Added an integration test for the always-on halt path (checkAlwaysOnSafeties → true, getLastLoopTypeTURN_TOOL_CALL_CAP).
Title/framing (bot) Title updated to clarify only the cap is always-on; the two heuristics stay opt-in.

Tests: 262 passing (loopDetection 59 + client 203, +4 new), core tsc --noEmit clean, lint/format clean.

Thanks @yiliang114 and the triage bot for the careful review. 🙏

中文

已处理 review,推送 5fe2046 上一个 commit 的 CI 已全绿;这一轮处理 review 提出的边界情况(检测逻辑本身已被上面的真机 E2E 确认正确)。六个 inline 线程均已回复并 resolve。

发现 修复
Retry 处理(bot,Critical) 上限把失败/重试尝试里的工具调用也计入了。checkAlwaysOnSafeties 现在在每个 Finished(轮次边界)提交 turnToolCallTotal,并在 Retry 时回滚到该基线——只丢弃失败的那次尝试,之前已完成的轮次照常累计。这就是 @yiliang114 说的 snapshot/rollback,不是会丢掉前面轮次计数的简单清零。
Headless 提示(发现 1) emitLoopDetectedMessage 连上限也叫用户去设 model.skipLoopDetection,但上限在该门控之前运行、关不掉。现在对上限单独出文案:"始终生效…无法关闭"。
重复打印(发现 2 / yiliang P3) 当上限被一个携带多个工具调用的响应触发时,剩余调用仍会执行 → 续轮 → 提示打印两次 + 浪费一次请求。始终生效的中断现在会清空 turn.pendingToolCalls
重复哈希(yiliang P3) 工具调用 key 每个事件被哈希 2–3 次。改为每事件哈希一次;删掉无用的 _toolCallKey 参数。
client 测试缺口(bot) 新增始终生效中断路径的集成测试(checkAlwaysOnSafeties → true,getLastLoopTypeTURN_TOOL_CALL_CAP)。
标题/表述(bot) 标题已更新,明确只有上限始终生效,两个启发式仍是 opt-in。

测试:262 通过(loopDetection 59 + client 203,新增 4 个),core tsc --noEmit 干净,lint/格式干净。

感谢 @yiliang114 和 triage bot 的细致 review。🙏

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

Downgraded from Approve to Comment: CI still running. Detection logic looks sound — the turn cap + commit/rollback for Retry is well-designed, and tests pass locally. Three non-blocking suggestions below, plus a small test-coverage nit.

— qwen3.7-max via Qwen Code /review

Comment thread packages/core/src/services/loopDetectionService.ts
Comment thread packages/core/src/core/client.ts Outdated
Comment thread packages/core/src/core/client.test.ts Outdated
Comment thread packages/core/src/services/loopDetectionService.test.ts
- addAndCheckHeuristicLoops now clears the new global-duplicate and alternating
  counters on Retry, so a retried replay can't inflate them into a false
  positive (mirrors the deterministic path's resetToolCallCount; the always-on
  cap keeps its own counter accurate via commit/rollback).
- The always-on halt uses the defensive `...(loopType && { value })` spread like
  the gated block instead of a non-null assertion on getLastLoopType().
- The always-on integration test now populates turn.pendingToolCalls before the
  halt so the clear runs against a non-empty array (confirmed it fails without
  the production clear).
- Assert getLastLoopType() for the global-duplicate and alternating detectors
  (the getter the client reads), plus a retry-replay regression test.
@wenshao

wenshao commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator Author

Second review pass addressed — pushed 0547a63. All four follow-ups handled, threads replied + resolved:

  • Retry on heuristics: addAndCheckHeuristicLoops now clears the global-duplicate / alternating counters on Retry, so a retried replay can't inflate them into a false positive (regression test added). Mirrors the deterministic path's reset; the always-on cap keeps its own counter via commit/rollback.
  • Defensive loopType: the always-on halt uses the ...(loopType && { value }) spread like the gated block instead of a non-null assertion.
  • Test coverage: the always-on integration test now populates turn.pendingToolCalls before the halt (verified it fails without the production clear), and both heuristic firing tests assert getLastLoopType() too.

CI was green on the prior commit and is re-running on 0547a63; 263 tests pass locally, tsc/lint/format clean.

中文

第二轮 review 已处理——推送 0547a63 四项后续全部处理,线程已回复 + resolve:

  • 启发式的 Retry 处理: addAndCheckHeuristicLoops 现在在 Retry 时清空 global-duplicate / alternating 计数器,避免重试重放把计数灌成误报(已加回归测试)。与确定性路径的 reset 一致;始终生效的上限则用 commit/rollback 保持自己的计数准确。
  • 防御性 loopType 始终生效中断改用 ...(loopType && { value }) 展开(与门控块一致),不再用非空断言。
  • 测试覆盖: 始终生效的集成测试现在会在中断前填充 turn.pendingToolCalls(已验证去掉生产代码的清空后测试会失败);两个启发式触发测试也都断言了 getLastLoopType()

上一个 commit 的 CI 已全绿,0547a63 正在重跑;本地 263 测试通过,tsc/lint/format 干净。

@wenshao

wenshao commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

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

@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 review findings. Downgraded from Approve to Comment: CI still running. — qwen3.7-max via Qwen Code /review

@yiliang114

yiliang114 commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

I did another local verification pass on the latest head 0547a6325f9b.

Green local checks:

  • cd packages/core && npx tsc --noEmit
  • cd packages/core && npx vitest run src/services/loopDetectionService.test.ts src/core/client.test.ts
  • npm run build
  • npm run bundle
  • cd packages/cli && npx tsc --noEmit

I also ran a tmux headless smoke test against the built dist/cli.js. The test used a local fake OpenAI-compatible /v1/chat/completions server and streamed 101 valid read_file tool calls in one assistant response. The tool-call args used the real schema (file_path) and pointed at the repo package.json.

The test still reproduced a remaining issue in headless mode. The CLI emits the new turn_tool_call_cap halt message, but it still appears to dispatch the 100 tool calls yielded before the cap fired. With QWEN_DEBUG_LOG_FILE=1, the valid-args run showed:

  • 100 cleanOrphanedToolCalls: dropping orphaned tool response ... entries
  • 100 duplicate tool-call ID entries with __qwen_dup_2
  • the Loop detection halted...turn_tool_call_cap message printed twice
  • no read_file schema errors in the valid-args run

So the core-side turn.pendingToolCalls.length = 0 is only part of the fix. In runNonInteractive, the first 100 ToolCallRequest events have already been copied into the local toolCallRequests array before LoopDetected arrives. Since LoopDetected currently only prints a message and the outer loop continues, processToolCallBatch(toolCallRequests, ...) can still run those already-collected calls.

Could we make the headless path treat LoopDetected as terminal for the current run, or at least clear/ignore the local collected tool-call batch before processToolCallBatch runs? The same pattern likely needs to be checked in the drain-item loop too.

Update: after weighing the scope, I don't think this remaining headless/nonInteractive edge case needs to block this PR. The main fix looks good to move forward with, and this can be tracked as a follow-up.

'the model repeated the same tool call across the turn, even when not back-to-back',
[LoopType.ALTERNATING_TOOL_CALL_PATTERN]:
'the model alternated between the same two tool calls in a repeating pattern',
[LoopType.TURN_TOOL_CALL_CAP]:

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.

The message here is correct, but the headless caller still needs to stop the current run, not just print the message. In the tmux headless smoke test, LoopDetected was emitted, but the already-collected toolCallRequests array still flowed into processToolCallBatch(...), so the first 100 streamed read_file calls were still dispatched before the run halted.

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

Overall this looks good to me. I ran several local tmux/headless rounds around the tool-call cap behavior; the main path looks fine to move forward with. I left one small headless/nonInteractive edge-case note, but I think it can be followed up separately.

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.

工具调用会一直陷入死循环

4 participants