fix(tools): dedup early-return bypasses loop detection in read_file (#15759) - #15762
Conversation
…ousResearch#15759) The dedup path at tools/file_tools.py:435 returned immediately on unchanged-file hits without updating the consecutive counter. The hard-block guard at line 538 (count >= 4) was therefore unreachable for models that repeatedly call read_file on a file that hasn't changed, producing an infinite loop of dedup stubs. Fix: before returning the dedup stub, acquire the tracker lock and increment consecutive exactly as the full-read path does. If the counter reaches the block threshold, return the BLOCKED error instead of the stub. If the counter reaches the warning threshold, annotate the stub with _warning to give the model a softer nudge first. notify_other_tool_call already resets consecutive=0, so interleaved tool calls continue to work correctly. Five new regression tests cover: dedup-hit warning, dedup-hit hard block, notify resets counter through the dedup path, and task isolation. Regression guard: removed fix → test_dedup_third_read_returns_stub_with_warning and test_dedup_fourth_read_is_hard_blocked fail; restored → 30/30 pass. CI baseline (8 pre-existing failures on origin/main) unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Fixes an infinite-loop scenario where read_file_tool would return a dedup stub for unchanged files without incrementing the consecutive re-read counter, preventing warning/block escalation.
Changes:
- Update the dedup-return path in
read_file_toolto incrementlast_key/consecutiveand emit warning/block responses at the same thresholds as the full-read path. - Add dedup-specific loop-detection tests covering stub, warning, hard-block, counter reset, and task isolation cases.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| tools/file_tools.py | Ensures dedup hits participate in consecutive-loop detection and can trigger warning/block responses. |
| tests/tools/test_read_loop_detection.py | Adds regression tests validating warning/block behavior for consecutive dedup hits. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| read_key = ("read", path, offset, limit) | ||
| with _read_tracker_lock: | ||
| if task_data["last_key"] == read_key: | ||
| task_data["consecutive"] += 1 | ||
| else: | ||
| task_data["last_key"] = read_key | ||
| task_data["consecutive"] = 1 |
There was a problem hiding this comment.
Loop detection can still be bypassed by calling read_file_tool with different string forms that resolve to the same file (e.g., ./foo.py vs foo.py). Dedup uses resolved_str for dedup_key, but read_key uses the raw path, so last_key changes and consecutive resets, preventing the >=3/4 warning/block from ever triggering. Consider building read_key from the resolved path (and updating the full-read path key similarly) so consecutive tracking matches the dedup identity.
| Without this fix, the dedup path returned early and the loop-detection guard | ||
| at line 538 was never reached — the agent could loop forever on a dedup stub. |
There was a problem hiding this comment.
This docstring hard-codes an internal line number ("at line 538"), which will become stale as tools/file_tools.py changes. Prefer referencing the guard semantically (e.g., "the loop-detection guard in read_file_tool") or linking to the relevant condition (e.g., count >= 4) instead of a specific line number.
| Without this fix, the dedup path returned early and the loop-detection guard | |
| at line 538 was never reached — the agent could loop forever on a dedup stub. | |
| Without this fix, the dedup path returned early and bypassed the | |
| loop-detection guard in read_file_tool, so the agent could loop forever | |
| on a dedup stub. |
| def test_dedup_second_read_returns_stub_no_warning(self, _mock_ops, _mock_mtime): | ||
| """2nd read (dedup hit) returns the stub but no warning yet.""" | ||
| read_file_tool("/tmp/test.py", task_id="td1") | ||
| result = json.loads(read_file_tool("/tmp/test.py", task_id="td1")) | ||
| assert result.get("dedup") is True | ||
| assert "_warning" not in result | ||
| assert "error" not in result | ||
|
|
There was a problem hiding this comment.
These new tests are inside a unittest.TestCase but use bare assert statements, while the rest of this file uses self.assert* methods. Mixing styles makes failures less consistent and can reduce diagnostics when running under unittest directly. Consider converting these assertions to self.assertIn/self.assertNotIn/self.assertTrue, etc., for consistency with the surrounding tests.
…_file (NousResearch#15759) Address three Copilot review findings on PR NousResearch#15762: 1. Loop-detection bypass via alternate path forms: both read_key constructions now use resolved_str instead of the raw path argument, so ./foo.py and /abs/foo.py share the consecutive counter and cannot reset it by switching string forms. 2. Stale line-number reference in the TestDedupLoopDetection docstring ("at line 538") removed; guard referenced semantically instead. 3. Bare assert statements in TestDedupLoopDetection converted to self.assert* methods for consistent unittest failure reporting. Added test_dedup_path_normalization_shares_counter to prove the bypass is closed: reverted fix → test fails with dedup stub instead of BLOCKED. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Thanks @copilot — all three findings are real. Addressed in `3dfec2cd`. 1. Loop-detection bypass via alternate path forms (line 448) 2. Hard-coded line number in docstring (test line 198) 3. Bare `assert` in `TestDedupLoopDetection` (test line 216) |
|
@copilot Both issues are already resolved: the docstring no longer references any hardcoded line number, and all assertions in |
|
Closing — superseded by @teknium1's #16382, which closed #15759 and is the better fix here. Both PRs target the read_file dedup-stub vs loop-detector race. Coverage comparison:
No remaining gap. Thanks @teknium1. |
Summary
read_filededup path attools/file_tools.py:435returned immediately for unchanged-file hits without updating the consecutive re-read counter.count >= 4) and the warning guard (count >= 3) at line 538 were unreachable for dedup hits.read_fileon an unchanged file received an infinite stream of dedup stubs with no escalation.The bug
_read_tracker[task_id]["consecutive"]is only incremented in the full-read path (line 504). The dedup path returned before that code ran:Issue reported with Qwen3.6 local models that became stuck re-reading files mid-task after a
hermes update.The fix
Before returning the dedup stub, acquire
_read_tracker_lockand updatelast_key/consecutiveusing the same logic as the full-read path. Ifcount >= 4, return theBLOCKEDerror instead of the stub. Ifcount >= 3, annotate the stub with_warning.notify_other_tool_call()already zeroesconsecutive, so interleaved tool calls continue to work correctly.Test plan
test_dedup_fourth_read_is_hard_blockedandtest_dedup_third_read_returns_stub_with_warningfail (stub returned with no warning or block)origin/main— unchangedRelated
🤖 Generated with Claude Code