Files
hermes-agent/tools/file_tools_read_tracking.py
2026-09-21 18:39:24 -07:00

368 lines
16 KiB
Python

"""Per-task read/search bookkeeping for the file tools.
Process-lifetime state behind read_file/search_files/write_file/patch.
Per task_id ``_read_tracker``
stores: ``last_key``/``consecutive`` (loop detection; reset by any OTHER tool
call), ``read_history`` (diagnostics), ``dedup`` (key -> file metadata; survives context
compression), ``dedup_generation_reads`` (keys whose full content was served since
the last compaction boundary; cleared on compression so one recovery read returns
full content), ``dedup_hits`` (stub-loop breaker), ``read_timestamps``
(staleness warnings), ``read_coverage`` (per resolved path: the line ranges the
task has paged through at one file version — contiguous pages that reach the last line
count as a whole-file read), ``full_write_baselines`` (resolved paths whose
whole-file content this task saw via unredacted read_file page(s) or wrote via
write_file; required before write_file may overwrite an existing file — patch
never qualifies) and ``not_found`` (short-TTL negative cache). Every
container is hard-capped (``_cap_read_tracker_data``) so long sessions stay small.
"""
import hashlib
import logging
import os
import stat
import threading
import time
from tools.file_state import _evict_oldest
from tools.file_tools_paths import _authoritative_workspace_root, _resolve_path_for_task
logger = logging.getLogger("tools.file_tools")
_read_tracker_lock = threading.Lock()
_read_tracker: dict = {}
# Consecutive patch failures per (task_id, resolved_path); escalates the hint
# when the model keeps failing the same file. Reset on a successful patch.
_patch_failure_lock = threading.Lock()
_patch_failure_tracker: dict = {} # {task_id: {resolved_path: count}}
_PATCH_FAILURE_PATHS_CAP = 64
# Only the most recent reads matter for dedup, loop detection and external-edit
# warnings; caps bound accretion regardless of session length.
_READ_HISTORY_CAP = 500
_DEDUP_CAP = 1000
_READ_TIMESTAMPS_CAP = 1000
_FULL_WRITE_BASELINES_CAP = 1000
_NOT_FOUND_CAP = 500
_NOT_FOUND_TTL_SECONDS = 60.0 # a path that didn't exist may be created soon
def _task_data(task_id: str) -> dict:
"""Get-or-create the tracker entry for *task_id*, back-filling missing containers
(search_tool / tests create partial entries). Lock must be held."""
task_data = _read_tracker.setdefault(task_id, {
"last_key": None, "consecutive": 0, "read_history": set()})
for key in ("dedup", "dedup_hits", "read_timestamps", "read_coverage", "full_write_baselines"):
task_data.setdefault(key, {})
task_data.setdefault("dedup_generation_reads", set())
return task_data
def _record_patch_failure(task_id: str, resolved_path: str) -> int:
"""Increment and return the consecutive-failure count for this path."""
with _patch_failure_lock:
task_failures = _patch_failure_tracker.setdefault(task_id, {})
# Evict the oldest entry once a task has failed on many distinct files.
if resolved_path not in task_failures:
_evict_oldest(task_failures, _PATCH_FAILURE_PATHS_CAP - 1)
task_failures[resolved_path] = task_failures.get(resolved_path, 0) + 1
return task_failures[resolved_path]
def _reset_patch_failures(task_id: str, resolved_paths: list) -> None:
"""Clear consecutive-failure counts for the given paths."""
if not resolved_paths:
return
with _patch_failure_lock:
task_failures = _patch_failure_tracker.get(task_id)
for rp in resolved_paths if task_failures else ():
task_failures.pop(rp, None)
def _cap_read_tracker_data(task_data: dict) -> None:
"""Enforce size caps on the per-task sub-containers. Call with ``_read_tracker_lock`` held."""
# Caps are read at call time so tests can monkeypatch the module constants.
for key, cap in (
("read_history", _READ_HISTORY_CAP),
("dedup", _DEDUP_CAP),
("dedup_hits", _DEDUP_CAP),
("dedup_generation_reads", _DEDUP_CAP),
("read_timestamps", _READ_TIMESTAMPS_CAP),
("read_coverage", _READ_TIMESTAMPS_CAP),
("full_write_baselines", _FULL_WRITE_BASELINES_CAP),
("not_found", _NOT_FOUND_CAP)):
container = task_data.get(key)
if container is not None and len(container) > cap:
_evict_oldest(container, cap)
def _resolved_or_none(filepath: str, task_id: str) -> str | None:
try:
return str(_resolve_path_for_task(filepath, task_id))
except (OSError, ValueError):
return None
def _pop_not_found(op: str, resolved_str: str, task_id: str) -> None:
"""Drop the negative-cache entry for *(op, resolved_str)*. Lock must be held."""
task_data = _read_tracker.get(task_id)
nf = task_data.get("not_found") if task_data else None
if nf:
nf.pop((op, resolved_str), None)
def _check_not_found_cache(op: str, resolved_str: str, task_id: str) -> str | None:
"""Return cached not-found JSON for *(op, resolved_str)* if still fresh.
*op* is "read" or "search" (different error JSON shapes). Evicted by TTL,
by write_file/patch on the path, or by any other tool call.
"""
with _read_tracker_lock:
task_data = _read_tracker.get(task_id)
entry = (task_data.get("not_found") or {}).get((op, resolved_str)) if task_data else None
if entry is None:
return None
ts, cached_json = entry
if time.monotonic() - ts > _NOT_FOUND_TTL_SECONDS:
_pop_not_found(op, resolved_str, task_id)
return None
# "check → create → read" is common, so never serve a stale miss for a path
# that now exists. The stat runs OUTSIDE the tracker lock: a hung stat on a
# dead network mount must not stall every task.
if os.path.exists(resolved_str):
with _read_tracker_lock:
_pop_not_found(op, resolved_str, task_id)
return None
return cached_json
def _record_not_found(op: str, resolved_str: str, task_id: str, error_json: str) -> None:
"""Cache a not-found error so the next *op* call for *resolved_str* skips I/O."""
with _read_tracker_lock:
task_data = _task_data(task_id)
task_data.setdefault("not_found", {})[(op, resolved_str)] = (time.monotonic(), error_json)
_cap_read_tracker_data(task_data)
def _bump_consecutive(task_data: dict, key: tuple) -> int:
"""Update last_key/consecutive for *key* and return the new count. Lock must be held."""
if task_data["last_key"] == key:
task_data["consecutive"] += 1
else:
task_data["last_key"] = key
task_data["consecutive"] = 1
return task_data["consecutive"]
def reset_file_dedup(task_id: str = None):
"""Advance the read-dedup generation after context compression (one task, or all
when ``task_id`` is None). The per-key ``dedup`` metadata map is PRESERVED so unchanged
files keep returning stubs instead of re-bloating the reclaimed context; the
generation-read set is cleared so the FIRST unchanged read of each key after
compaction returns full content the summary may have dropped. Stub-hit counters
are cleared so the hard block restarts fresh. write_file baselines survive
exactly like the dedup map does — while the file metadata still matches the
stamp this task recorded; byte identity is checked before overwriting. A baseline
whose file changed underneath is dropped
(the stat runs outside the lock so a hung mount cannot stall other tasks)."""
with _read_tracker_lock:
if task_id:
targets = [_read_tracker[task_id]] if _read_tracker.get(task_id) else []
else:
targets = list(_read_tracker.values())
for task_data in targets:
if "dedup_hits" in task_data:
task_data["dedup_hits"].clear()
task_data.setdefault("dedup_generation_reads", set()).clear()
candidates = [(task_data, dict(task_data.get("full_write_baselines", {})))
for task_data in targets]
for task_data, baselines in candidates:
changed = {p for p, version in baselines.items() if _file_metadata(p) != version[:-1]}
if changed:
with _read_tracker_lock:
for p in changed:
if task_data["full_write_baselines"].get(p) == baselines[p]:
task_data["full_write_baselines"].pop(p, None)
def notify_other_tool_call(task_id: str = "default"):
"""Reset the consecutive read/search counter for a task.
Called by the dispatcher for every tool OTHER than read_file/search_files.
Also clears stub-hit counters and the not-found cache: any other tool may
have created a previously-missing path (or flipped its permissions).
"""
with _read_tracker_lock:
task_data = _read_tracker.get(task_id)
if task_data:
task_data["last_key"] = None
task_data["consecutive"] = 0
for key in ("dedup_hits", "not_found"):
if task_data.get(key):
task_data[key].clear()
def _invalidate_dedup_for_path(filepath: str, task_id: str) -> None:
"""Evict every dedup entry (all offset/limit ranges) and not-found entry for *filepath*
after a write, so the next read returns fresh content. Acquires the lock itself."""
resolved = _resolved_or_none(filepath, task_id)
if resolved is None:
return
with _read_tracker_lock:
task_data = _read_tracker.get(task_id)
if task_data is None:
return
dedup = task_data.get("dedup")
if dedup:
for k in [k for k in dedup if k[0] == resolved]:
del dedup[k]
_pop_not_found("read", resolved, task_id)
_pop_not_found("search", resolved, task_id)
def _update_read_timestamp(filepath: str, task_id: str) -> None:
"""After a successful write: invalidate dedup and refresh the stored mtime so
consecutive edits by the same task don't trigger false staleness warnings.
Also invalidates the dedup cache for the written path so that subsequent reads return fresh content
(fixes #13144).
"""
_invalidate_dedup_for_path(filepath, task_id)
resolved = _resolved_or_none(filepath, task_id)
if resolved is None:
return
try:
current_mtime = os.path.getmtime(resolved)
except OSError:
return
with _read_tracker_lock:
task_data = _read_tracker.get(task_id)
if task_data is not None:
task_data.setdefault("read_timestamps", {})[resolved] = current_mtime
_cap_read_tracker_data(task_data)
def _file_metadata(resolved: str) -> tuple | None:
try:
st = os.stat(resolved)
return st.st_dev, st.st_ino, st.st_size, st.st_mtime_ns, st.st_ctime_ns
except OSError:
return None
def _file_version(resolved: str) -> tuple | None:
"""A byte snapshot, not just mtime (editors/copy tools can preserve that)."""
try:
if not stat.S_ISREG(os.stat(resolved).st_mode):
return None
fd = os.open(resolved, os.O_RDONLY | getattr(os, "O_NONBLOCK", 0) | getattr(os, "O_BINARY", 0))
with os.fdopen(fd, "rb") as stream:
before = os.fstat(stream.fileno())
if not stat.S_ISREG(before.st_mode):
return None
digest = hashlib.file_digest(stream, "sha256").digest()
after = os.stat(resolved)
fields = ("st_dev", "st_ino", "st_size", "st_mtime_ns", "st_ctime_ns")
version = tuple(getattr(before, name) for name in fields)
if version == tuple(getattr(after, name) for name in fields):
return (*version, digest)
return None
except OSError:
return None
def _mark_full_write_baseline(resolved: str, task_id: str, expected_sha256: str | None = None) -> None:
"""Record that *task_id* saw the whole current content of *resolved* (full
unredacted read_file, or its own successful write_file), so a later
write_file may replace the file. Acquires the lock itself."""
version = _file_version(resolved)
if version is None or (expected_sha256 is not None and version[-1].hex() != expected_sha256):
return
with _read_tracker_lock:
task_data = _task_data(task_id)
task_data["full_write_baselines"][str(resolved)] = version
_cap_read_tracker_data(task_data)
def _has_full_write_baseline(resolved: str, task_id: str) -> bool:
with _read_tracker_lock:
task_data = _read_tracker.get(task_id) or {}
baseline = task_data.get("full_write_baselines", {}).get(str(resolved))
return baseline is not None and _file_version(resolved) == baseline
_READ_COVERAGE_RANGES_CAP = 256
def _note_read_coverage(task_data: dict, resolved: str, version: tuple, start: int, end: int,
total_lines, redacted: bool) -> tuple[bool, bool]:
"""Merge the page ``start..end`` into this task's coverage of *resolved* and return
``(complete, redacted_any)``: whether pages taken at this same *version* now reach from
line 1 to *total_lines*, and whether any of them came back redacted. A file too large
for one read_file page (>2000 lines / the char budget) can only ever be seen this
way, so paging through it must count as a whole-file read. A new version restarts the
coverage (the earlier pages describe a file that no longer exists). Lock must be held."""
coverage = task_data.setdefault("read_coverage", {})
entry = coverage.get(resolved)
if entry is None or entry["version"] != version or len(entry["ranges"]) > _READ_COVERAGE_RANGES_CAP:
entry = coverage[resolved] = {"version": version, "ranges": [], "redacted": False}
entry["redacted"] = entry["redacted"] or redacted
merged: list[tuple[int, int]] = []
for s, e in sorted(entry["ranges"] + [(start, end)]):
if merged and s <= merged[-1][1] + 1:
merged[-1] = (merged[-1][0], max(merged[-1][1], e))
else:
merged.append((s, e))
entry["ranges"] = merged
complete = (isinstance(total_lines, int) and total_lines > 0
and merged[0][0] <= 1 and merged[0][1] >= total_lines)
return complete, entry["redacted"]
def _read_mtime_drifted(filepath: str, task_id: str) -> bool:
"""True when the file's mtime changed since this task last read it. False when
never read, fresh, or unstattable (a deleted file is the write's problem)."""
resolved = _resolved_or_none(filepath, task_id)
if resolved is None:
return False
with _read_tracker_lock:
task_data = _read_tracker.get(task_id)
read_mtime = task_data.get("read_timestamps", {}).get(resolved) if task_data else None
if read_mtime is None:
return False
try:
return os.path.getmtime(resolved) != read_mtime
except OSError:
return False
def _check_file_staleness(filepath: str, task_id: str) -> str | None:
"""Warn (don't block) when the file's mtime changed since this task last read it."""
if _read_mtime_drifted(filepath, task_id):
return (
f"Warning: {filepath} was modified since you last read it "
"(external edit or concurrent agent). The content you read may be "
"stale. Consider re-reading the file to verify before writing.")
return None
def _mark_verification_stale(task_id: str, resolved_paths: list[str],
session_id: str | None = None) -> None:
"""Best-effort note that successful edits made prior verification stale. cwd: the
first edited path's recognised project root, else the workspace root, else the first parent."""
from pathlib import Path
paths = [p for p in resolved_paths if p]
if not paths:
return
try:
from agent.coding_context import project_facts_for
from agent.verification_evidence import mark_workspace_edited
parents = [str(Path(p).parent) for p in paths]
cwd = (next((c for c in parents if project_facts_for(c)), None)
or _authoritative_workspace_root(task_id) or parents[0])
mark_workspace_edited(session_id=session_id or task_id, cwd=cwd, paths=paths)
except Exception:
logger.debug("verification stale marker failed", exc_info=True)