fix(context): derive the /context file listing from the builder's own discovery walk
Review follow-up (Enough1122) on the salvaged #91272: the original list_context_file_sources() hand-mirrored the priority ladder inside build_context_files_prompt, so the two would drift the moment the builder gained a context type or changed precedence — misreporting what the prompt holds is worse than not showing it. Now prompt_builder exposes one candidate finder per context type (_CONTEXT_FILE_CANDIDATES → discover_context_files) and BOTH the loaders and the manifest walk it. The manifest lives in the new sibling agent/context_file_sources.py (not appended to the facade) and: - reports empty / unreadable files truthfully instead of "✓ 0 tokens", - mirrors the install-tree guard ("suppressed") so a Desktop session that fell back into the Hermes tree sees why nothing loaded, - lists every .cursor/rules/*.mdc as loaded, matching the builder which concatenates all of them, - measures truncation on the rendered "## label" section like the builder. The block now renders on every surface that shows the /context category table: CLI/TUI (hermes_cli/cli_info_mixin.py) and the messaging gateway (gateway/slash_commands_status.py). The Desktop popover consumes the raw session.context_breakdown payload (no text table) and is left as-is. Tests trimmed to the two invariants: manifest/prompt parity across every context type at once, and truncated/suppressed follow the builder.
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
"""Per-file manifest of the context/instruction files behind the ``/context`` "Rules" figure.
|
||||
|
||||
Read-only: enumerates the same candidates ``build_context_files_prompt`` loads (through
|
||||
``agent.prompt_builder.discover_context_files`` — one discovery walk, so the listing cannot drift from the
|
||||
prompt) and reports, per file, its size and whether it was loaded, truncated over the context-file cap,
|
||||
shadowed by a higher-priority context type, empty/unreadable, or suppressed by the install-tree guard.
|
||||
Nothing here builds a prompt or touches the truncation-warning ContextVar, so it is free of cache impact.
|
||||
|
||||
Inspired by Copilot CLI 1.0.81's per-file ``/instructions`` view.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from agent import prompt_builder as _pb
|
||||
from agent.model_metadata import estimate_tokens_rough
|
||||
|
||||
# status -> (glyph, note shown after the token count; "" = none)
|
||||
_STATUS_DISPLAY = {
|
||||
"loaded": ("✓", ""),
|
||||
"truncated": ("◐", "truncated — over context_file_max_chars"),
|
||||
"shadowed": ("○", "not loaded — higher-priority context type wins"),
|
||||
"empty": ("○", "not loaded — empty file"),
|
||||
"unreadable": ("✗", "not loaded — could not be read"),
|
||||
"suppressed": ("○", "not loaded — cwd fell back to the Hermes install tree"),
|
||||
}
|
||||
|
||||
|
||||
def _entry(label: str, path: Path, content: str, status: str) -> Dict[str, Any]:
|
||||
return {
|
||||
"label": label, "path": str(path), "chars": len(content), "est_tokens": estimate_tokens_rough(content),
|
||||
"loaded": status in ("loaded", "truncated"), "status": status,
|
||||
}
|
||||
|
||||
|
||||
def _empty_status(path: Path) -> str:
|
||||
"""A file the builder read as "" is either genuinely empty or unreadable (permissions, timeout)."""
|
||||
try:
|
||||
return "unreadable" if path.stat().st_size > 0 else "empty"
|
||||
except OSError:
|
||||
return "unreadable"
|
||||
|
||||
|
||||
def _loaded_status(rendered_len: int, max_chars: int) -> str:
|
||||
return "truncated" if rendered_len > max_chars else "loaded"
|
||||
|
||||
|
||||
def list_context_file_sources(
|
||||
cwd: Optional[str] = None, context_length: Optional[int] = None, allow_install_tree_fallback: bool = False,
|
||||
home_override: "Path | None" = None, skip_soul: bool = False,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""One dict per context file Hermes considered, in the builder's priority order.
|
||||
|
||||
Same signature semantics as ``build_context_files_prompt`` (``cwd=None`` → launch dir, install-tree guard
|
||||
unless *allow_install_tree_fallback*). Keys: ``label``, ``path``, ``chars``, ``est_tokens``, ``loaded``
|
||||
and ``status`` ∈ loaded / truncated / shadowed / empty / unreadable / suppressed.
|
||||
"""
|
||||
cwd_path = Path(cwd if cwd is not None else os.getcwd()).resolve()
|
||||
max_chars = _pb._get_context_file_max_chars(context_length)
|
||||
suppressed = _pb._project_context_suppressed(cwd, cwd_path, allow_install_tree_fallback)
|
||||
sources: List[Dict[str, Any]] = []
|
||||
winner: Optional[str] = None
|
||||
for kind, label, path, content in _pb.discover_context_files(cwd_path):
|
||||
if not content:
|
||||
status = _empty_status(path)
|
||||
elif suppressed:
|
||||
status = "suppressed"
|
||||
elif winner in (None, kind):
|
||||
winner = kind
|
||||
# The builder caps the rendered ``## label`` section, not the raw file.
|
||||
status = _loaded_status(len(f"## {label}\n\n{content}"), max_chars)
|
||||
else:
|
||||
status = "shadowed"
|
||||
sources.append(_entry(label, path, content, status))
|
||||
|
||||
if not skip_soul:
|
||||
home = Path(home_override) if home_override is not None else _pb.get_hermes_home()
|
||||
soul_path = home / "SOUL.md"
|
||||
if _pb._exists_or_denied(soul_path):
|
||||
content = _pb._read_context_file(soul_path)
|
||||
status = _loaded_status(len(content), max_chars) if content else _empty_status(soul_path)
|
||||
sources.append(_entry("SOUL.md", soul_path, content, status))
|
||||
return sources
|
||||
|
||||
|
||||
def context_file_sources_for_agent(agent: Any) -> List[Dict[str, Any]]:
|
||||
"""The manifest for a live agent, resolved exactly like ``agent.system_prompt._context_files_part``
|
||||
(session cwd, install-tree policy per platform, the agent's own profile home)."""
|
||||
if getattr(agent, "skip_context_files", False):
|
||||
return []
|
||||
from agent.runtime_cwd import resolve_context_cwd
|
||||
from agent.system_prompt import _agent_home
|
||||
launch_artifact = getattr(agent, "_context_cwd_is_launch_artifact", False)
|
||||
cwd = None if launch_artifact else resolve_context_cwd()
|
||||
ctx_len = getattr(getattr(agent, "context_compressor", None), "context_length", None)
|
||||
return list_context_file_sources(
|
||||
cwd=str(cwd) if cwd is not None else None, context_length=ctx_len if isinstance(ctx_len, int) else None,
|
||||
allow_install_tree_fallback=getattr(agent, "platform", None) in ("cli", "tui"), home_override=_agent_home(agent),
|
||||
)
|
||||
|
||||
|
||||
def render_context_file_lines(sources: List[Dict[str, Any]]) -> List[str]:
|
||||
"""Plain-text ``Context files`` block for ``/context``; [] when nothing was found."""
|
||||
if not sources:
|
||||
return []
|
||||
width = max(len(str(src.get("label") or "")) for src in sources)
|
||||
lines = ["Context files"]
|
||||
for src in sources:
|
||||
glyph, note = _STATUS_DISPLAY.get(str(src.get("status") or ""), ("•", ""))
|
||||
suffix = f" ({note})" if note else ""
|
||||
lines.append(f"{glyph} {str(src.get('label') or ''):<{width}} ~{int(src.get('est_tokens') or 0):>9,} tokens{suffix}")
|
||||
return lines
|
||||
+94
-41
@@ -1501,14 +1501,13 @@ def _context_section(content: str, label: str, warn_name: str, path: Path, conte
|
||||
return _truncate_content(body, warn_name, context_length=context_length, read_path=str(path))
|
||||
|
||||
|
||||
def _load_hermes_md(cwd_path: Path, context_length: Optional[int] = None) -> str:
|
||||
def _hermes_md_candidates(cwd_path: Path) -> list[tuple[str, Path, str]]:
|
||||
""".hermes.md / HERMES.md — nearest match walking up to the git root."""
|
||||
hermes_md_path = _find_hermes_md(cwd_path)
|
||||
content = _read_context_file(hermes_md_path) if hermes_md_path else ""
|
||||
if not content:
|
||||
return ""
|
||||
label = str(hermes_md_path.relative_to(cwd_path)) if hermes_md_path.is_relative_to(cwd_path) else hermes_md_path.name
|
||||
return _context_section(_strip_yaml_frontmatter(content), label, ".hermes.md", hermes_md_path, context_length)
|
||||
path = _find_hermes_md(cwd_path)
|
||||
if path is None:
|
||||
return []
|
||||
label = str(path.relative_to(cwd_path)) if path.is_relative_to(cwd_path) else path.name
|
||||
return [(label, path, _read_context_file(path))]
|
||||
|
||||
|
||||
def _agents_md_directory_chain(cwd_path: Path) -> list[Path]:
|
||||
@@ -1521,59 +1520,120 @@ def _agents_md_directory_chain(cwd_path: Path) -> list[Path]:
|
||||
return [root] + [root.joinpath(*parts[: i + 1]) for i in range(len(parts))]
|
||||
|
||||
|
||||
def _agents_md_candidates(cwd_path: Path) -> list[tuple[str, Path, str]]:
|
||||
"""AGENTS.md chain from git root down to cwd; per directory the first NON-EMPTY of ``AGENTS.override.md`` /
|
||||
``AGENTS.md`` / ``agents.md`` wins (empty or unreadable files are listed but fall through)."""
|
||||
cwd_resolved = cwd_path.resolve()
|
||||
found: list[tuple[str, Path, str]] = []
|
||||
for directory in _agents_md_directory_chain(cwd_resolved):
|
||||
for name in ("AGENTS.override.md", "AGENTS.md", "agents.md"):
|
||||
candidate = directory / name
|
||||
if not _exists_or_denied(candidate):
|
||||
continue
|
||||
content = _read_context_file(candidate)
|
||||
label = name if directory == cwd_resolved else os.path.relpath(candidate, cwd_resolved)
|
||||
found.append((label, candidate, content))
|
||||
if content:
|
||||
break # first name match wins per directory
|
||||
return found
|
||||
|
||||
|
||||
def _claude_md_candidates(cwd_path: Path) -> list[tuple[str, Path, str]]:
|
||||
"""CLAUDE.md / claude.md — cwd only, first non-empty wins."""
|
||||
found: list[tuple[str, Path, str]] = []
|
||||
for name in ("CLAUDE.md", "claude.md"):
|
||||
candidate = cwd_path / name
|
||||
if not _exists_or_denied(candidate):
|
||||
continue
|
||||
content = _read_context_file(candidate)
|
||||
found.append((name, candidate, content))
|
||||
if content:
|
||||
break
|
||||
return found
|
||||
|
||||
|
||||
def _cursorrules_candidates(cwd_path: Path) -> list[tuple[str, Path, str]]:
|
||||
""".cursorrules + .cursor/rules/*.mdc — cwd only; every non-empty file is concatenated."""
|
||||
candidates: list[tuple[str, Path]] = [(".cursorrules", cwd_path / ".cursorrules")]
|
||||
cursor_rules_dir = cwd_path / ".cursor" / "rules"
|
||||
if cursor_rules_dir.is_dir():
|
||||
candidates += [(f".cursor/rules/{f.name}", f) for f in sorted(cursor_rules_dir.glob("*.mdc"))]
|
||||
return [(label, path, _read_context_file(path)) for label, path in candidates if _exists_or_denied(path)]
|
||||
|
||||
|
||||
# Project-context types in priority order: the first type with any non-empty file wins, later types are
|
||||
# shadowed. Both the prompt build (loaders below) and the /context manifest
|
||||
# (``agent/context_file_sources.py``) enumerate files through these finders, so the two cannot drift.
|
||||
_CONTEXT_FILE_CANDIDATES = {
|
||||
"hermes_md": _hermes_md_candidates,
|
||||
"agents_md": _agents_md_candidates,
|
||||
"claude_md": _claude_md_candidates,
|
||||
"cursorrules": _cursorrules_candidates,
|
||||
}
|
||||
|
||||
|
||||
def discover_context_files(cwd_path: Path) -> list[tuple[str, str, Path, str]]:
|
||||
"""Every project-context file on disk as ``(kind, label, path, content)`` in priority order.
|
||||
``content == ""`` means empty or unreadable — such a file is never loaded."""
|
||||
return [(kind, label, path, content)
|
||||
for kind, finder in _CONTEXT_FILE_CANDIDATES.items() for label, path, content in finder(cwd_path)]
|
||||
|
||||
|
||||
def _project_context_suppressed(cwd: Optional[str], cwd_path: Path, allow_install_tree_fallback: bool) -> bool:
|
||||
"""A FALLBACK-picked cwd inside the Hermes install tree must not gain system-prompt authority (the desktop
|
||||
default would load this repo's contributor AGENTS.md). An explicitly configured cwd is honored verbatim —
|
||||
the Hermes tree is a legitimate workspace when the user deliberately points a session at it — and
|
||||
CLI-style surfaces pass allow_install_tree_fallback=True because their launch dir IS the user's shell cwd
|
||||
(developing Hermes in-tree). See #64590."""
|
||||
from agent.runtime_cwd import _is_install_tree
|
||||
return cwd is None and not allow_install_tree_fallback and _is_install_tree(cwd_path)
|
||||
|
||||
|
||||
def _load_hermes_md(cwd_path: Path, context_length: Optional[int] = None) -> str:
|
||||
""".hermes.md / HERMES.md — nearest match walking up to the git root."""
|
||||
for label, path, content in _hermes_md_candidates(cwd_path):
|
||||
if content:
|
||||
return _context_section(_strip_yaml_frontmatter(content), label, ".hermes.md", path, context_length)
|
||||
return ""
|
||||
|
||||
|
||||
def _load_agents_md(cwd_path: Path, context_length: Optional[int] = None) -> str:
|
||||
"""AGENTS.md — merged directory chain from git root down to cwd.
|
||||
|
||||
Per directory the first of ``AGENTS.override.md`` / ``AGENTS.md`` / ``agents.md`` wins (a gitignored
|
||||
personal override shadows the committed file); identical content seen again down the chain is skipped.
|
||||
|
||||
Each directory on the chain (see ``_agents_md_directory_chain``) contributes its ``AGENTS.override.md``
|
||||
/ ``AGENTS.md`` / ``agents.md`` (first name wins per directory) as its own provenance-labelled section.
|
||||
Each directory on the chain (see ``_agents_md_candidates``) contributes its ``AGENTS.override.md`` /
|
||||
``AGENTS.md`` / ``agents.md`` (first name wins per directory) as its own provenance-labelled section.
|
||||
``AGENTS.override.md`` wins over ``AGENTS.md`` so a developer can keep a personal, typically-gitignored
|
||||
override next to the committed project instructions without editing the tracked file (same convention as
|
||||
earendil-works/pi#7681). 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.override.md", "AGENTS.md", "agents.md"):
|
||||
candidate = directory / name
|
||||
content = _read_context_file(candidate)
|
||||
if not content:
|
||||
continue
|
||||
if content not in seen_content: # else: identical copy along the chain
|
||||
seen_content.add(content)
|
||||
label = name if directory == cwd_resolved else os.path.relpath(candidate, cwd_resolved)
|
||||
sections.append(_context_section(content, label, label, candidate, context_length))
|
||||
break # first name match wins per directory
|
||||
for label, candidate, content in _agents_md_candidates(cwd_path):
|
||||
if content and content not in seen_content: # else: empty, or an identical copy along the chain
|
||||
seen_content.add(content)
|
||||
sections.append(_context_section(content, label, label, candidate, context_length))
|
||||
if len(sections) <= 1:
|
||||
return sections[0] if sections else ""
|
||||
# Per-file budgets applied above; also cap the merged chain so a deep monorepo can't multiply the budget.
|
||||
return _truncate_content("\n\n".join(sections), "AGENTS.md (directory chain)", context_length=context_length,
|
||||
read_path=str(cwd_resolved / "AGENTS.md"))
|
||||
read_path=str(cwd_path.resolve() / "AGENTS.md"))
|
||||
|
||||
|
||||
def _load_claude_md(cwd_path: Path, context_length: Optional[int] = None) -> str:
|
||||
"""CLAUDE.md / claude.md — cwd only."""
|
||||
for name in ("CLAUDE.md", "claude.md"):
|
||||
content = _read_context_file(cwd_path / name)
|
||||
for name, path, content in _claude_md_candidates(cwd_path):
|
||||
if content:
|
||||
return _context_section(content, name, "CLAUDE.md", cwd_path / name, context_length)
|
||||
return _context_section(content, name, "CLAUDE.md", path, context_length)
|
||||
return ""
|
||||
|
||||
|
||||
def _load_cursorrules(cwd_path: Path, context_length: Optional[int] = None) -> str:
|
||||
""".cursorrules + .cursor/rules/*.mdc — cwd only, concatenated."""
|
||||
candidates: list[tuple[Path, str]] = [(cwd_path / ".cursorrules", ".cursorrules")]
|
||||
cursor_rules_dir = cwd_path / ".cursor" / "rules"
|
||||
if cursor_rules_dir.is_dir():
|
||||
candidates += [(f, f".cursor/rules/{f.name}") for f in sorted(cursor_rules_dir.glob("*.mdc"))]
|
||||
cursorrules_content = "".join(
|
||||
f"## {label}\n\n{_scan_context_content(content, label)}\n\n"
|
||||
for path, label in candidates if (content := _read_context_file(path))
|
||||
for label, _path, content in _cursorrules_candidates(cwd_path) if content
|
||||
)
|
||||
if not cursorrules_content:
|
||||
return ""
|
||||
@@ -1592,14 +1652,7 @@ def build_context_files_prompt(
|
||||
from HERMES_HOME is independent and always included unless *skip_soul* (already the identity slot).
|
||||
"""
|
||||
cwd_path = Path(cwd if cwd is not None else os.getcwd()).resolve()
|
||||
# A FALLBACK-picked cwd inside the Hermes install tree must not gain system-prompt authority (the desktop
|
||||
# default would load this repo's contributor AGENTS.md). An explicit cwd is honored verbatim.
|
||||
# An explicitly configured cwd is honored verbatim — the Hermes tree is a legitimate workspace when the
|
||||
# user deliberately points a session at it — and CLI-style surfaces pass
|
||||
# allow_install_tree_fallback=True because their launch dir IS the user's shell cwd (developing Hermes
|
||||
# in-tree). See #64590.
|
||||
from agent.runtime_cwd import _is_install_tree
|
||||
if cwd is None and not allow_install_tree_fallback and _is_install_tree(cwd_path):
|
||||
if _project_context_suppressed(cwd, cwd_path, allow_install_tree_fallback):
|
||||
logger.warning(
|
||||
"skipping project-context discovery: working-directory resolution fell back to the Hermes "
|
||||
"install tree (%s) — set terminal.cwd to your project directory", cwd_path,
|
||||
|
||||
@@ -488,7 +488,9 @@ class GatewayStatusCommandsMixin:
|
||||
if not (payload.get("categories") or []):
|
||||
return []
|
||||
details = _quiet_sync(lambda: compute_context_details(agent), {"skills": [], "toolsets": []}) if expanded else None
|
||||
return render_context_breakdown_lines(payload, details=details, grid=False)
|
||||
from agent.context_file_sources import context_file_sources_for_agent, render_context_file_lines
|
||||
file_lines = _quiet_sync(lambda: render_context_file_lines(context_file_sources_for_agent(agent)), [])
|
||||
return render_context_breakdown_lines(payload, details=details, grid=False) + ([""] + file_lines if file_lines else [])
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
@@ -654,10 +654,16 @@ class CLIInfoMixin:
|
||||
except Exception:
|
||||
details = {"skills": [], "toolsets": []}
|
||||
|
||||
from agent.context_file_sources import context_file_sources_for_agent, render_context_file_lines
|
||||
try:
|
||||
file_lines = render_context_file_lines(context_file_sources_for_agent(self.agent))
|
||||
except Exception:
|
||||
file_lines = []
|
||||
|
||||
print()
|
||||
print(f" 🧠 Context Usage — {payload.get('model') or self.model}")
|
||||
print()
|
||||
for line in render_context_breakdown_lines(payload, details=details, grid=True):
|
||||
for line in render_context_breakdown_lines(payload, details=details, grid=True) + ([""] + file_lines if file_lines else []):
|
||||
print(f" {line}")
|
||||
print()
|
||||
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Per-file context source manifest (`list_context_file_sources`).
|
||||
"""Per-file context manifest (``agent/context_file_sources.py``) behind the ``/context`` Rules figure.
|
||||
|
||||
Read-only mirror of ``build_context_files_prompt`` discovery: one entry per
|
||||
candidate context/instruction file with size, token estimate, and whether it
|
||||
was loaded, truncated, or shadowed by a higher-priority context type.
|
||||
|
||||
Inspired by: GitHub Copilot CLI 1.0.81 — "Show each user instruction file
|
||||
separately in /instructions".
|
||||
The manifest and ``build_context_files_prompt`` share one discovery walk, so the invariant under test is
|
||||
parity: a file is reported ``loaded`` iff its content appears in the built prompt.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from agent.prompt_builder import list_context_file_sources
|
||||
from agent.context_file_sources import list_context_file_sources, render_context_file_lines
|
||||
from agent.prompt_builder import build_context_files_prompt
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
@@ -24,89 +22,62 @@ def _by_label(sources):
|
||||
return {s["label"]: s for s in sources}
|
||||
|
||||
|
||||
class TestDiscovery:
|
||||
def test_agents_md_loaded(self, project):
|
||||
(project / "AGENTS.md").write_text("# rules\n" * 10)
|
||||
sources = list_context_file_sources(cwd=str(project))
|
||||
entry = _by_label(sources)["AGENTS.md"]
|
||||
assert entry["loaded"] is True
|
||||
assert entry["status"] == "loaded"
|
||||
assert entry["chars"] > 0
|
||||
assert entry["est_tokens"] == (entry["chars"] + 3) // 4
|
||||
def test_manifest_matches_what_the_prompt_actually_loads(project, tmp_path_factory):
|
||||
"""Every context type present at once; the ladder picks .hermes.md, the chain lists both AGENTS files,
|
||||
CLAUDE.md/.cursorrules/.cursor/rules/*.mdc are shadowed, an empty file never wins, SOUL.md rides along."""
|
||||
(project / ".hermes.md").write_text("hermes rules")
|
||||
(project / "AGENTS.md").write_text("root agents rules")
|
||||
sub = project / "pkg"
|
||||
sub.mkdir()
|
||||
(sub / "AGENTS.override.md").write_text("") # empty: falls through to AGENTS.md in the same directory
|
||||
(sub / "AGENTS.md").write_text("pkg agents rules")
|
||||
(sub / "CLAUDE.md").write_text("claude rules")
|
||||
(sub / ".cursorrules").write_text("cursor rules")
|
||||
(sub / ".cursor" / "rules").mkdir(parents=True)
|
||||
(sub / ".cursor" / "rules" / "a.mdc").write_text("mdc rule a")
|
||||
home = tmp_path_factory.mktemp("home")
|
||||
(home / "SOUL.md").write_text("identity text")
|
||||
|
||||
def test_empty_project_returns_empty(self, tmp_path, tmp_path_factory):
|
||||
(tmp_path / ".git").mkdir()
|
||||
empty_home = tmp_path_factory.mktemp("empty_home")
|
||||
assert (
|
||||
list_context_file_sources(cwd=str(tmp_path), home_override=empty_home)
|
||||
== []
|
||||
)
|
||||
sources = list_context_file_sources(cwd=str(sub), home_override=home)
|
||||
prompt = build_context_files_prompt(cwd=str(sub), home_override=home)
|
||||
|
||||
def test_priority_shadowing_hermes_md_over_agents_md(self, project):
|
||||
(project / ".hermes.md").write_text("hermes rules")
|
||||
(project / "AGENTS.md").write_text("agents rules")
|
||||
entries = _by_label(list_context_file_sources(cwd=str(project)))
|
||||
assert entries[".hermes.md"]["status"] == "loaded"
|
||||
assert entries["AGENTS.md"]["status"] == "shadowed"
|
||||
assert entries["AGENTS.md"]["loaded"] is False
|
||||
statuses = {s["label"]: s["status"] for s in sources}
|
||||
assert statuses == {
|
||||
".hermes.md": "loaded", "../AGENTS.md": "shadowed", "AGENTS.override.md": "empty", "AGENTS.md": "shadowed",
|
||||
"CLAUDE.md": "shadowed", ".cursorrules": "shadowed", ".cursor/rules/a.mdc": "shadowed", "SOUL.md": "loaded",
|
||||
}
|
||||
for src in sources:
|
||||
body = Path(src["path"]).read_text().strip() if src["chars"] else ""
|
||||
assert src["loaded"] == (bool(body) and body in prompt), src
|
||||
assert all(s["est_tokens"] > 0 for s in sources if s["chars"])
|
||||
|
||||
def test_claude_md_shadowed_by_agents_md(self, project):
|
||||
(project / "AGENTS.md").write_text("agents rules")
|
||||
(project / "CLAUDE.md").write_text("claude rules")
|
||||
entries = _by_label(list_context_file_sources(cwd=str(project)))
|
||||
assert entries["AGENTS.md"]["status"] == "loaded"
|
||||
assert entries["CLAUDE.md"]["status"] == "shadowed"
|
||||
# Same walk, other winner: drop .hermes.md and the whole AGENTS chain loads while the rest stays shadowed.
|
||||
(project / ".hermes.md").unlink()
|
||||
statuses = {s["label"]: s["status"] for s in list_context_file_sources(cwd=str(sub), home_override=home)}
|
||||
prompt = build_context_files_prompt(cwd=str(sub), home_override=home)
|
||||
assert statuses["../AGENTS.md"] == statuses["AGENTS.md"] == "loaded" and "root agents rules" in prompt
|
||||
assert statuses["CLAUDE.md"] == "shadowed" and "claude rules" not in prompt
|
||||
|
||||
def test_cursorrules_listed(self, project):
|
||||
(project / ".cursorrules").write_text("cursor rules")
|
||||
rules_dir = project / ".cursor" / "rules"
|
||||
rules_dir.mkdir(parents=True)
|
||||
(rules_dir / "a.mdc").write_text("rule a")
|
||||
entries = _by_label(list_context_file_sources(cwd=str(project)))
|
||||
assert entries[".cursorrules"]["status"] == "loaded"
|
||||
assert entries[".cursor/rules/a.mdc"]["status"] == "loaded"
|
||||
|
||||
def test_agents_override_wins_per_directory(self, project):
|
||||
(project / "AGENTS.md").write_text("committed")
|
||||
(project / "AGENTS.override.md").write_text("personal override")
|
||||
labels = [s["label"] for s in list_context_file_sources(cwd=str(project))]
|
||||
assert "AGENTS.override.md" in labels
|
||||
assert "AGENTS.md" not in labels # first name wins per directory
|
||||
def test_truncated_and_suppressed_statuses_follow_the_builder(project, monkeypatch, tmp_path_factory):
|
||||
import agent.prompt_builder as pb
|
||||
|
||||
def test_directory_chain_lists_both_agents_files(self, project):
|
||||
(project / "AGENTS.md").write_text("root rules")
|
||||
sub = project / "pkg"
|
||||
sub.mkdir()
|
||||
(sub / "AGENTS.md").write_text("pkg rules")
|
||||
sources = list_context_file_sources(cwd=str(sub))
|
||||
agents_entries = [s for s in sources if "AGENTS.md" in s["label"]]
|
||||
assert len(agents_entries) == 2
|
||||
assert all(s["loaded"] for s in agents_entries)
|
||||
monkeypatch.setattr(pb, "_get_context_file_max_chars", lambda *_a: 40)
|
||||
(project / "AGENTS.md").write_text("x" * 100)
|
||||
home = tmp_path_factory.mktemp("home")
|
||||
entry = _by_label(list_context_file_sources(cwd=str(project), home_override=home))["AGENTS.md"]
|
||||
assert entry["status"] == "truncated" and entry["loaded"] is True
|
||||
assert "[...truncated AGENTS.md" in build_context_files_prompt(cwd=str(project), home_override=home)
|
||||
|
||||
def test_truncated_status_when_over_cap(self, project, monkeypatch):
|
||||
import agent.prompt_builder as pb
|
||||
# Install-tree guard: a fallback cwd (cwd=None) inside the Hermes tree lists the file but never loads it.
|
||||
monkeypatch.setattr("agent.runtime_cwd._is_install_tree", lambda _p: True)
|
||||
monkeypatch.chdir(project)
|
||||
entry = _by_label(list_context_file_sources(cwd=None, home_override=home))["AGENTS.md"]
|
||||
assert entry["status"] == "suppressed" and entry["loaded"] is False
|
||||
assert build_context_files_prompt(cwd=None, skip_soul=True) == ""
|
||||
assert _by_label(list_context_file_sources(cwd=None, allow_install_tree_fallback=True, home_override=home))[
|
||||
"AGENTS.md"]["status"] == "truncated"
|
||||
|
||||
monkeypatch.setattr(pb, "_get_context_file_max_chars", lambda *_a: 10)
|
||||
(project / "AGENTS.md").write_text("x" * 100)
|
||||
entries = _by_label(list_context_file_sources(cwd=str(project)))
|
||||
assert entries["AGENTS.md"]["status"] == "truncated"
|
||||
assert entries["AGENTS.md"]["loaded"] is True
|
||||
|
||||
def test_soul_md_from_home_override(self, project, tmp_path_factory):
|
||||
home = tmp_path_factory.mktemp("hermes_home")
|
||||
(home / "SOUL.md").write_text("identity")
|
||||
entries = _by_label(
|
||||
list_context_file_sources(cwd=str(project), home_override=home)
|
||||
)
|
||||
assert entries["SOUL.md"]["status"] == "loaded"
|
||||
|
||||
def test_read_only_no_side_effects(self, project):
|
||||
(project / "AGENTS.md").write_text("rules")
|
||||
before = (project / "AGENTS.md").read_text()
|
||||
list_context_file_sources(cwd=str(project))
|
||||
assert (project / "AGENTS.md").read_text() == before
|
||||
|
||||
def test_accepts_path_object(self, project):
|
||||
(project / "AGENTS.md").write_text("rules")
|
||||
sources = list_context_file_sources(cwd=project)
|
||||
assert _by_label(sources)["AGENTS.md"]["loaded"] is True
|
||||
lines = render_context_file_lines(list_context_file_sources(cwd=None, home_override=home))
|
||||
assert lines[0] == "Context files" and "AGENTS.md" in lines[1] and "install tree" in lines[1]
|
||||
assert render_context_file_lines([]) == []
|
||||
|
||||
@@ -63,7 +63,7 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in
|
||||
| `/egress [status]` | Show Docker egress proxy status — enabled/configured/running state, credential source, token mappings, uncovered providers, and next remediation step. Works in CLI, TUI, Desktop chat, and messaging gateway. |
|
||||
| `/redraw` | Force a full UI repaint (recovers from terminal drift after tmux resize, mouse selection artifacts, etc.) |
|
||||
| `/status` | Show session info — model, provider, profile, session ID, working directory, title, created/updated timestamps, token totals, agent-running state — followed by a local **Session recap** block (recent user/assistant turn counts, tool result count, top tools used, last few files touched, the latest user prompt, and the latest assistant reply). The recap is computed locally from the in-memory conversation; no LLM call, no prompt-cache impact. |
|
||||
| `/context [all]` (alias: `/ctx`) | Visual context-window breakdown. On the CLI/TUI: a 5×20 glyph block grid (each cell ≈ 1% of the model window) plus an estimated per-category table — system prompt, tool definitions, rules, skills index, MCP, subagents, memory, conversation — versus free space, followed by a per-file **Context files** listing (AGENTS.md chain, .hermes.md, CLAUDE.md, .cursorrules, SOUL.md) with each file's token estimate and whether it was loaded, truncated over `context_file_max_chars`, or shadowed by a higher-priority context type. On messaging platforms: a usage gauge with auto-compression threshold/headroom, compression stats, cumulative throughput, and the same category table in plain text. `/context all` appends per-skill and per-toolset cost listings (index cost vs SKILL.md load cost; schema tokens per toolset). Read-only and computed locally — no LLM call, no prompt-cache impact. |
|
||||
| `/context [all]` (alias: `/ctx`) | Visual context-window breakdown. On the CLI/TUI: a 5×20 glyph block grid (each cell ≈ 1% of the model window) plus an estimated per-category table — system prompt, tool definitions, rules, skills index, MCP, subagents, memory, conversation — versus free space. On messaging platforms: a usage gauge with auto-compression threshold/headroom, compression stats, cumulative throughput, and the same category table in plain text. Both end with a per-file **Context files** listing (.hermes.md, AGENTS.md chain, CLAUDE.md, .cursorrules + .cursor/rules/*.mdc, SOUL.md) showing each file's token estimate and whether it was loaded, truncated over `context_file_max_chars`, shadowed by a higher-priority context type, empty/unreadable, or suppressed by the install-tree guard — the answer to "why is my CLAUDE.md ignored?". `/context all` appends per-skill and per-toolset cost listings (index cost vs SKILL.md load cost; schema tokens per toolset). Read-only and computed locally — no LLM call, no prompt-cache impact. |
|
||||
| `/agents` (alias: `/tasks`) | Show active agents and running tasks across the current session. |
|
||||
| `/bg <prompt>` | Run a prompt in a separate background session. The agent processes your prompt independently — your current session stays free for other work. Results appear as a panel when the task finishes. See [CLI Background Sessions](/user-guide/cli#background-sessions). |
|
||||
| `/btw <question>` | Ask a quick side question **about the current conversation** without interrupting it. A one-shot auxiliary LLM call answers from a read-only snapshot of the transcript — the live session's history and prompt cache are untouched, and the current turn keeps running. For independent work with a fresh context, use `/bg`. |
|
||||
|
||||
Reference in New Issue
Block a user