mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-19 11:06:18 +00:00
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:
parent
86406cf197
commit
582a632868
10
CHANGELOG.md
10
CHANGELOG.md
@ -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
|
||||
|
||||
@ -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 步:内置
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user