Files
hermes-agent/scripts/ci/check_agents_md_size.py
Teknium e1ec584b57 feat(ci): cap every AGENTS.md chain below the context budget it is truncated at
scripts/ci/check_agents_md_size.py (in scripts/check and lint.yml): root <= 12k, every
AGENTS.md plus its ancestors <= 30k (the joined root->cwd block on a 128k window, minus
the per-file labels), every area file <= _MAX_HINT_CHARS read from the source so the two
cannot drift. On origin/main it reports the root and all 11 root-to-area chains over.
2026-10-04 23:05:42 -07:00

66 lines
3.0 KiB
Python

#!/usr/bin/env python3
"""Fail when a tracked AGENTS.md would be truncated when an agent loads it.
An agent started in a directory gets every AGENTS.md from the git root down to that directory as
ONE project-context block: ``agent/prompt_builder.py`` joins the chain before truncating it, at 6%
of the context window (30,720 chars on a 128k-token model, a 20k floor on small ones). So the
budget is per CHAIN: each AGENTS.md plus all of its ancestors stays within CHAIN_MAX_CHARS (the
128k budget minus room for the per-file provenance labels), and the root, which is in every
chain, stays within ROOT_MAX_CHARS so area files have room. An area file reached as a
subdirectory hint is also capped by ``_MAX_HINT_CHARS`` in ``agent/subdirectory_hints.py``, read
from the source here so the two can never drift. Shrink a file (move long form to the developer
guide or a sibling doc) instead of raising any number.
Exit codes: 0 = all within their caps, 1 = a file or chain is over (paths and sizes printed).
"""
from __future__ import annotations
import ast
import subprocess
import sys
from pathlib import Path, PurePosixPath
ROOT_MAX_CHARS = 12_000
CHAIN_MAX_CHARS = 30_000
def _int_constant(source: Path, name: str) -> int:
for node in ast.walk(ast.parse(source.read_text(encoding="utf-8-sig"))):
if isinstance(node, ast.Assign) and any(getattr(t, "id", None) == name for t in node.targets):
return int(ast.literal_eval(node.value))
raise SystemExit(f"{source}: no {name} assignment; update scripts/ci/check_agents_md_size.py")
def _chain(rel: str, sizes: dict[str, int]) -> list[str]:
"""Every tracked AGENTS.md from the root down to ``rel``, root first."""
ancestors = [str(p / "AGENTS.md") if str(p) != "." else "AGENTS.md" for p in PurePosixPath(rel).parents]
return [p for p in reversed(ancestors) if p in sizes and p != rel] + [rel]
def main(repo: Path) -> int:
hint_cap = _int_constant(repo / "agent" / "subdirectory_hints.py", "_MAX_HINT_CHARS")
tracked = subprocess.run(
["git", "ls-files", "-z", "--", "AGENTS.md", "*/AGENTS.md"], cwd=repo, capture_output=True,
check=True, stdin=subprocess.DEVNULL, timeout=60,
).stdout.decode("utf-8").split("\0")
sizes = {rel: len((repo / rel).read_text(encoding="utf-8-sig")) for rel in filter(None, tracked)}
over = []
for rel, size in sorted(sizes.items()):
cap = ROOT_MAX_CHARS if rel == "AGENTS.md" else hint_cap
if size > cap:
over.append(f"{rel}: {size} chars > {cap} cap")
chain = _chain(rel, sizes)
total = sum(sizes[p] for p in chain)
if len(chain) > 1 and total > CHAIN_MAX_CHARS:
over.append(f"{' + '.join(chain)}: {total} chars > {CHAIN_MAX_CHARS} chain cap")
for line in over:
print(line)
if over:
print("Move long form into website/docs/developer-guide/ or a sibling doc instead of raising a cap.")
return 1 if over else 0
if __name__ == "__main__":
sys.exit(main(Path(sys.argv[1]) if len(sys.argv) > 1 else Path(__file__).resolve().parents[2]))