fix(agents): key read_file loop detection on its exact line window (#5486)

Layer 1 quantized read_file's line range into 200-line buckets, which
erased the offset inside a bucket: every read shorter than a bucket
collapsed onto its neighbours. Five sequential 40-line reads hashed
identically and tripped the hard stop, ending the run with a forced final
answer and stop_reason=loop_capped — on exactly the ranged reads that
read_file's own truncation notice tells the model to make.

Bucketing cannot separate progress from repetition in general: an equality
key can only approximate range overlap, and the approximation was erasing
the offset that distinguishes the two. Key on the exact window instead,
with an omitted end_line kept open-ended so a bare read and an explicit
start_line=1 still share one key.

Repeating a single range is still caught at the same threshold, and a read
loop that varies its bounds remains covered by the per-tool frequency
layer.

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Hyeonsang Cho 2026-09-17 10:36:55 +09:00 committed by GitHub
parent 86406cf197
commit 582a632868
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 140 additions and 19 deletions

View File

@ -582,6 +582,16 @@ This section accumulates work toward the **2.1.0** milestone
### Fixed
- **middleware:** Stop loop detection from cutting off an agent that pages
through a file. `read_file` calls were keyed by 200-line buckets, so every
read shorter than a bucket collapsed onto its neighbours: five sequential
40-line reads hashed identically and tripped the hard stop, ending the run
with a forced final answer and `stop_reason=loop_capped` — on exactly the
ranged reads `read_file`'s own truncation notice tells the model to make.
The key now uses the exact line window, with an omitted `end_line` kept
open-ended so a bare read and an explicit `start_line=1` still share one key.
Repeating a single range is still caught at the same threshold, and a read
loop that varies its bounds remains covered by the per-tool frequency layer.
- **subagents:** Give `max_turns` the meaning operators read it as. It was
handed to LangGraph as `recursion_limit`, which counts super-steps — one per
graph node — while `create_agent` compiles a node for every middleware

View File

@ -397,6 +397,12 @@
### 修复
- **中间件:** 循环检测不再中断正在分段读取文件的智能体。此前 `read_file` 调用按 200 行分桶作为
键,因此任何短于一个桶的读取都会与相邻读取塌缩到同一个键:连续五次 40 行读取会哈希成相同值并
触发硬停止,运行被迫给出最终答复并带上 `stop_reason=loop_capped`——而这恰恰是 `read_file` 自身
的截断提示要求模型去做的分段读取。现在键使用精确的行区间,省略 `end_line` 时保持"读到末行"的
开放语义,因此不带范围的读取与显式 `start_line=1` 仍共用同一个键。重复同一区间依然会在原有阈值
被拦下,边界抖动的读取循环仍由按工具类型计数的频率层覆盖。
- **子智能体:** `max_turns` 现在真正表示运维人员理解的"轮次"。此前它被直接当作 LangGraph 的
`recursion_limit` 传入,而后者统计的是 super-step——每个图节点一步`create_agent` 会为每个
中间件生命周期钩子编译出一个节点,因此在子智能体的中间件链上一轮要花掉 7~8 步:内置

View File

@ -98,6 +98,9 @@ _DEFAULT_MAX_TRACKED_THREADS = 100 # LRU limit for tracked thread/run scopes
_DEFAULT_TOOL_FREQ_WARN = 30 # warn after 30 calls to the same tool type
_DEFAULT_TOOL_FREQ_HARD_LIMIT = 50 # force-stop after 50 calls to the same tool type
_MAX_PENDING_WARNINGS_PER_RUN = 4
# Stands in for ``read_file``'s omitted ``end_line`` in a call key: the read
# runs to the last line, which is not the same window as any numbered bound.
_OPEN_ENDED_READ = "end"
type _RunScopeKey = tuple[str, str | None]
@ -128,29 +131,50 @@ def _normalize_tool_call_args(raw_args: object) -> tuple[dict, str | None]:
return {}, json.dumps(raw_args, sort_keys=True, default=str)
def _coerce_line_number(value: object) -> int | None:
"""Parse one ``read_file`` line bound, or ``None`` when absent or unusable."""
try:
line = int(value) # type: ignore[arg-type]
except (TypeError, ValueError):
return None
return max(line, 1)
def _normalized_read_range(args: dict) -> tuple[int, int | None]:
"""Normalize ``read_file``'s line range into one comparable window.
Omitting ``end_line`` reads through the last line, so it normalizes to
``None`` (open-ended) rather than collapsing onto ``start_line``: that keeps
``read_file(path)`` and ``read_file(path, start_line=1)`` the same read,
written two ways on a single key. A reversed range is ordered, so one
window written either way also produces a single key.
The window is otherwise kept exact. Quantizing it into 200-line buckets (the
original heuristic) collapsed every read shorter than a bucket onto its
neighbours, so an agent paging a file in 40-line chunks tripped the hard
stop on its fifth *distinct* read while ``read_file``'s own truncation
notice tells the model to page with ``start_line``/``end_line``. Bucketing
cannot separate progress from repetition in general: equality keys can only
approximate range overlap, and the approximation was erasing the offset that
distinguishes the two. An exact window still catches the loop this layer
exists for the same read emitted over and over and a loop that jitters
its bounds is what Layer 2's per-tool frequency window covers.
"""
start_line = _coerce_line_number(args.get("start_line"))
end_line = _coerce_line_number(args.get("end_line"))
if start_line is None:
start_line = 1
if end_line is not None and end_line < start_line:
start_line, end_line = end_line, start_line
return start_line, end_line
def _stable_tool_key(name: str, args: dict, fallback_key: str | None) -> str:
"""Derive a stable key from salient args without overfitting to noise."""
if name == "read_file" and fallback_key is None:
path = args.get("path") or ""
start_line = args.get("start_line")
end_line = args.get("end_line")
bucket_size = 200
try:
start_line = int(start_line) if start_line is not None else 1
except (TypeError, ValueError):
start_line = 1
try:
end_line = int(end_line) if end_line is not None else start_line
except (TypeError, ValueError):
end_line = start_line
start_line, end_line = sorted((start_line, end_line))
bucket_start = max(start_line, 1)
bucket_end = max(end_line, 1)
bucket_start = (bucket_start - 1) // bucket_size
bucket_end = (bucket_end - 1) // bucket_size
return f"{path}:{bucket_start}-{bucket_end}"
start_line, end_line = _normalized_read_range(args)
return f"{path}:{start_line}-{end_line if end_line is not None else _OPEN_ENDED_READ}"
# write_file / str_replace are content-sensitive: same path may be updated
# with different payloads during iteration. Using only salient fields (path)

View File

@ -196,6 +196,87 @@ class TestHashToolCalls:
assert _hash_tool_calls([a]) != _hash_tool_calls([b])
class TestReadFileRangeKey:
"""``read_file`` keys must separate paging progress from re-reading.
Line ranges used to be quantized into 200-line buckets, which erased the
offset inside a bucket: every read shorter than 200 lines collapsed onto its
neighbours, so paging one file in 40-line chunks looked like five identical
calls and tripped the hard stop on reads ``read_file``'s own truncation
notice tells the model to make.
"""
@staticmethod
def _read_call(path="/w/app.py", **range_args):
return {"name": "read_file", "id": "call_read", "args": {"path": path, **range_args}}
def test_adjacent_pages_are_distinct_calls(self):
pages = [self._read_call(start_line=start, end_line=start + 39) for start in (1, 41, 81, 121, 161)]
hashes = {_hash_tool_calls([page]) for page in pages}
assert len(hashes) == len(pages)
def test_paging_through_a_file_does_not_hard_stop(self):
"""Regression: five sequential 40-line reads used to force a final answer."""
mw = LoopDetectionMiddleware(warn_threshold=3, hard_limit=5)
runtime = _make_runtime()
for start in range(1, 401, 40):
decision = mw._apply(_make_state(tool_calls=[self._read_call(start_line=start, end_line=start + 39)]), runtime)
assert decision is None, f"read of lines {start}-{start + 39} was treated as a loop"
assert mw.consume_stop_reason("test-run") is None
def test_repeating_one_range_still_hard_stops(self):
mw = LoopDetectionMiddleware(warn_threshold=3, hard_limit=5)
runtime = _make_runtime()
call = [self._read_call(start_line=1, end_line=40)]
for _ in range(4):
assert mw._apply(_make_state(tool_calls=call), runtime) is None
hard_stop = mw._apply(_make_state(tool_calls=call), runtime)
assert hard_stop is not None
assert hard_stop["messages"][0].tool_calls == []
assert mw.consume_stop_reason("test-run") == "loop_capped"
def test_rereading_the_same_page_counts_even_between_new_pages(self):
"""Interleaving fresh pages must not hide a repeated read: the window counts by key."""
mw = LoopDetectionMiddleware(warn_threshold=3, hard_limit=3)
runtime = _make_runtime()
repeated = [self._read_call(start_line=1, end_line=40)]
assert mw._apply(_make_state(tool_calls=repeated), runtime) is None
assert mw._apply(_make_state(tool_calls=[self._read_call(start_line=41, end_line=80)]), runtime) is None
assert mw._apply(_make_state(tool_calls=repeated), runtime) is None
assert mw._apply(_make_state(tool_calls=[self._read_call(start_line=81, end_line=120)]), runtime) is None
assert mw._apply(_make_state(tool_calls=repeated), runtime) is not None
def test_omitted_end_line_matches_a_bare_read_of_the_same_file(self):
"""Both read to the last line, so they are one read written two ways."""
open_ended = self._read_call(start_line=1)
bare = self._read_call()
assert _hash_tool_calls([open_ended]) == _hash_tool_calls([bare])
def test_omitted_end_line_is_not_a_single_line_read(self):
open_ended = self._read_call(start_line=10)
single_line = self._read_call(start_line=10, end_line=10)
assert _hash_tool_calls([open_ended]) != _hash_tool_calls([single_line])
def test_line_bounds_are_clamped_to_the_first_line(self):
assert _hash_tool_calls([self._read_call(start_line=0, end_line=40)]) == _hash_tool_calls([self._read_call(start_line=1, end_line=40)])
def test_unparsable_end_line_reads_as_open_ended(self):
"""The tool rejects such a call anyway; the key must stay stable, not crash."""
assert _hash_tool_calls([self._read_call(start_line=5, end_line="oops")]) == _hash_tool_calls([self._read_call(start_line=5)])
def test_different_paths_stay_distinct(self):
assert _hash_tool_calls([self._read_call(path="/w/a.py", start_line=1, end_line=40)]) != _hash_tool_calls([self._read_call(path="/w/b.py", start_line=1, end_line=40)])
class TestLoopDetection:
def test_no_tool_calls_returns_none(self):
mw = LoopDetectionMiddleware()