Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,866 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests for read_file_tool safety guards: device-path blocking,
|
||||
character-count limits, file deduplication, and dedup reset on
|
||||
context compression.
|
||||
|
||||
Run with: python -m pytest tests/tools/test_file_read_guards.py -v
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from tools.file_tools import (
|
||||
read_file_tool,
|
||||
write_file_tool,
|
||||
reset_file_dedup,
|
||||
_is_blocked_device,
|
||||
_invalidate_dedup_for_path,
|
||||
_READ_DEDUP_STATUS_MESSAGE,
|
||||
_DEFAULT_MAX_READ_CHARS,
|
||||
_read_tracker,
|
||||
notify_other_tool_call,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _FakeReadResult:
|
||||
"""Minimal stand-in for FileOperations.read_file return value."""
|
||||
def __init__(self, content="line1\nline2\n", total_lines=2, file_size=100):
|
||||
self.content = content
|
||||
self._total_lines = total_lines
|
||||
self._file_size = file_size
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"content": self.content,
|
||||
"total_lines": self._total_lines,
|
||||
"file_size": self._file_size,
|
||||
}
|
||||
|
||||
|
||||
def _make_fake_ops(content="hello\n", total_lines=1, file_size=6):
|
||||
fake = MagicMock()
|
||||
fake.read_file = lambda path, offset=1, limit=500: _FakeReadResult(
|
||||
content=content, total_lines=total_lines, file_size=file_size,
|
||||
)
|
||||
return fake
|
||||
|
||||
|
||||
def _make_safe_tempdir(prefix: str) -> str:
|
||||
"""Create a temp dir outside macOS system-sensitive /private/var paths."""
|
||||
return tempfile.mkdtemp(prefix=prefix, dir=os.getcwd())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Device path blocking
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDevicePathBlocking(unittest.TestCase):
|
||||
"""Paths like /dev/zero should be rejected before any I/O."""
|
||||
|
||||
def test_blocked_device_detection(self):
|
||||
for dev in ("/dev/zero", "/dev/random", "/dev/urandom", "/dev/stdin",
|
||||
"/dev/tty", "/dev/console", "/dev/stdout", "/dev/stderr",
|
||||
"/dev/fd/0", "/dev/fd/1", "/dev/fd/2"):
|
||||
self.assertTrue(_is_blocked_device(dev), f"{dev} should be blocked")
|
||||
|
||||
def test_safe_device_not_blocked(self):
|
||||
self.assertFalse(_is_blocked_device("/dev/null"))
|
||||
self.assertFalse(_is_blocked_device("/dev/sda1"))
|
||||
|
||||
def test_proc_fd_blocked(self):
|
||||
self.assertTrue(_is_blocked_device("/proc/self/fd/0"))
|
||||
self.assertTrue(_is_blocked_device("/proc/12345/fd/2"))
|
||||
|
||||
def test_proc_fd_other_not_blocked(self):
|
||||
# The path-pattern check only blocklists /fd/0, /fd/1, /fd/2 as stdio
|
||||
# aliases. Higher-numbered fds are not pattern-blocked; whether they
|
||||
# ultimately get blocked depends on realpath resolution (a separate
|
||||
# concern, handled in test_symlink_to_blocked_device_is_blocked).
|
||||
# Using the lower-level _is_blocked_device_path here keeps the
|
||||
# assertion stable across environments where pytest workers happen to
|
||||
# have fd 3 dup'd to a blocked device.
|
||||
from tools.file_tools import _is_blocked_device_path
|
||||
|
||||
self.assertFalse(_is_blocked_device_path("/proc/self/fd/3"))
|
||||
|
||||
def test_proc_sensitive_pseudo_files_blocked(self):
|
||||
"""environ/cmdline/maps (and maps variants) under /proc/<pid> must be blocked (issue #4427)."""
|
||||
for path in (
|
||||
"/proc/self/environ",
|
||||
"/proc/12345/environ",
|
||||
"/proc/self/cmdline",
|
||||
"/proc/99/cmdline",
|
||||
"/proc/self/maps",
|
||||
"/proc/1/maps",
|
||||
"/proc/self/smaps",
|
||||
"/proc/12345/smaps",
|
||||
"/proc/self/smaps_rollup",
|
||||
"/proc/99/smaps_rollup",
|
||||
"/proc/self/numa_maps",
|
||||
"/proc/1/numa_maps",
|
||||
"/proc/self/mem",
|
||||
"/proc/12345/mem",
|
||||
"/proc/self/auxv",
|
||||
"/proc/1/auxv",
|
||||
"/proc/self/pagemap",
|
||||
"/proc/99/pagemap",
|
||||
):
|
||||
self.assertTrue(_is_blocked_device(path), f"{path} should be blocked")
|
||||
|
||||
def test_proc_task_thread_sensitive_files_blocked(self):
|
||||
"""Per-thread /proc/<pid>/task/<tid>/<file> aliases leak the same data."""
|
||||
for path in (
|
||||
"/proc/self/task/1234/maps",
|
||||
"/proc/self/task/1234/smaps",
|
||||
"/proc/self/task/1234/auxv",
|
||||
"/proc/self/task/1234/pagemap",
|
||||
"/proc/self/task/1234/environ",
|
||||
):
|
||||
self.assertTrue(_is_blocked_device(path), f"{path} should be blocked")
|
||||
|
||||
def test_proc_legitimate_files_not_blocked(self):
|
||||
"""Top-level /proc files like cpuinfo and meminfo must remain accessible."""
|
||||
for path in ("/proc/cpuinfo", "/proc/meminfo", "/proc/uptime", "/proc/version"):
|
||||
self.assertFalse(_is_blocked_device(path), f"{path} should not be blocked")
|
||||
|
||||
def test_normpath_alias_to_blocked_device_is_blocked(self):
|
||||
self.assertTrue(_is_blocked_device("/dev/../dev/zero"))
|
||||
self.assertTrue(_is_blocked_device("/dev/./urandom"))
|
||||
|
||||
def test_normal_files_not_blocked(self):
|
||||
self.assertFalse(_is_blocked_device("/tmp/test.py"))
|
||||
self.assertFalse(_is_blocked_device("/home/user/.bashrc"))
|
||||
|
||||
def test_symlink_to_blocked_device_is_blocked(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
link_path = os.path.join(tmpdir, "zero-link")
|
||||
try:
|
||||
os.symlink("/dev/zero", link_path)
|
||||
except OSError as exc:
|
||||
self.skipTest(f"symlink unavailable: {exc}")
|
||||
self.assertTrue(_is_blocked_device(link_path))
|
||||
|
||||
def test_symlink_to_regular_file_not_blocked(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
target_path = os.path.join(tmpdir, "regular.txt")
|
||||
link_path = os.path.join(tmpdir, "regular-link")
|
||||
with open(target_path, "w", encoding="utf-8") as handle:
|
||||
handle.write("safe\n")
|
||||
try:
|
||||
os.symlink(target_path, link_path)
|
||||
except OSError as exc:
|
||||
self.skipTest(f"symlink unavailable: {exc}")
|
||||
self.assertFalse(_is_blocked_device(link_path))
|
||||
|
||||
|
||||
def test_read_file_tool_rejects_device(self):
|
||||
"""read_file_tool returns an error without any file I/O."""
|
||||
result = json.loads(read_file_tool("/dev/zero", task_id="dev_test"))
|
||||
self.assertIn("error", result)
|
||||
self.assertIn("device file", result["error"])
|
||||
|
||||
@patch("tools.file_tools._get_file_ops")
|
||||
def test_read_file_tool_rejects_device_symlink_before_io(self, mock_ops):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
link_path = os.path.join(tmpdir, "zero-link")
|
||||
try:
|
||||
os.symlink("/dev/zero", link_path)
|
||||
except OSError as exc:
|
||||
self.skipTest(f"symlink unavailable: {exc}")
|
||||
|
||||
result = json.loads(read_file_tool(link_path, task_id="dev_link_test"))
|
||||
|
||||
self.assertIn("error", result)
|
||||
self.assertIn("device file", result["error"])
|
||||
mock_ops.assert_not_called()
|
||||
|
||||
@patch("tools.file_tools._get_file_ops")
|
||||
def test_read_file_tool_rejects_task_cwd_relative_device_alias_symlink(self, mock_ops):
|
||||
if not os.path.exists("/dev/stdin"):
|
||||
self.skipTest("/dev/stdin is not available on this platform")
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
workspace = os.path.join(tmpdir, "workspace")
|
||||
process_cwd = os.path.join(tmpdir, "process")
|
||||
os.mkdir(workspace)
|
||||
os.mkdir(process_cwd)
|
||||
link_path = os.path.join(workspace, "stdin-link")
|
||||
try:
|
||||
os.symlink("/dev/../dev/stdin", link_path)
|
||||
except OSError as exc:
|
||||
self.skipTest(f"symlink unavailable: {exc}")
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(process_cwd)
|
||||
with patch.dict(os.environ, {"TERMINAL_CWD": workspace}, clear=False):
|
||||
result = json.loads(read_file_tool("stdin-link", task_id="dev_rel_link_test"))
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
self.assertIn("error", result)
|
||||
self.assertIn("device file", result["error"])
|
||||
mock_ops.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Non-regular files (FIFOs, sockets, directories)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestNonRegularFileReads(unittest.TestCase):
|
||||
"""Blocking paths the device blocklist structurally cannot cover.
|
||||
|
||||
The blocklist matches literal ``/dev/*`` names. A FIFO is a file *type*
|
||||
and can sit at any path, so no name list catches it. Reading one with no
|
||||
writer blocks in the size probe, and the read helpers pass no timeout, so
|
||||
the turn wedges until the process is killed.
|
||||
|
||||
Each read runs on a worker thread with a wall clock: a thread still alive
|
||||
at the deadline means the call blocked, which fails as an assertion
|
||||
instead of hanging the suite.
|
||||
"""
|
||||
|
||||
DEADLINE_SECONDS = 20.0
|
||||
|
||||
def _read_within_deadline(self, path, task_id):
|
||||
import threading
|
||||
|
||||
box = {}
|
||||
|
||||
def call():
|
||||
try:
|
||||
box["raw"] = read_file_tool(path, task_id=task_id)
|
||||
except BaseException as exc: # noqa: BLE001
|
||||
box["exc"] = exc
|
||||
|
||||
worker = threading.Thread(target=call, daemon=True)
|
||||
worker.start()
|
||||
worker.join(self.DEADLINE_SECONDS)
|
||||
self.assertFalse(
|
||||
worker.is_alive(),
|
||||
f"read_file_tool({path!r}) still running after "
|
||||
f"{self.DEADLINE_SECONDS:.0f}s — the read blocked",
|
||||
)
|
||||
if "exc" in box:
|
||||
raise box["exc"]
|
||||
return json.loads(box["raw"])
|
||||
|
||||
def test_read_file_tool_on_fifo_errors_instead_of_blocking(self):
|
||||
if not hasattr(os, "mkfifo"):
|
||||
self.skipTest("platform has no os.mkfifo")
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
fifo_path = os.path.join(tmpdir, "pipe")
|
||||
try:
|
||||
os.mkfifo(fifo_path)
|
||||
except (OSError, NotImplementedError) as exc:
|
||||
self.skipTest(f"mkfifo unavailable: {exc}")
|
||||
|
||||
result = self._read_within_deadline(fifo_path, "fifo_read_test")
|
||||
|
||||
# The tool layer intercepts first with a success=False NOTE (a fact
|
||||
# about the file, not an error — merged stat-guard design); the
|
||||
# shell-layer sentinel behind it errors. Accept either surface.
|
||||
surface = result.get("error") or result.get("note") or ""
|
||||
self.assertTrue(surface, f"expected error or note, got: {result}")
|
||||
self.assertIn("not a regular file", surface)
|
||||
|
||||
def test_read_file_tool_on_directory_errors_instead_of_blocking(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
result = self._read_within_deadline(tmpdir, "dir_read_test")
|
||||
|
||||
self.assertIn("error", result)
|
||||
self.assertIn("not a regular file", result["error"])
|
||||
|
||||
def test_regular_file_still_reads(self):
|
||||
"""The guard must not cost ordinary reads their content."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
target = os.path.join(tmpdir, "notes.txt")
|
||||
with open(target, "w", encoding="utf-8") as handle:
|
||||
handle.write("first line\nsecond line\n")
|
||||
|
||||
result = self._read_within_deadline(target, "regular_read_test")
|
||||
|
||||
self.assertNotIn("error", result)
|
||||
self.assertIn("second line", result["content"])
|
||||
|
||||
def test_missing_file_still_reports_not_found(self):
|
||||
"""An absent path keeps the not-found wording, not the type error."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
missing = os.path.join(tmpdir, "no-such-file.txt")
|
||||
result = self._read_within_deadline(missing, "missing_read_test")
|
||||
|
||||
self.assertIn("error", result)
|
||||
self.assertNotIn("not a regular file", result["error"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Character-count limits
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCharacterCountGuard(unittest.TestCase):
|
||||
"""Oversized reads are truncated on a line boundary (nearai/ironclaw#5029),
|
||||
not rejected — the model gets the head of the file plus a next_offset."""
|
||||
|
||||
def setUp(self):
|
||||
_read_tracker.clear()
|
||||
|
||||
def tearDown(self):
|
||||
_read_tracker.clear()
|
||||
|
||||
@patch("tools.file_tools._get_file_ops")
|
||||
@patch("tools.file_tools._get_max_read_chars", return_value=1000)
|
||||
def test_oversized_multiline_read_truncated_with_continuation(self, _mock_limit, mock_ops):
|
||||
"""A read whose many lines exceed the char budget is trimmed to the
|
||||
last complete line and offers a next_offset, instead of returning an
|
||||
error with no content."""
|
||||
# 50 lines of 100 chars each = ~5050 chars, well over the 1000 budget.
|
||||
big_content = "\n".join(f"{i}|" + "z" * 98 for i in range(1, 51))
|
||||
mock_ops.return_value = _make_fake_ops(
|
||||
content=big_content,
|
||||
total_lines=50,
|
||||
file_size=len(big_content),
|
||||
)
|
||||
result = json.loads(read_file_tool("/tmp/huge.txt", task_id="big"))
|
||||
# No hard rejection — content is present.
|
||||
self.assertNotIn("error", result)
|
||||
self.assertIn("content", result)
|
||||
self.assertTrue(result["content"])
|
||||
# Truncation metadata for the model to paginate.
|
||||
self.assertTrue(result["truncated"])
|
||||
self.assertEqual(result["truncated_by"], "bytes")
|
||||
self.assertIn("next_offset", result)
|
||||
self.assertGreater(result["next_offset"], 1)
|
||||
# Body fits the budget (allowing for redaction not growing it).
|
||||
self.assertLessEqual(len(result["content"]), 1000)
|
||||
self.assertIn("offset", result["hint"])
|
||||
|
||||
|
||||
@patch("tools.file_tools._get_file_ops")
|
||||
@patch("tools.file_tools._get_max_read_chars", return_value=_DEFAULT_MAX_READ_CHARS)
|
||||
def test_content_under_limit_passes(self, _mock_limit, mock_ops):
|
||||
"""Content just under the limit should pass through fine."""
|
||||
mock_ops.return_value = _make_fake_ops(
|
||||
content="y" * (_DEFAULT_MAX_READ_CHARS - 1),
|
||||
file_size=_DEFAULT_MAX_READ_CHARS - 1,
|
||||
)
|
||||
result = json.loads(read_file_tool("/tmp/justunder.txt", task_id="under"))
|
||||
self.assertNotIn("error", result)
|
||||
self.assertIn("content", result)
|
||||
|
||||
|
||||
class TestTruncateToCharBudget(unittest.TestCase):
|
||||
"""Unit tests for the line-boundary char-budget trimmer."""
|
||||
|
||||
def _fn(self):
|
||||
from tools.file_tools import _truncate_to_char_budget
|
||||
return _truncate_to_char_budget
|
||||
|
||||
def test_fits_unchanged(self):
|
||||
fn = self._fn()
|
||||
text = "1|a\n2|b\n3|c"
|
||||
out, lines, trunc = fn(text, 1000)
|
||||
self.assertEqual(out, text)
|
||||
self.assertEqual(lines, 3)
|
||||
self.assertFalse(trunc)
|
||||
|
||||
|
||||
def test_empty_content(self):
|
||||
fn = self._fn()
|
||||
out, lines, trunc = fn("", 100)
|
||||
self.assertEqual(out, "")
|
||||
self.assertEqual(lines, 0)
|
||||
self.assertFalse(trunc)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# File deduplication
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFileDedup(unittest.TestCase):
|
||||
"""Re-reading an unchanged file should return a lightweight stub."""
|
||||
|
||||
def setUp(self):
|
||||
_read_tracker.clear()
|
||||
self._tmpdir = _make_safe_tempdir("hermes-dedup-")
|
||||
self._tmpfile = os.path.join(self._tmpdir, "dedup_test.txt")
|
||||
with open(self._tmpfile, "w", encoding="utf-8") as f:
|
||||
f.write("line one\nline two\n")
|
||||
|
||||
def tearDown(self):
|
||||
_read_tracker.clear()
|
||||
try:
|
||||
os.unlink(self._tmpfile)
|
||||
os.rmdir(self._tmpdir)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
@patch("tools.file_tools._get_file_ops")
|
||||
def test_second_read_returns_dedup_stub(self, mock_ops):
|
||||
"""Second read of same file+range returns non-content dedup status."""
|
||||
mock_ops.return_value = _make_fake_ops(
|
||||
content="line one\nline two\n", file_size=20,
|
||||
)
|
||||
# First read — full content
|
||||
r1 = json.loads(read_file_tool(self._tmpfile, task_id="dup"))
|
||||
self.assertNotIn("dedup", r1)
|
||||
|
||||
# Second read — should get dedup stub
|
||||
r2 = json.loads(read_file_tool(self._tmpfile, task_id="dup"))
|
||||
self.assertTrue(r2.get("dedup"), "Second read should return dedup stub")
|
||||
self.assertEqual(r2.get("status"), "unchanged")
|
||||
self.assertIn("unchanged", r2.get("message", ""))
|
||||
self.assertFalse(r2.get("content_returned"))
|
||||
self.assertNotIn("content", r2)
|
||||
|
||||
@patch("tools.file_tools._get_file_ops")
|
||||
def test_write_rejects_internal_read_status_text(self, mock_ops):
|
||||
"""write_file must not persist internal read_file status text."""
|
||||
fake = MagicMock()
|
||||
fake.write_file = MagicMock()
|
||||
mock_ops.return_value = fake
|
||||
|
||||
result = json.loads(write_file_tool(
|
||||
self._tmpfile,
|
||||
_READ_DEDUP_STATUS_MESSAGE,
|
||||
task_id="guard",
|
||||
))
|
||||
|
||||
self.assertIn("error", result)
|
||||
self.assertIn("internal read_file display text", result["error"])
|
||||
fake.write_file.assert_not_called()
|
||||
|
||||
|
||||
@patch("tools.file_tools._get_file_ops")
|
||||
def test_different_task_not_deduped(self, mock_ops):
|
||||
"""Different task_ids have separate dedup caches."""
|
||||
mock_ops.return_value = _make_fake_ops(
|
||||
content="line one\nline two\n", file_size=20,
|
||||
)
|
||||
read_file_tool(self._tmpfile, task_id="task_a")
|
||||
|
||||
r2 = json.loads(read_file_tool(self._tmpfile, task_id="task_b"))
|
||||
self.assertNotEqual(r2.get("dedup"), True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dedup stub-loop guard (issue #15759)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDedupStubLoopGuard(unittest.TestCase):
|
||||
"""Repeated dedup stubs must escalate to a hard BLOCKED error so weak
|
||||
tool-following models don't burn iteration budget in an infinite loop
|
||||
of ``read_file → stub → read_file → stub → ...``"""
|
||||
|
||||
def setUp(self):
|
||||
_read_tracker.clear()
|
||||
self._tmpdir = tempfile.mkdtemp()
|
||||
self._tmpfile = os.path.join(self._tmpdir, "loop_test.txt")
|
||||
with open(self._tmpfile, "w", encoding="utf-8") as f:
|
||||
f.write("line one\nline two\n")
|
||||
|
||||
def tearDown(self):
|
||||
_read_tracker.clear()
|
||||
try:
|
||||
os.unlink(self._tmpfile)
|
||||
os.rmdir(self._tmpdir)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
@patch("tools.file_tools._get_file_ops")
|
||||
def test_third_read_is_blocked(self, mock_ops):
|
||||
"""read → stub → BLOCKED. Second stub escalates to hard error."""
|
||||
mock_ops.return_value = _make_fake_ops(
|
||||
content="line one\nline two\n", file_size=20,
|
||||
)
|
||||
# 1. Real read — full content
|
||||
r1 = json.loads(read_file_tool(self._tmpfile, task_id="loop"))
|
||||
self.assertNotIn("dedup", r1)
|
||||
self.assertNotIn("error", r1)
|
||||
|
||||
# 2. Dedup stub (first hit)
|
||||
r2 = json.loads(read_file_tool(self._tmpfile, task_id="loop"))
|
||||
self.assertTrue(r2.get("dedup"))
|
||||
self.assertNotIn("error", r2)
|
||||
|
||||
# 3. Dedup stub (second hit) — escalates to BLOCKED
|
||||
r3 = json.loads(read_file_tool(self._tmpfile, task_id="loop"))
|
||||
self.assertIn("error", r3, "Second dedup stub should be BLOCKED")
|
||||
self.assertIn("BLOCKED", r3["error"])
|
||||
self.assertIn("STOP", r3["error"])
|
||||
self.assertEqual(r3.get("already_read"), 3)
|
||||
# The loop-breaker must NOT be a dedup stub, or the model sees the
|
||||
# same passive message it has been ignoring.
|
||||
self.assertNotIn("dedup", r3)
|
||||
|
||||
@patch("tools.file_tools._get_file_ops")
|
||||
def test_subsequent_reads_stay_blocked(self, mock_ops):
|
||||
"""Once blocked, continued hammering keeps returning BLOCKED."""
|
||||
mock_ops.return_value = _make_fake_ops(
|
||||
content="line one\nline two\n", file_size=20,
|
||||
)
|
||||
read_file_tool(self._tmpfile, task_id="loop") # read
|
||||
read_file_tool(self._tmpfile, task_id="loop") # stub
|
||||
r3 = json.loads(read_file_tool(self._tmpfile, task_id="loop"))
|
||||
self.assertIn("error", r3)
|
||||
# 4th, 5th, ... calls must stay blocked, never revert to stub
|
||||
for _ in range(5):
|
||||
rN = json.loads(read_file_tool(self._tmpfile, task_id="loop"))
|
||||
self.assertIn("error", rN)
|
||||
self.assertIn("BLOCKED", rN["error"])
|
||||
|
||||
@patch("tools.file_tools._get_file_ops")
|
||||
def test_file_modification_clears_block(self, mock_ops):
|
||||
"""Real file change should break out of the block — new content
|
||||
is legitimately different and the agent should see it."""
|
||||
mock_ops.return_value = _make_fake_ops(
|
||||
content="line one\nline two\n", file_size=20,
|
||||
)
|
||||
read_file_tool(self._tmpfile, task_id="loop")
|
||||
read_file_tool(self._tmpfile, task_id="loop")
|
||||
r3 = json.loads(read_file_tool(self._tmpfile, task_id="loop"))
|
||||
self.assertIn("error", r3)
|
||||
|
||||
# File changes — mtime updates
|
||||
time.sleep(0.05)
|
||||
with open(self._tmpfile, "w", encoding="utf-8") as f:
|
||||
f.write("brand new content\n")
|
||||
|
||||
r4 = json.loads(read_file_tool(self._tmpfile, task_id="loop"))
|
||||
self.assertNotIn("error", r4)
|
||||
self.assertNotIn("dedup", r4)
|
||||
|
||||
@patch("tools.file_tools._get_file_ops")
|
||||
def test_other_tool_call_clears_hits(self, mock_ops):
|
||||
"""An intervening non-read tool call resets stub-hit counters,
|
||||
just like it resets the consecutive-read counter."""
|
||||
mock_ops.return_value = _make_fake_ops(
|
||||
content="line one\nline two\n", file_size=20,
|
||||
)
|
||||
read_file_tool(self._tmpfile, task_id="loop")
|
||||
read_file_tool(self._tmpfile, task_id="loop") # 1st stub
|
||||
|
||||
# Agent did something else — e.g. terminal, write_file — so the
|
||||
# stub-loop is broken. Counter should reset.
|
||||
notify_other_tool_call("loop")
|
||||
|
||||
r3 = json.loads(read_file_tool(self._tmpfile, task_id="loop"))
|
||||
# Should be a stub again, NOT blocked
|
||||
self.assertTrue(r3.get("dedup"))
|
||||
self.assertNotIn("error", r3)
|
||||
|
||||
@patch("tools.file_tools._get_file_ops")
|
||||
def test_different_ranges_tracked_independently(self, mock_ops):
|
||||
"""Stub-hit counter is keyed by (path, offset, limit), so hammering
|
||||
one range shouldn't block reads of a different range."""
|
||||
mock_ops.return_value = _make_fake_ops(
|
||||
content="line one\nline two\n", file_size=20,
|
||||
)
|
||||
# Burn down one range
|
||||
read_file_tool(self._tmpfile, offset=1, limit=100, task_id="loop")
|
||||
read_file_tool(self._tmpfile, offset=1, limit=100, task_id="loop")
|
||||
r3 = json.loads(read_file_tool(
|
||||
self._tmpfile, offset=1, limit=100, task_id="loop",
|
||||
))
|
||||
self.assertIn("error", r3)
|
||||
|
||||
# Different range — fresh read, should go through
|
||||
r_other = json.loads(read_file_tool(
|
||||
self._tmpfile, offset=1, limit=200, task_id="loop",
|
||||
))
|
||||
self.assertNotIn("error", r_other)
|
||||
|
||||
@patch("tools.file_tools._get_file_ops")
|
||||
def test_reset_file_dedup_clears_hits(self, mock_ops):
|
||||
"""Post-compression reset must clear stub-hit counters too,
|
||||
otherwise the agent stays blocked after compression."""
|
||||
mock_ops.return_value = _make_fake_ops(
|
||||
content="line one\nline two\n", file_size=20,
|
||||
)
|
||||
read_file_tool(self._tmpfile, task_id="loop")
|
||||
read_file_tool(self._tmpfile, task_id="loop")
|
||||
r3 = json.loads(read_file_tool(self._tmpfile, task_id="loop"))
|
||||
self.assertIn("error", r3)
|
||||
|
||||
reset_file_dedup("loop")
|
||||
|
||||
# Post-compression: block counters cleared and exact content is served
|
||||
# once because the earlier payload may no longer be in context.
|
||||
r4 = json.loads(read_file_tool(self._tmpfile, task_id="loop"))
|
||||
self.assertNotIn("error", r4)
|
||||
self.assertNotIn("dedup", r4)
|
||||
self.assertIn("content", r4)
|
||||
|
||||
# The next unchanged read in this generation is lightweight again.
|
||||
r5 = json.loads(read_file_tool(self._tmpfile, task_id="loop"))
|
||||
self.assertTrue(r5.get("dedup"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dedup reset on compression
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDedupResetOnCompression(unittest.TestCase):
|
||||
"""Compaction starts a new full-content recovery generation."""
|
||||
|
||||
def setUp(self):
|
||||
_read_tracker.clear()
|
||||
self._tmpdir = tempfile.mkdtemp()
|
||||
self._tmpfile = os.path.join(self._tmpdir, "compress_test.txt")
|
||||
with open(self._tmpfile, "w", encoding="utf-8") as f:
|
||||
f.write("original content\n")
|
||||
|
||||
def tearDown(self):
|
||||
_read_tracker.clear()
|
||||
try:
|
||||
os.unlink(self._tmpfile)
|
||||
os.rmdir(self._tmpdir)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
@patch("tools.file_tools._get_file_ops")
|
||||
def test_first_post_compaction_read_recovers_exact_content(self, mock_ops):
|
||||
"""First post-compaction read is full; later reads deduplicate."""
|
||||
mock_ops.return_value = _make_fake_ops(
|
||||
content="SECRET_EXACT_LINE=42\n", file_size=21,
|
||||
)
|
||||
# First read — populates dedup cache
|
||||
read_file_tool(self._tmpfile, task_id="comp")
|
||||
|
||||
# Verify dedup works before reset
|
||||
r_dedup = json.loads(read_file_tool(self._tmpfile, task_id="comp"))
|
||||
self.assertTrue(r_dedup.get("dedup"), "Should dedup before reset")
|
||||
|
||||
# Simulate compression
|
||||
reset_file_dedup("comp")
|
||||
|
||||
# Exact prior bytes may have been omitted from the summary, so the
|
||||
# first read in the new generation must restore them.
|
||||
r_post = json.loads(read_file_tool(self._tmpfile, task_id="comp"))
|
||||
self.assertNotIn("dedup", r_post)
|
||||
self.assertIn("SECRET_EXACT_LINE=42", r_post.get("content", ""))
|
||||
|
||||
# The persisted mtime map still saves tokens after that recovery read.
|
||||
r_again = json.loads(read_file_tool(self._tmpfile, task_id="comp"))
|
||||
self.assertTrue(r_again.get("dedup"))
|
||||
|
||||
|
||||
@patch("tools.file_tools._get_file_ops")
|
||||
def test_reset_preserves_loop_detection(self, mock_ops):
|
||||
"""reset_file_dedup does NOT affect the consecutive-read counter."""
|
||||
mock_ops.return_value = _make_fake_ops(
|
||||
content="original content\n", file_size=18,
|
||||
)
|
||||
# Build up consecutive count (read 1 and 2)
|
||||
read_file_tool(self._tmpfile, task_id="loop")
|
||||
# 2nd read is deduped — doesn't increment consecutive counter
|
||||
read_file_tool(self._tmpfile, task_id="loop")
|
||||
|
||||
reset_file_dedup("loop")
|
||||
|
||||
# First read in the new generation returns full content, not a stale
|
||||
# block or a stub that points to compacted-away bytes.
|
||||
r3 = json.loads(read_file_tool(self._tmpfile, task_id="loop"))
|
||||
self.assertNotIn("error", r3)
|
||||
self.assertNotIn("dedup", r3)
|
||||
self.assertIn("content", r3)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Large-file hint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLargeFileHint(unittest.TestCase):
|
||||
"""Large truncated files should include a hint about targeted reads."""
|
||||
|
||||
def setUp(self):
|
||||
_read_tracker.clear()
|
||||
|
||||
def tearDown(self):
|
||||
_read_tracker.clear()
|
||||
|
||||
@patch("tools.file_tools._get_file_ops")
|
||||
def test_large_truncated_file_gets_hint(self, mock_ops):
|
||||
content = "line\n" * 400 # 2000 chars, small enough to pass char guard
|
||||
fake = _make_fake_ops(content=content, total_lines=10000, file_size=600_000)
|
||||
# Make to_dict return truncated=True
|
||||
orig_read = fake.read_file
|
||||
def patched_read(path, offset=1, limit=500):
|
||||
r = orig_read(path, offset, limit)
|
||||
orig_to_dict = r.to_dict
|
||||
def new_to_dict():
|
||||
d = orig_to_dict()
|
||||
d["truncated"] = True
|
||||
return d
|
||||
r.to_dict = new_to_dict
|
||||
return r
|
||||
fake.read_file = patched_read
|
||||
mock_ops.return_value = fake
|
||||
|
||||
result = json.loads(read_file_tool("/tmp/bigfile.log", task_id="hint"))
|
||||
self.assertIn("_hint", result)
|
||||
self.assertIn("section you need", result["_hint"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config override
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestConfigOverride(unittest.TestCase):
|
||||
"""file_read_max_chars in config.yaml should control the char guard."""
|
||||
|
||||
def setUp(self):
|
||||
_read_tracker.clear()
|
||||
# Reset the cached value so each test gets a fresh lookup
|
||||
import tools.file_tools as _ft
|
||||
_ft._max_read_chars_cached = None
|
||||
|
||||
def tearDown(self):
|
||||
_read_tracker.clear()
|
||||
import tools.file_tools as _ft
|
||||
_ft._max_read_chars_cached = None
|
||||
|
||||
@patch("tools.file_tools._get_file_ops")
|
||||
@patch("hermes_cli.config.load_config", return_value={"file_read_max_chars": 50})
|
||||
def test_custom_config_lowers_limit(self, _mock_cfg, mock_ops):
|
||||
"""A config value of 50 should trigger truncation for reads over 50 chars,
|
||||
with the configured limit reflected in the continuation hint."""
|
||||
mock_ops.return_value = _make_fake_ops(content="x" * 60, file_size=60)
|
||||
result = json.loads(read_file_tool("/tmp/cfgtest.txt", task_id="cfg1"))
|
||||
self.assertNotIn("error", result)
|
||||
self.assertTrue(result["truncated"])
|
||||
self.assertEqual(result["truncated_by"], "bytes")
|
||||
self.assertIn("50", result["hint"]) # should show the configured limit
|
||||
self.assertLessEqual(len(result["content"]), 50)
|
||||
|
||||
@patch("tools.file_tools._get_file_ops")
|
||||
@patch("hermes_cli.config.load_config", return_value={"file_read_max_chars": 500_000})
|
||||
def test_custom_config_raises_limit(self, _mock_cfg, mock_ops):
|
||||
"""A config value of 500K should allow reads up to 500K chars."""
|
||||
# 200K chars would be rejected at the default 100K but passes at 500K
|
||||
mock_ops.return_value = _make_fake_ops(
|
||||
content="y" * 200_000, file_size=200_000,
|
||||
)
|
||||
result = json.loads(read_file_tool("/tmp/cfgtest2.txt", task_id="cfg2"))
|
||||
self.assertNotIn("error", result)
|
||||
self.assertIn("content", result)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Write invalidates dedup cache (fixes #13144)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestWriteInvalidatesDedup(unittest.TestCase):
|
||||
"""write_file_tool and patch_tool must invalidate the read_file dedup
|
||||
cache for the written path. Without this, a read→write→read sequence
|
||||
within the same mtime second returns a stale 'File unchanged' stub.
|
||||
|
||||
Regression test for https://github.com/NousResearch/hermes-agent/issues/13144
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
_read_tracker.clear()
|
||||
self._tmpdir = _make_safe_tempdir("hermes-write-dedup-")
|
||||
self._tmpfile = os.path.join(self._tmpdir, "write_dedup.txt")
|
||||
with open(self._tmpfile, "w", encoding="utf-8") as f:
|
||||
f.write("original content\n")
|
||||
|
||||
def tearDown(self):
|
||||
_read_tracker.clear()
|
||||
try:
|
||||
os.unlink(self._tmpfile)
|
||||
os.rmdir(self._tmpdir)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
@patch("tools.file_tools._get_file_ops")
|
||||
def test_write_invalidates_dedup_same_second(self, mock_ops):
|
||||
"""read→write→read within the same mtime second returns fresh content.
|
||||
|
||||
This is the core #13144 scenario: on filesystems with ≥1ms mtime
|
||||
granularity, a write that lands in the same timestamp as the prior
|
||||
read would previously cause the second read to return a stale dedup
|
||||
stub because the mtime comparison saw no change.
|
||||
"""
|
||||
fake = MagicMock()
|
||||
fake.read_file = lambda path, offset=1, limit=500: _FakeReadResult(
|
||||
content="original content\n", total_lines=1, file_size=18,
|
||||
)
|
||||
fake.write_file = lambda path, content: MagicMock(
|
||||
to_dict=lambda: {"success": True, "path": path}
|
||||
)
|
||||
mock_ops.return_value = fake
|
||||
|
||||
# 1. Read — populates dedup cache.
|
||||
r1 = json.loads(read_file_tool(self._tmpfile, task_id="wr"))
|
||||
self.assertNotEqual(r1.get("dedup"), True)
|
||||
|
||||
# 2. Write — must invalidate dedup for this path.
|
||||
# (No sleep — we intentionally stay in the same mtime second.)
|
||||
write_file_tool(self._tmpfile, "new content\n", task_id="wr")
|
||||
|
||||
# 3. Read again — should get full content, NOT dedup stub.
|
||||
fake.read_file = lambda path, offset=1, limit=500: _FakeReadResult(
|
||||
content="new content\n", total_lines=1, file_size=13,
|
||||
)
|
||||
r2 = json.loads(read_file_tool(self._tmpfile, task_id="wr"))
|
||||
self.assertNotEqual(r2.get("dedup"), True,
|
||||
"read after write must not return dedup stub")
|
||||
self.assertIn("content", r2)
|
||||
|
||||
@patch("tools.file_tools._get_file_ops")
|
||||
def test_write_invalidates_all_offsets(self, mock_ops):
|
||||
"""A write invalidates dedup entries for ALL offset/limit combos."""
|
||||
fake = MagicMock()
|
||||
fake.read_file = lambda path, offset=1, limit=500: _FakeReadResult(
|
||||
content="line1\nline2\nline3\n", total_lines=3, file_size=20,
|
||||
)
|
||||
fake.write_file = lambda path, content: MagicMock(
|
||||
to_dict=lambda: {"success": True, "path": path}
|
||||
)
|
||||
mock_ops.return_value = fake
|
||||
|
||||
# Read with different offsets to populate multiple dedup entries.
|
||||
read_file_tool(self._tmpfile, offset=1, limit=100, task_id="off")
|
||||
read_file_tool(self._tmpfile, offset=50, limit=100, task_id="off")
|
||||
|
||||
# Write — should invalidate BOTH dedup entries.
|
||||
write_file_tool(self._tmpfile, "replaced\n", task_id="off")
|
||||
|
||||
# Both reads should return fresh content.
|
||||
r1 = json.loads(read_file_tool(self._tmpfile, offset=1, limit=100, task_id="off"))
|
||||
r2 = json.loads(read_file_tool(self._tmpfile, offset=50, limit=100, task_id="off"))
|
||||
self.assertNotEqual(r1.get("dedup"), True,
|
||||
"offset=1 should not dedup after write")
|
||||
self.assertNotEqual(r2.get("dedup"), True,
|
||||
"offset=50 should not dedup after write")
|
||||
|
||||
|
||||
# Task B still sees dedup (its cache is separate — the file
|
||||
# *may* have changed on disk, but mtime comparison handles that;
|
||||
# here we test that invalidation is scoped to the writing task).
|
||||
# Note: on real FS, task B's dedup might or might not hit depending
|
||||
# on mtime. The point is that _invalidate_dedup_for_path is
|
||||
# correctly scoped to task_id.
|
||||
|
||||
|
||||
def test_invalidate_dedup_for_path_noop_on_empty_dedup(self):
|
||||
"""_invalidate_dedup_for_path is safe when dedup dict is empty."""
|
||||
_read_tracker.clear()
|
||||
_read_tracker["t"] = {
|
||||
"last_key": None, "consecutive": 0,
|
||||
"read_history": set(), "dedup": {},
|
||||
}
|
||||
_invalidate_dedup_for_path("/some/path", "t")
|
||||
self.assertEqual(_read_tracker["t"]["dedup"], {})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user