From 2e2fcc09ff6fb1f913cc9465f1d4e4a5bbaf928c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:22:55 -0700 Subject: [PATCH] Port from superagent-ai/grok-cli: directory-chain AGENTS.md loading --- agent/prompt_builder.py | 91 ++++++++++++++++--- tests/agent/test_prompt_builder.py | 66 ++++++++++++++ .../docs/user-guide/features/context-files.md | 15 +++ 3 files changed, 158 insertions(+), 14 deletions(-) diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 5309c72552..1a85598123 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -14,7 +14,7 @@ from collections import OrderedDict from pathlib import Path from hermes_constants import get_hermes_home, get_skills_dir, is_wsl -from typing import Optional +from typing import List, Optional from agent.runtime_cwd import resolve_agent_cwd from agent.skill_utils import ( @@ -2059,23 +2059,86 @@ def _load_hermes_md(cwd_path: Path, context_length: Optional[int] = None) -> str return "" +def _agents_md_directory_chain(cwd_path: Path) -> List[Path]: + """Directories to check for AGENTS.md: git root first, cwd last. + + Ported from superagent-ai/grok-cli ``src/utils/instructions.ts`` + (``directoryChain``): the chain runs from the git repository root down + through every intermediate directory to *cwd*, so deeper directories can + add more specific guidance that appears later (and therefore takes + precedence) in the merged prompt. Without a git root — or when *cwd* + sits outside it — only *cwd* itself is checked, matching the historical + single-directory behavior. + """ + current = cwd_path.resolve() + root = _find_git_root(current) + if root is None or root == current: + return [current] + try: + rel = current.relative_to(root) + except ValueError: + return [current] + chain = [root] + acc = root + for part in rel.parts: + acc = acc / part + chain.append(acc) + return chain + + def _load_agents_md(cwd_path: Path, context_length: Optional[int] = None) -> str: - """AGENTS.md — top-level only (no recursive walk).""" - for name in ["AGENTS.md", "agents.md"]: - candidate = cwd_path / name - if candidate.exists(): + """AGENTS.md — merged directory chain from git root down to cwd. + + Each directory on the chain (see ``_agents_md_directory_chain``) + contributes its ``AGENTS.md`` / ``agents.md`` (first name wins per + directory) as its own provenance-labelled section. Identical content + encountered again further down the chain (copied or symlinked files) is + deduplicated. With a single match — the common case, and always the + case outside a git repo — output is identical to the historical + single-file behavior. + """ + cwd_resolved = cwd_path.resolve() + sections: List[str] = [] + seen_content: set = set() + for directory in _agents_md_directory_chain(cwd_resolved): + for name in ["AGENTS.md", "agents.md"]: + candidate = directory / name + if not candidate.exists(): + continue try: content = candidate.read_text(encoding="utf-8").strip() - if content: - content = _scan_context_content(content, name) - result = f"## {name}\n\n{content}" - return _truncate_content( - result, "AGENTS.md", context_length=context_length, - read_path=str(candidate), - ) except Exception as e: logger.debug("Could not read %s: %s", candidate, e) - return "" + continue + if not content: + continue + if content in seen_content: + break # identical copy along the chain — skip duplicate + seen_content.add(content) + if directory == cwd_resolved: + label = name + else: + label = os.path.relpath(candidate, cwd_resolved) + scanned = _scan_context_content(content, label) + section = f"## {label}\n\n{scanned}" + section = _truncate_content( + section, label, context_length=context_length, + read_path=str(candidate), + ) + sections.append(section) + break # first name match wins per directory + if not sections: + return "" + if len(sections) == 1: + return sections[0] + # Per-file budgets were already applied above; also cap the merged chain + # so a deep monorepo cannot multiply the context-file budget unbounded. + merged = "\n\n".join(sections) + return _truncate_content( + merged, "AGENTS.md (directory chain)", + context_length=context_length, + read_path=str(cwd_resolved / "AGENTS.md"), + ) def _load_claude_md(cwd_path: Path, context_length: Optional[int] = None) -> str: @@ -2140,7 +2203,7 @@ def build_context_files_prompt( Priority (first found wins — only ONE project context type is loaded): 1. .hermes.md / HERMES.md (walk to git root) - 2. AGENTS.md / agents.md (cwd only) + 2. AGENTS.md / agents.md (merged chain: git root → cwd) 3. CLAUDE.md / claude.md (cwd only) 4. .cursorrules / .cursor/rules/*.mdc (cwd only) diff --git a/tests/agent/test_prompt_builder.py b/tests/agent/test_prompt_builder.py index 28def42c05..c545220dcf 100644 --- a/tests/agent/test_prompt_builder.py +++ b/tests/agent/test_prompt_builder.py @@ -3,6 +3,7 @@ import builtins import importlib import logging +import os import sys import pytest @@ -449,6 +450,71 @@ class TestBuildContextFilesPrompt: assert "Ruff for linting" in result assert "Project Context" in result + # --- AGENTS.md directory chain (port of grok-cli instructions.ts) --- + + def test_agents_md_chain_merges_root_to_cwd(self, tmp_path): + # git-root AGENTS.md + intermediate + cwd are all merged, root first + # and cwd last so deeper guidance takes precedence. + (tmp_path / ".git").mkdir() + (tmp_path / "AGENTS.md").write_text("Root: use Ruff.") + pkg = tmp_path / "packages" + pkg.mkdir() + (pkg / "AGENTS.md").write_text("Packages: pnpm workspace.") + app = pkg / "webapp" + app.mkdir() + (app / "AGENTS.md").write_text("Webapp: React 19 only.") + result = build_context_files_prompt(cwd=str(app), skip_soul=True) + assert "Root: use Ruff." in result + assert "Packages: pnpm workspace." in result + assert "Webapp: React 19 only." in result + # order: root before intermediate before cwd + assert result.index("Root: use Ruff.") < result.index("Packages: pnpm") + assert result.index("Packages: pnpm") < result.index("Webapp: React 19") + # provenance headers point at each source file relative to cwd + assert f"## {os.path.join('..', '..', 'AGENTS.md')}" in result + assert f"## {os.path.join('..', 'AGENTS.md')}" in result + assert "## AGENTS.md" in result + + def test_agents_md_chain_skips_gaps(self, tmp_path): + # Intermediate dirs without AGENTS.md contribute nothing. + (tmp_path / ".git").mkdir() + (tmp_path / "AGENTS.md").write_text("Root rules.") + deep = tmp_path / "a" / "b" / "c" + deep.mkdir(parents=True) + result = build_context_files_prompt(cwd=str(deep), skip_soul=True) + assert "Root rules." in result + assert result.count("## ") == 1 + + def test_agents_md_chain_dedupes_identical_content(self, tmp_path): + (tmp_path / ".git").mkdir() + (tmp_path / "AGENTS.md").write_text("Same rules everywhere.") + sub = tmp_path / "sub" + sub.mkdir() + (sub / "AGENTS.md").write_text("Same rules everywhere.") + result = build_context_files_prompt(cwd=str(sub), skip_soul=True) + assert result.count("Same rules everywhere.") == 1 + + def test_agents_md_single_file_output_unchanged(self, tmp_path): + # Zero-regression guarantee: with one AGENTS.md at cwd (git repo or + # not), the section is byte-identical to historical single-file form. + from agent.prompt_builder import _load_agents_md + + (tmp_path / ".git").mkdir() + sub = tmp_path / "sub" + sub.mkdir() + (sub / "AGENTS.md").write_text("Only file.") + assert _load_agents_md(sub) == "## AGENTS.md\n\nOnly file." + + def test_agents_md_no_git_root_stays_cwd_only(self, tmp_path): + # Without a git root, parents are never consulted (no picking up an + # AGENTS.md planted in /tmp or $HOME). + (tmp_path / "AGENTS.md").write_text("Planted in parent.") + sub = tmp_path / "sub" + sub.mkdir() + from agent.prompt_builder import _load_agents_md + + assert _load_agents_md(sub) == "" + def test_skips_agents_md_in_install_tree_on_fallback(self, monkeypatch, tmp_path): # A backend that FALLS BACK into the install tree (cwd=None → getcwd, # the desktop default) must not load that tree's contributor AGENTS.md diff --git a/website/docs/user-guide/features/context-files.md b/website/docs/user-guide/features/context-files.md index c3e3f61edf..4ec8e38866 100644 --- a/website/docs/user-guide/features/context-files.md +++ b/website/docs/user-guide/features/context-files.md @@ -27,6 +27,21 @@ Only **one** project context type is loaded per session (first match wins): `.he `AGENTS.md` is the primary project context file. It tells the agent how your project is structured, what conventions to follow, and any special instructions. +### Directory Chain (git root → working directory) + +When your working directory sits inside a git repository, Hermes loads a **merged chain** of `AGENTS.md` files at session start: the git-root `AGENTS.md` first, then the `AGENTS.md` in every intermediate directory down to your working directory. Deeper files appear later in the prompt, so more specific guidance takes precedence. Each file gets its own provenance header (e.g. `## ../../AGENTS.md`), and identical copies along the chain are deduplicated. + +``` +monorepo/ (git root, cwd = packages/webapp/) +├── AGENTS.md ← Loaded first (repo-wide conventions) +└── packages/ + ├── AGENTS.md ← Loaded second + └── webapp/ + └── AGENTS.md ← Loaded last (most specific, takes precedence) +``` + +Outside a git repository, only the working directory itself is checked — parents are never consulted, so an `AGENTS.md` planted in `/tmp` or `$HOME` can't leak into unrelated sessions. + ### Progressive Subdirectory Discovery At session start, Hermes loads the `AGENTS.md` from your working directory into the system prompt. As the agent navigates into subdirectories during the session (via `read_file`, `terminal`, `search_files`, etc.), it **progressively discovers** context files in those directories and injects them into the conversation at the moment they become relevant.