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 ### 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 - **subagents:** Give `max_turns` the meaning operators read it as. It was
handed to LangGraph as `recursion_limit`, which counts super-steps — one per handed to LangGraph as `recursion_limit`, which counts super-steps — one per
graph node — while `create_agent` compiles a node for every middleware 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 的 - **子智能体:** `max_turns` 现在真正表示运维人员理解的"轮次"。此前它被直接当作 LangGraph 的
`recursion_limit` 传入,而后者统计的是 super-step——每个图节点一步,且 `create_agent` 会为每个 `recursion_limit` 传入,而后者统计的是 super-step——每个图节点一步,且 `create_agent` 会为每个
中间件生命周期钩子编译出一个节点,因此在子智能体的中间件链上一轮要花掉 7~8 步:内置 中间件生命周期钩子编译出一个节点,因此在子智能体的中间件链上一轮要花掉 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_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 _DEFAULT_TOOL_FREQ_HARD_LIMIT = 50 # force-stop after 50 calls to the same tool type
_MAX_PENDING_WARNINGS_PER_RUN = 4 _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] 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) 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: def _stable_tool_key(name: str, args: dict, fallback_key: str | None) -> str:
"""Derive a stable key from salient args without overfitting to noise.""" """Derive a stable key from salient args without overfitting to noise."""
if name == "read_file" and fallback_key is None: if name == "read_file" and fallback_key is None:
path = args.get("path") or "" path = args.get("path") or ""
start_line = args.get("start_line") start_line, end_line = _normalized_read_range(args)
end_line = args.get("end_line") return f"{path}:{start_line}-{end_line if end_line is not None else _OPEN_ENDED_READ}"
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}"
# write_file / str_replace are content-sensitive: same path may be updated # write_file / str_replace are content-sensitive: same path may be updated
# with different payloads during iteration. Using only salient fields (path) # 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]) 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: class TestLoopDetection:
def test_no_tool_calls_returns_none(self): def test_no_tool_calls_returns_none(self):
mw = LoopDetectionMiddleware() mw = LoopDetectionMiddleware()