Files
hermes-agent/agent/subdirectory_hints.py
ethernet 9f2ba1b74d merge origin/main (779 commits) into ethie/pm-clean
Branch semantics kept where main and PM disagree: update_cmd_deps.py,
constraints-termux.txt, the Electron update-api-check module and the
post-swap hand-off test stay deleted; the pending-fleet-restart catch-up
and the local_runtime tag/download ladder stay retired (PM owns engines).

Ported from main onto the branch's shape: profile_scoped_chore for the
auto-archive and plugin-update housekeeping chores, the local-runtime
cross-process boot lock and residency cap, the checkpoint tmp_pack sweep,
the cua daemon-liveness status probe, the remote-served Desktop update
flag (posix.sh / windows.ps1), sign-in for env-pinned remote gateways
(urlDisabled on RemoteSetupFields), the uvloop extra split (uvicorn
without [standard]), and the umask-scoping spawn test.

uv.lock regenerated with pm.build_env --lock-only; new utf-8 reads from
main switched to utf-8-sig (check-windows-footguns).
2026-09-21 00:58:39 -04:00

272 lines
13 KiB
Python

"""Progressive subdirectory hint discovery: as the agent navigates into
subdirectories via tool calls, load project context files (AGENTS.md, CLAUDE.md,
.cursorrules) from them and append to the tool result — context arrives without
touching the system prompt (prompt caching preserved). Complements the startup
CWD-only loading in ``prompt_builder.py``."""
import hashlib
import logging
import os
import shlex
from pathlib import Path
from typing import Dict, Any, Optional, Set
from agent.prompt_builder import _read_text_with_timeout, _scan_context_content, _truncate_content
from agent.search_policy import SEARCH_PRUNE_DIR_NAMES
logger = logging.getLogger(__name__)
# Same filenames as prompt_builder.py, in priority order (first match wins per dir).
_HINT_FILENAMES = ["AGENTS.override.md", "AGENTS.md", "agents.md", "CLAUDE.md", "claude.md", ".cursorrules"]
# Per-file ceiling for on-demand subdirectory hints. 32 KiB matches Codex's `project_doc_max_bytes` default
# (Claude Code and Cursor apply none); it is a guard against a stray huge CLAUDE.md in a vendored tree, not a
# target — keep area AGENTS.md files well under it (~8k) because this text lands in a tool result on the first
# touch of that directory. Over the ceiling: head+tail kept, marker with the path so the agent can read_file it,
# and a WARNING in the log (the old 8k silent tail-chop cut apps/desktop/AGENTS.md for months unnoticed).
_MAX_HINT_CHARS = 32_000
_PATH_ARG_KEYS = {"path", "file_path", "workdir"}
_COMMAND_TOOLS = {"terminal"}
_MAX_ANCESTOR_WALK = 5 # ancestor levels walked per path — bounds deep-path scans
# Shared with broad recursive search probes so context discovery and search never drift into
# different dependency/cache/build trees (those hold *copies* of context files, never authoritative ones).
_EXCLUDED_DIR_NAMES = SEARCH_PRUNE_DIR_NAMES
def _digest(content: str) -> str:
return hashlib.sha256(content.encode("utf-8")).hexdigest()
def _resolved_hint_target(hint_path: Path, working_dir: Path) -> Optional[Path]:
"""Resolved hint-file target, or None when the file must not be loaded.
``is_file()`` and ``read_text`` follow symlinks, so a checked-in
``sub/AGENTS.md -> ~/.aws/credentials`` would inject an out-of-tree file
into the tool result. The resolved target must stay inside the resolved
working dir (same containment ``_within_working_dir`` applies to the
directory itself) and pass the canonical read deny-list
(``context_references`` applies it to explicit @-references), which also
catches in-tree targets like a symlink to the project ``.env``.
"""
try:
resolved = hint_path.resolve()
except (OSError, RuntimeError):
return None
try:
inside = resolved.is_relative_to(working_dir)
except (OSError, ValueError, RuntimeError):
inside = False
if not inside:
return None
try:
from agent.file_safety import get_read_block_error
blocked = get_read_block_error(str(resolved)) is not None
except Exception:
# Mirror context_references: a deny-list lookup that fails re-opens the
# exact hole the check closes, so fail closed.
return None
return None if blocked else resolved
def _first_hint_file(directory: Path):
"""``(path, stripped content)`` of the first readable non-empty hint file
in *directory* (priority order), or None. Unreadable files are skipped."""
for filename in _HINT_FILENAMES:
candidate = directory / filename
try:
if not candidate.is_file() or (target := _resolved_hint_target(candidate, directory)) is None:
continue
# Read the resolved target (not the link path) so a symlink swapped
# between check and read still lands on the vetted file.
content = target.read_text(encoding="utf-8-sig").strip()
except (OSError, UnicodeDecodeError):
continue
return candidate, content
return None
_NAV_COMMANDS = frozenset({"cd", "pushd"})
_SHELL_OPERATORS = frozenset({"&&", "||", "|", ";", "&", ";;", "|&", "(", ")"})
def _nav_targets(cmd: str) -> list:
"""Operands of `cd` / `pushd` that begin a shell segment. `cd -` and bare `cd` yield nothing."""
lexer = shlex.shlex(cmd, posix=True, punctuation_chars=True)
lexer.whitespace_split = True
try:
tokens = list(lexer)
except ValueError:
return []
targets, segment_start = [], True
for idx, token in enumerate(tokens):
if token in _SHELL_OPERATORS:
segment_start = True
continue
if segment_start and token in _NAV_COMMANDS:
operand = next((t for t in tokens[idx + 1:] if t in _SHELL_OPERATORS or not t.startswith("-")), None)
if operand and operand not in _SHELL_OPERATORS:
targets.append(operand)
segment_start = False
return targets
class SubdirectoryHintTracker:
"""Track which directories the agent visits and load hints on first access.
Usage: after each tool call, ``hints = tracker.check_tool_call(name, args)``
and append the returned text to the tool result.
"""
def __init__(self, working_dir: Optional[str] = None, *, enabled: bool = True):
# ``enabled=False`` mirrors ``skip_context_files``: a session that opted out of
# AGENTS.md/CLAUDE.md injection at startup must not get the same files spliced into
# tool results later — cron jobs relaying exact stdout leaked them to chat (#9441).
self.enabled = enabled
self.working_dir = Path(working_dir or os.getcwd()).resolve()
# The working dir is pre-marked loaded (startup context handles it).
self._loaded_dirs: Set[Path] = {self.working_dir}
# Content digests already injected: the same file reached through
# symlinks/hardlinks/copies is never re-sent. Seeded with the CWD hint
# file prompt_builder already loaded.
self._loaded_digests: Set[str] = set()
found = _first_hint_file(self.working_dir)
if found and found[1]:
self._loaded_digests.add(_digest(found[1]))
def check_tool_call(self, tool_name: str, tool_args: Dict[str, Any]) -> Optional[str]:
"""Return formatted hint text for newly visited directories, or None."""
if not self.enabled:
return None
all_hints = [h for d in self._extract_directories(tool_name, tool_args) if (h := self._load_hints_for_directory(d))]
return "\n\n" + "\n\n".join(all_hints) if all_hints else None
def _extract_directories(self, tool_name: str, args: Dict[str, Any]) -> list:
"""Extract directory paths from tool call arguments."""
candidates: Set[Path] = set()
for key in _PATH_ARG_KEYS:
val = args.get(key)
if isinstance(val, str) and val.strip():
self._add_path_candidate(val, candidates)
cmd = args.get("command", "") if tool_name in _COMMAND_TOOLS else None
if isinstance(cmd, str):
self._extract_paths_from_command(cmd, candidates)
return list(candidates)
def _add_path_candidate(self, raw_path: str, candidates: Set[Path]):
"""Add a raw path's directory and its ancestors (up to ``_MAX_ANCESTOR_WALK``
levels, stopping at the first already-loaded dir) so reading
``project/src/main.py`` still discovers ``project/AGENTS.md``."""
try:
p = Path(raw_path).expanduser()
if not p.is_absolute():
p = self.working_dir / p
p = p.resolve()
if p.suffix or (p.exists() and p.is_file()):
p = p.parent
for _ in range(_MAX_ANCESTOR_WALK):
if p in self._loaded_dirs:
break
if self._is_valid_subdir(p):
candidates.add(p)
if p.parent == p:
break # filesystem root
p = p.parent
except (OSError, ValueError, RuntimeError):
pass
def _extract_paths_from_command(self, cmd: str, candidates: Set[Path]):
"""Extract path-like tokens (contain / or .; not flags or URLs) from a shell command."""
try:
tokens = shlex.split(cmd)
except ValueError:
tokens = cmd.split()
# `cd backend && ls`: a bare directory name has no `/` or `.`, so the generic filter below drops
# it; the operand of a navigation command is a path by construction (#11032). Only a `cd` at the
# START of a shell segment counts (`echo cd backend` is prose); punctuation-aware tokenizing keeps
# a quoted `'backend;'` literal while splitting bare `backend;ls` at the operator.
for target in _nav_targets(cmd):
self._add_path_candidate(target, candidates)
for token in tokens:
if token.startswith(("-", "http://", "https://", "git@")) or ("/" not in token and "." not in token):
continue
self._add_path_candidate(token, candidates)
def _within_working_dir(self, path: Path) -> bool:
"""Reject paths outside the working-dir tree: loading ~/.codex/AGENTS.md
or ~/.claude/CLAUDE.md would mix another agent's instructions into this
session. Falls back to an ancestor check when ``is_relative_to`` fails."""
try:
return path.is_relative_to(self.working_dir)
except (OSError, ValueError):
try:
path.relative_to(self.working_dir)
return True
except ValueError:
return False
def _is_valid_subdir(self, path: Path) -> bool:
"""Directory inside the working-dir tree, not yet loaded, not an excluded copy dir."""
try:
if not path.is_dir():
return False
except OSError:
return False
return path not in self._loaded_dirs and self._within_working_dir(path) and not self._is_excluded(path)
def _is_excluded(self, path: Path) -> bool:
"""True when a segment *below* the working dir is an excluded copy dir
(a user deliberately working inside ``vendor/`` keeps that segment legitimate)."""
try:
rel_parts = path.relative_to(self.working_dir).parts
except ValueError:
return True # outside the tree — already rejected upstream
return any(part in _EXCLUDED_DIR_NAMES for part in rel_parts)
def _load_hints_for_directory(self, directory: Path) -> Optional[str]:
"""Load the first hint file in *directory*; formatted text or None."""
self._loaded_dirs.add(directory)
if not self._within_working_dir(directory):
logger.debug("Skipping hint files in %s — outside working_dir %s", directory, self.working_dir)
return None
for filename in _HINT_FILENAMES:
hint_path = directory / filename
try:
if not hint_path.is_file():
continue
except OSError:
continue
if (target := _resolved_hint_target(hint_path, self.working_dir)) is None:
continue
try:
content = (_read_text_with_timeout(target) or "").strip()
if not content:
continue
digest = _digest(content)
if digest in self._loaded_digests:
logger.debug("Skipping duplicate hint content at %s (digest %s)", hint_path, digest[:12])
return None
self._loaded_digests.add(digest)
# Same security scan as startup context loading.
content = _scan_context_content(content, filename)
rel_path = self._display_path(hint_path)
content = _truncate_content(
content, filename, max_chars=_MAX_HINT_CHARS, read_path=rel_path, queue_warning=False,
)
logger.debug("Loaded subdirectory hints from %s: %s", directory, [rel_path])
return f"[Subdirectory context discovered: {rel_path}]\n{content}" # first match wins per directory
except Exception as exc:
logger.debug("Could not read %s: %s", hint_path, exc)
return None
def _display_path(self, hint_path: Path) -> str:
"""Working-dir-relative, else ``~/``-relative (POSIX rendering so Windows
never shows ``~/AppData\\Local\\...`` chimeras), else absolute."""
try:
return str(hint_path.relative_to(self.working_dir))
except (ValueError, RuntimeError):
pass
try:
return "~/" + hint_path.relative_to(Path.home()).as_posix()
except (ValueError, RuntimeError):
return str(hint_path)