Review finding: `echo cd backend` injected backend/AGENTS.md, and the
rstrip(";") applied after shlex had removed quoting turned `cd 'backend;'`
into `backend`. Tokenize with punctuation_chars so operators are their own
tokens: a `cd` counts only at a segment start, `backend;ls` splits at the
operator, and a quoted `'backend;'` stays the literal name.
235 lines
11 KiB
Python
235 lines
11 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 _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():
|
|
continue
|
|
content = candidate.read_text(encoding="utf-8").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
|
|
try:
|
|
content = (_read_text_with_timeout(hint_path) 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)
|
|
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)
|