Prior art: how agent harnesses detect tool-call loops先行实现:各家 Agent 如何检测工具调用循环

A source-level review of 14 agent harnesses, HEAD-pinned (October 2026). Self-contained: we read every mechanism below in the project's own repository at the recorded commit.对 14 个 Agent 工具的源码级调研,HEAD 核实(2026 年 10 月)。本文自包含:以下每条机制均在对应项目的仓库中按所记录的 commit 实读确认。

1. Why this review exists1. 调研由来

In a real session, an agent polled the same three git commands about 200 times over ~87 minutes. It kept going after the user interrupted, and only stopped when the user switched models. The commands rotated (status → diff --stat → diff file), so no three consecutive calls ever matched. That shape — periodic rotation — is what most existing detectors miss. We reviewed how other harnesses detect loops before designing our own.一次真实会话中,Agent 在约 87 分钟内轮询同三条 git 命令约 200 次,用户打断无效,最后靠切换模型才停止。这些命令是轮换的(status → diff --stat → diff file),任意连续三次都不相同。这种「周期轮换」形态正是多数现有检测器漏掉的。因此在设计自己的检测器之前,我们先调研了各家的做法。

2. The incident's shape: from chaos to a metronome2. 事故的形态:从混沌到节拍器

We encoded the 200 loop calls into four symbols. S=git status --short (85), D=git diff [file] (53), T=git diff --stat (49), V=variants (13). The timeline shows three clear phases:把 200 次循环调用编码为符号:S=git status --short(85 次)、D=git diff [file](53 次)、T=git diff --stat(49 次)、V=写法变体(13 次),时序呈现三个相位:

[0..49]    VSDVTDVDTVVSVVTVDSVDSDVVDSTVDSVDSTDSTDSTDSTDSTDSTD
[50..99]   STDSTDSTDSTDSTSDSTSDSTSDSTSDSTSDSTSDSTSDSTSDSTSDST
[100..149] SDSTSDSTSDSTSDSTSDSTSDSTSDSTSDSTSDSTSDSTSDSTSDSTSD
[150..199] STSDSTSDSTSDSTSDSTSDSTSDSTSDSTSDSTSDSTSDSTSDSTSDST

The important consequence: entropy decreases over time. Variants vanish after the first 15% of the loop. The model does not get messier, it gets more regular. A detector based on spotting variation or on adjacent repeats cannot see the metronome phase at all.关键推论:熵随时间下降——变体在开头 15% 之后归零。模型不是越转越乱,而是越转越「整齐」。靠「识别变化」或「相邻重复」的检测器,在节拍器相位里完全失明。

3. The unified comparison matrix3. 统一对比矩阵

Implementations as rows, dimensions as columns. A dash means "not applicable" or "no general detector".实现为行、维度为列。「—」表示不适用或无通用检测器。

ImplementationPredicateOutput compared?Scope / windowThresholdsAction on triggerReset on user msgHard breaker
opencode v1 doom_loopsame tool + raw JSON input❌one assistant message3permission ask—❌ ("always" bypasses)
kimi-code Repeat Breakertool + normalized-args sha256❌cross-step streak3/5/8/12tiered warnings → stop turn at 12—✅ at 12
deepseek-harness repeat-tool-remindertool + key-sorted params❌chained consecutive calls3/5/8reminder only, never blocks✅ resets the chain❌
Claude Codeno general detector—————only maxTurns
Codexno general detector (goal mode: by output per round)❌goal rounds3 idle or failed roundsgoal marked blocked—✅ goal mode only
pino general detector—————❌
gemini-cli LoopDetectionServicesha256(tool + params), k-period cycles + LLM layer❌ (LLM layer sees results)last 25 calls, text 5000 charsk=1..5 ×5 repeats, text 10, LLM from turn 30first: inject feedback. Second: abort stream and ask✅ hard reset✅ second hit
OpenClaw tool-loop-*tool + params hash + result hash + failure-equivalence hash✅ result hash gates every streaklast 30 calls, per runIdwarn 10 / block 20 / global 30 / unknown-tool 10warn: append to result. Severe: block the batch. Second severe: end the runper runId✅ block 20 + global 30
Cline LoopDetectionTrackertool + JSON.stringify(sortKeys(params))❌adjacent only, resets on any different callwarn 3 / block 5warn: inject a user message. Block: stop the run until the user replies❌ (only compaction/restore)block stops the run
Roo Code ToolRepetitionDetectorstable-stringify({name, params, nativeArgs})❌adjacent only3 (reuses mistake limit)block the call, ask the user inline❌block stops the run
crewAIsanitize(tool) + params, shallow❌adjacent pair only1 (any adjacent repeat)skip the call, return "stop repeating" as the tool result——
AutoGPT (platform)(tool, json.dumps(args, sort_keys=True))❌ (only the failure outcome)adjacent only, success resets3 failures, 5 empty callsblock and return STOP as the tool result. Empty-call breaker aborts the stream❌✅ empty-call breaker
takt(tool, outcome, inputHash, resultHash) 4-tuple, domain-separated sha256✅ resultHash is part of the tupleadjacent only (single lastKey)12 exact repeats, 2 unknown tools, 4 bad args, 3 edit conflicts, 10 errorsabort the SSE stream → corrective prompt (once per fingerprint) → fresh session retry → failper-attempt lifecyclestream abort
AgentSight (alibaba/anolisa)out-of-process observability, no inline gaten/aprocess leveln/an/an/an/a
Our designA: tool + canonical input, B: output lines, C: tool + failure-equivalence hash✅ A counts only on byte-identical outputper signature, no window, interleaving never resetsA: warn 3 / block 6, B: 3/5/8/12, C: warn 4 / block 6permission ask (per signature), breaker stops the runno (A/C survive)✅ B at 12

