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 实读确认。
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),任意连续三次都不相同。这种「周期轮换」形态正是多数现有检测器漏掉的。因此在设计自己的检测器之前,我们先调研了各家的做法。
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% 之后归零。模型不是越转越乱,而是越转越「整齐」。靠「识别变化」或「相邻重复」的检测器,在节拍器相位里完全失明。
Implementations as rows, dimensions as columns. A dash means "not applicable" or "no general detector".实现为行、维度为列。「—」表示不适用或无通用检测器。
| Implementation | Predicate | Output compared? | Scope / window | Thresholds | Action on trigger | Reset on user msg | Hard breaker |
|---|---|---|---|---|---|---|---|
opencode v1 doom_loop | same tool + raw JSON input | ❌ | one assistant message | 3 | permission ask | — | ❌ ("always" bypasses) |
kimi-code Repeat Breaker | tool + normalized-args sha256 | ❌ | cross-step streak | 3/5/8/12 | tiered warnings → stop turn at 12 | — | ✅ at 12 |
deepseek-harness repeat-tool-reminder | tool + key-sorted params | ❌ | chained consecutive calls | 3/5/8 | reminder only, never blocks | ✅ resets the chain | ❌ |
| Claude Code | no general detector | — | — | — | — | — | only maxTurns |
| Codex | no general detector (goal mode: by output per round) | ❌ | goal rounds | 3 idle or failed rounds | goal marked blocked | — | ✅ goal mode only |
| pi | no general detector | — | — | — | — | — | ❌ |
gemini-cli LoopDetectionService | sha256(tool + params), k-period cycles + LLM layer | ❌ (LLM layer sees results) | last 25 calls, text 5000 chars | k=1..5 ×5 repeats, text 10, LLM from turn 30 | first: 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 streak | last 30 calls, per runId | warn 10 / block 20 / global 30 / unknown-tool 10 | warn: append to result. Severe: block the batch. Second severe: end the run | per runId | ✅ block 20 + global 30 |
Cline LoopDetectionTracker | tool + JSON.stringify(sortKeys(params)) | ❌ | adjacent only, resets on any different call | warn 3 / block 5 | warn: inject a user message. Block: stop the run until the user replies | ❌ (only compaction/restore) | block stops the run |
Roo Code ToolRepetitionDetector | stable-stringify({name, params, nativeArgs}) | ❌ | adjacent only | 3 (reuses mistake limit) | block the call, ask the user inline | ❌ | block stops the run |
| crewAI | sanitize(tool) + params, shallow | ❌ | adjacent pair only | 1 (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 resets | 3 failures, 5 empty calls | block 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 tuple | adjacent only (single lastKey) | 12 exact repeats, 2 unknown tools, 4 bad args, 3 edit conflicts, 10 errors | abort the SSE stream → corrective prompt (once per fingerprint) → fresh session retry → fail | per-attempt lifecycle | stream abort |
| AgentSight (alibaba/anolisa) | out-of-process observability, no inline gate | n/a | process level | n/a | n/a | n/a | n/a |
| Our design | A: tool + canonical input, B: output lines, C: tool + failure-equivalence hash | ✅ A counts only on byte-identical output | per signature, no window, interleaving never resets | A: warn 3 / block 6, B: 3/5/8/12, C: warn 4 / block 6 | permission ask (per signature), breaker stops the run | no (A/C survive) | ✅ B at 12 |
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 次调用序列上做了回放,看各自在哪里触发、为何失败:
| Approach | Mechanism | Fires? | Why it fails |
|---|---|---|---|
| Old doom loop (v1) | last 3 parts of one message, identical | never (one tool part per message) | scope: single message |
| #32089 | count same signature ≥3 across the whole session | call #7 (earliest) | no window: npm test hours apart also trips. Full history read per call |
| #47797 | ring buffer, period p=1..10, last 3 blocks equal | call #39 (catches the period-3 phase) | lives inside one drain: a user message resets it. Re-prompts every round |
| #47845 | block repeats inside one message | never | same scope problem, plus O(n²) recomputation |
| #46272 | 10 identical calls in a user turn, hard stop | never (no run of 10 in a rotation) | no permission flow, no escape hatch. 9 wasted rounds before the threshold |
| #37109 | fingerprint of terminal output ×3 | never (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 streak | call #21 | — |
| Our signal B (line novelty ×8) | global line-level novelty | call #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、模板检查、陈旧),而不是方案错误。
JSON.stringify.检测器自引入起就没演进过。doom_loop 于 2025-10-30 由 PR #3445 引入。此后的改动全是权限层管线,检测算法本身一年未动:阈值仍是 3、范围仍是单条 assistant 消息、比对仍是裸 JSON.stringify。| Introduced | Since then | |
|---|---|---|
| gemini-cli | 2025-07-15; hash-based cycle detection landed 10 days later | three layers, continuously improved (LLM layer added 2025-07-18) |
| opencode doom_loop | 2025-10-30 (PR #3445) | no algorithm change for a year |
| OpenClaw | 2026-02-16 | 7 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 的哈希式周期检测晚出生三个月,此后没有任何算法演进。这条完全可核实,也比「没人解决过」更能说明差距。
echo "---" separators, quoting). Key sorting fixes attribute order only, not command-string variants.变体逃逸。逐字节比对会被 echo "---" 这类装饰变体绕过;参数 key 排序只解决属性序,不解决命令字符串级变体。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 先注入反馈、第二次才问用户;我们的档位也一样——先给模型警告,再轮到用户。
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 表示)。所有机制均以源码为准,不以转述为据;没有检测器的项目,文中直接写明。