Files
hermes-agent/agent/subdirectory_hints.py
Teknium be5c6a2fd8 refactor(agent/prompt): remove dead code, unify duplicated helpers, compact docstrings across prompt/skill/redaction modules
Dead (zero refs): coding_system_blocks, get_friendly_tool_labels, get_scan_ordered_skills_dirs,
_project_quarantine_cache_clear, clear_stable_prefixes, _redact_http_request_target_query_params,
_has_http_method_substring, PromptCachePlan.marker_count, display _diff_* colour thunks (-> _diff_ansi),
pass-through RedactingFormatter.__init__.
Unified: _slugify -> slugify_skill_name; reload diff -> diff_command_snapshots; _is_summary_item ->
is_compaction_summary_message alias; sanitizer walkers -> _sanitize_messages/_sanitize_structure;
assignment redaction passes -> _redact_assignments/_should_redact_assignment; quiet-mode tool lines -> _CUTE_LINES table.
2026-09-02 13:53:57 -07:00

262 lines
9.9 KiB
Python

"""Progressive subdirectory hint discovery.
As the agent navigates into subdirectories via tool calls, this module loads
project context files (AGENTS.md, CLAUDE.md, .cursorrules) from those
directories and appends them to the tool result — context arrives without
touching the system prompt (preserving prompt caching). Complements the
startup CWD-only loading in ``prompt_builder.py``. Inspired by goose's
SubdirectoryHintTracker.
"""
import hashlib
import logging
import os
import shlex
from pathlib import Path
from typing import Dict, Any, Optional, Set
from agent.prompt_builder import _scan_context_content
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",
]
_MAX_HINT_CHARS = 8_000
_PATH_ARG_KEYS = {"path", "file_path", "workdir"}
_COMMAND_TOOLS = {"terminal"}
# Ancestor levels walked per path — bounds the scan for deeply nested paths.
_MAX_ANCESTOR_WALK = 5
# Directories that hold *copies* of context files (backups, vendored deps,
# VCS internals, caches), never authoritative project context.
_EXCLUDED_DIR_NAMES = frozenset({
"node_modules", "venv", ".venv", "__pycache__",
".git", ".hg", ".svn",
".Trash", ".cache", ".tox", ".mypy_cache", ".pytest_cache",
"site-packages", "dist-packages",
"backups", "backup", ".backups",
"vendor", "third_party",
})
def _is_ancestor_or_same(a: Path, b: Path) -> bool:
"""True if *a* is *b* or one of its ancestors."""
try:
b.relative_to(a)
return True
except ValueError:
return False
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):
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.
self._loaded_digests: Set[str] = set()
self._seed_working_dir_digest()
def _seed_working_dir_digest(self) -> None:
"""Record the CWD context file's digest (prompt_builder already loaded it)."""
for filename in _HINT_FILENAMES:
candidate = self.working_dir / filename
try:
if not candidate.is_file():
continue
content = candidate.read_text(encoding="utf-8").strip()
except (OSError, UnicodeDecodeError):
continue
if content:
self._loaded_digests.add(
hashlib.sha256(content.encode("utf-8")).hexdigest()
)
break # first match wins, mirroring startup loading
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."""
all_hints = []
for d in self._extract_directories(tool_name, tool_args):
hints = self._load_hints_for_directory(d)
if hints:
all_hints.append(hints)
if not all_hints:
return None
return "\n\n" + "\n\n".join(all_hints)
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)
if tool_name in _COMMAND_TOOLS:
cmd = args.get("command", "")
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 to candidates.
Walks up toward the root, stopping at the first already-loaded
directory or after ``_MAX_ANCESTOR_WALK`` levels, 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)
parent = p.parent
if parent == p:
break # filesystem root
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()
for token in tokens:
if token.startswith("-"):
continue
if "/" not in token and "." not in token:
continue
if token.startswith(("http://", "https://", "git@")):
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. ``is_relative_to`` handles
symlinked paths; the ancestor check is a best-effort fallback.
"""
try:
return path.is_relative_to(self.working_dir)
except (OSError, ValueError):
return _is_ancestor_or_same(self.working_dir, path)
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
if path in self._loaded_dirs:
return False
if not self._within_working_dir(path):
return False
return 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.
Only segments under ``working_dir`` are screened: 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
found_hints = []
for filename in _HINT_FILENAMES:
hint_path = directory / filename
try:
if not hint_path.is_file():
continue
except OSError:
continue
try:
content = hint_path.read_text(encoding="utf-8").strip()
if not content:
continue
digest = hashlib.sha256(content.encode("utf-8")).hexdigest()
if digest in self._loaded_digests:
logger.debug(
"Skipping duplicate hint content at %s (digest %s)",
hint_path,
digest[:12],
)
break
self._loaded_digests.add(digest)
# Same security scan as startup context loading.
content = _scan_context_content(content, filename)
if len(content) > _MAX_HINT_CHARS:
content = (
content[:_MAX_HINT_CHARS]
+ f"\n\n[...truncated {filename}: {len(content):,} chars total]"
)
rel_path = str(hint_path)
try:
rel_path = str(hint_path.relative_to(self.working_dir))
except (ValueError, RuntimeError):
try:
# as_posix: "~/" shorthand implies POSIX rendering
# (avoids ~/AppData\Local\... chimeras on Windows).
rel_path = "~/" + hint_path.relative_to(Path.home()).as_posix()
except (ValueError, RuntimeError):
pass # keep absolute
found_hints.append((rel_path, content))
break # first match wins per directory (like startup loading)
except Exception as exc:
logger.debug("Could not read %s: %s", hint_path, exc)
if not found_hints:
return None
sections = [
f"[Subdirectory context discovered: {rel_path}]\n{content}"
for rel_path, content in found_hints
]
logger.debug(
"Loaded subdirectory hints from %s: %s",
directory,
[h[0] for h in found_hints],
)
return "\n\n".join(sections)