4. The stranded PRs, replayed on the incident4. 搁浅 PR 在本事故序列上的回放

We replayed the earlier opencode PR attempts on the same 200-call incident. This shows where each one fires, and why each fails:我们把 opencode 此前的几个 PR 方案在同一份 200 次调用序列上做了回放,看各自在哪里触发、为何失败:

ApproachMechanismFires?Why it fails
Old doom loop (v1)last 3 parts of one message, identicalnever (one tool part per message)scope: single message
#32089count same signature ≥3 across the whole sessioncall #7 (earliest)no window: npm test hours apart also trips. Full history read per call
#47797ring buffer, period p=1..10, last 3 blocks equalcall #39 (catches the period-3 phase)lives inside one drain: a user message resets it. Re-prompts every round
#47845block repeats inside one messageneversame scope problem, plus O(n²) recomputation
#4627210 identical calls in a user turn, hard stopnever (no run of 10 in a rotation)no permission flow, no escape hatch. 9 wasted rounds before the threshold
#37109fingerprint of terminal output ×3never (the three commands have different outputs)large diff, high review cost; closed by a bot for template non-compliance, no technical review
Our signal A (signature + output ×6)per-signature independent streakcall #21—
Our signal B (line novelty ×8)global line-level noveltycall #12—

Lessons we took. The right scope is the current user turn across assistant messages. Detector state stays in memory, with no database read per call. Use canonical serialization, not raw JSON.stringify. Ask through the permission flow and de-duplicate prompts. Several of these PRs died from process reasons (no review, template checks, staleness), not from being wrong.我们得到的启示:正确的范围是当前用户回合内、跨 assistant 消息;检测状态驻内存,绝不每次调用读数据库;用规范化序列化而非裸 JSON.stringify;走权限流且询问去重。这些 PR 多数死于流程原因(无人 review、模板检查、陈旧),而不是方案错误。

5. Community demand and the detector's history5. 社区需求与检测器的历史

6. Introduction timeline: three harnesses compared6. 引入时间线:三家对照

