"""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 # A container cwd is not a host path: a docker/local session whose working dir is the backend # container's default /home (or /root) home must not start hint discovery either — the same # vacuous-containment hazard as the desktop home pin, just container-shaped (#76902). _CONTAINER_HOMES = frozenset({Path("/home"), Path("/root"), Path("/workspace")}) def _is_home_like_working_dir(working_dir: Path) -> bool: """True when ``working_dir`` is a home the desktop/container fell back to — not a project.""" home = Path.home() if working_dir == home or working_dir in _CONTAINER_HOMES or working_dir.parent in _CONTAINER_HOMES: return True # Also the local-sandbox home mount: a docker backend binds the host home under a # container path whose basename stays the user name (e.g. /mnt/host-home/brooklyn). for part in working_dir.parts: if part in (".home", "host-home", "host_home"): return True 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, *, 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() # $HOME is not a project (#76902): the packaged Desktop app with no default project # dir configured resolves its cwd (and TERMINAL_CWD) to home, and the containment # check is then vacuously true for the whole home subtree — every AGENTS.md / # CLAUDE.md / .cursorrules under ~ would be injected on tool calls. Hint discovery # runs only inside a real project workspace; a home cwd only resumes when the # session adopts a real project (see ``rebind_working_dir``). self._home_is_working_dir = _is_home_like_working_dir(self.working_dir) # 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 rebind_working_dir(self, working_dir: str) -> None: """Re-anchor hint discovery to *working_dir* (a session workspace adoption).""" try: new_dir = Path(working_dir).expanduser().resolve() if not new_dir.is_dir() or _is_home_like_working_dir(new_dir): return except (OSError, ValueError, RuntimeError): return if new_dir == self.working_dir: self._home_is_working_dir = False return self.working_dir = new_dir self._home_is_working_dir = False # Fresh anchor, fresh bookkeeping: the new project's directories get their # own first-visit hints (startup never loaded context files for it). self._loaded_dirs = {self.working_dir} self._loaded_digests = 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 if self._home_is_working_dir: 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 self._home_is_working_dir or 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)