#!/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]))