IntroducedSince then
gemini-cli2025-07-15; hash-based cycle detection landed 10 days laterthree layers, continuously improved (LLM layer added 2025-07-18)
opencode doom_loop2025-10-30 (PR #3445)no algorithm change for a year
OpenClaw2026-02-167 targeted hardenings in 8 months, 6 detectors (output hash, failure equivalence, argument churn)

opencode's detector was born three months after gemini-cli's hash-based cycle detection and then stood still. That is a fully verifiable statement, and it frames the gap better than claiming nobody solved the problem.opencode 的检测器比 gemini-cli 的哈希式周期检测晚出生三个月,此后没有任何算法演进。这条完全可核实,也比「没人解决过」更能说明差距。

7. Cross-cutting patterns7. 横向规律

  1. "3" is the shared warning threshold across opencode, kimi-code, deepseek-harness and Codex's goal mode.「连续 3 次」是各家警告档的共同起点(opencode、kimi-code、deepseek-harness、Codex goal 模式)。
  2. Two philosophies: hard breakers (kimi-code stops the turn at 12, Codex marks the goal blocked) vs soft reminders (deepseek-harness never blocks, opencode asks). Unattended runs defeat soft reminders.两派哲学:硬熔断派(kimi-code 到 12 停 turn、Codex goal 置 blocked)与软提醒派(deepseek-harness 绝不拦截、opencode 问用户)。无人值守场景下软提醒会被绕过。
  3. Scope decides effectiveness. The v1 detector failed because real loops run one or two calls per message across many messages. kimi-code's cross-step streak is the only design that held up against that shape.检测范围决定有效性。v1 检测器败在「单条消息内」——真实死循环都是跨消息、每轮一两个调用。kimi-code 的跨 step streak 是唯一能经得起这种形态的设计。
  4. Variant escape. Decorative variants bypass byte-level input comparison (echo "---" separators, quoting). Key sorting fixes attribute order only, not command-string variants.变体逃逸。逐字节比对会被 echo "---" 这类装饰变体绕过;参数 key 排序只解决属性序,不解决命令字符串级变体。
  5. The need is general. Claude Code and Codex keep adding guards for specific no-progress shapes. But nobody offers a general tool-call-level detector with output-identity gating and no window.需求是普遍的。Claude Code 与 Codex 都在为特定的无进展形态补洞,但「工具调用级 + 输出一致性 + 无窗口」的通用检测,目前没人做。

8. Design positioning: the one hard difference8. 设计定位:最硬的那条差异

Window size must be at least threshold × rotation period, or the block tier is dead code. OpenClaw's window of 30 calls and block tier of 20 can never catch a period-4 rotation (the measured max streak is 15). gemini-cli avoids this with exact period matching, at the cost of firing on any variant. Our signal A has no time window at all. Each signature stores only its last output hash and its streak. A period-4 signature climbs to 85 repeats. Memory is O(number of signatures), cost per call is O(1), and we never read history.窗口大小必须 ≥ 阈值 × 轮换周期,否则拦截档就是死代码。OpenClaw 窗口 30、拦截档 20,在周期 4 轮换下够不着(实测单签名最大 streak 15)。gemini-cli 靠精确周期匹配绕开,代价是一个变体就打断。我们的信号 A 不设时间窗口:每个签名只存「上次输出哈希 + 当前 streak」,所以周期 4 的签名能一路累到 85 次。内存 O(签名数)、单次调用 O(1)、不读历史库。

The replay numbers behind that claim: on the incident, gemini-cli fires at call #45. OpenClaw never reaches its block tier. Our B fires at #12 and A at #21. We do not need to claim that nobody did this before — the four numbers carry the argument.支撑这条的实测数字:同一份事故序列上,gemini-cli 在第 45 次调用触发,OpenClaw 永远到不了拦截档,我们的 B 在第 12 次、A 在第 21 次触发。我们不需要声称「没人做过」——四个数字本身就构成论据。

Two more positioning choices. Thresholds: 3 is the shared warning level. Block levels vary widely (Cline 5, Roo 3, OpenClaw 20). We warn early and keep the block tiers conservative. Escalation: gemini-cli injects feedback first and only asks the user on the second hit. Our tiers do the same. The model gets warnings before the user is ever prompted.另外两条定位。阈值:3 是各家共同的警告起点,但拦截档差异很大(Cline 5、Roo 3、OpenClaw 20),所以我们早警告、拦截档保持保守。升级方式:gemini-cli 先注入反馈、第二次才问用户;我们的档位也一样——先给模型警告,再轮到用户。

9. What we adopted, and what we deliberately rejected9. 我们采纳了什么,刻意拒绝了什么

10. Method10. 调研方法

We read every mechanism in the project's own repository at a recorded HEAD commit in October 2026. The commits: opencode v1.18.30, gemini-cli 56da16d, OpenClaw dd0ecdd4, Cline 95e54e4, Roo Code b867ec9, crewAI 42ae4bf, AutoGPT 0edeafc, takt 11c18844. The incident analysis comes from an anonymized real session export (models shown as Model A / Model B). We did not rely on second-hand descriptions. Where a project has no detector, we say so directly.以上每条机制均在对应项目仓库中按所记录的 HEAD commit 实读(2026 年 10 月:opencode v1.18.30;gemini-cli 56da16d;OpenClaw dd0ecdd4;Cline 95e54e4;Roo Code b867ec9;crewAI 42ae4bf;AutoGPT 0edeafc;takt 11c18844)。事故分析来自一份脱敏的真实会话导出(模型以 Model A / Model B 表示)。所有机制均以源码为准,不以转述为据;没有检测器的项目,文中直接写明。