Port from superagent-ai/grok-cli: directory-chain AGENTS.md loading
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user