Port from superagent-ai/grok-cli: directory-chain AGENTS.md loading

This commit is contained in:
Teknium
2026-08-06 21:22:55 -07:00
parent 1405d330e7
commit 2e2fcc09ff
3 changed files with 158 additions and 14 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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.