"""The project's own notes on how to work in it — AGENTS.md, CLAUDE.md. A file in the root of the project directory, read once per reply and put in the system message. Everything about the shape of this module is copied from `index.py`, and for the same three reasons: * **`cached()` never does work.** `harness.context_variables` is synchronous and runs on the request path, so an SFTP round trip from there would hold a request open while somebody's box thought about it. The build happens in `generation._warm_project`, which is async and already doing network work. * **`ensure()` shares one build between concurrent callers**, via `_BUILDING` and `asyncio.shield`. * **Each name catches its own `ExecError`.** This is the ladder lesson from `index.py` arriving before the bug does: an `AGENTS.md` that cannot be read -- a permission, an SFTP-only account, a directory where a file was expected -- must not stop `CLAUDE.md` being tried. The contents are **untrusted**, and go into the *system* message of a chat that can run commands. Nothing here can fix that; what does is the wording of the `context.agent_instructions` fragment, which names where the file came from and bounds what it is allowed to do. Two things are done here: control characters are stripped, and backticks are neutralised so the file cannot close the fence it is put inside and start writing what looks like our own prose. """ from __future__ import annotations import asyncio import logging import posixpath import re import time from dataclasses import dataclass from lembas.services.agent.base import ExecError, Executor log = logging.getLogger(__name__) # In order. AGENTS.md first because it is the vendor-neutral convention a shared # repository is likeliest to carry; CLAUDE.md next because it is the one most # widely written in practice. Root only, no recursion: a per-directory # convention is a different feature with a different cost model. NAMES = ("AGENTS.md", "CLAUDE.md", "AGENT.md", ".agents.md") TTL = 300.0 MAX_CACHED = 64 # The default ceiling on what reaches the prompt. The admin setting wins. MAX_CHARS = 4000 _CONTROL = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]") @dataclass(frozen=True) class Instructions: """What was found in the project root, and where.""" filename: str = "" text: str = "" built_at: float = 0.0 @property def ok(self) -> bool: return bool(self.filename and self.text.strip()) def clean(raw: str) -> str: """Made safe to put inside a fenced block in a system message.""" text = _CONTROL.sub("", raw).replace("\r\n", "\n").replace("\r", "\n") # It must not be able to close our fence and carry on in what then reads as # our own voice. Replaced rather than escaped: this is a display of somebody # else's file, not a round trip. return text.replace("```", "'''") async def build(executor: Executor, budget: int = MAX_CHARS) -> Instructions: """Look for each name in turn, and stop at the first one that reads.""" for name in NAMES: try: # Four bytes a character is generous for UTF-8 prose and stops a # two-megabyte file being pulled across to be thrown away. raw = await executor.read_file(name, max_bytes=max(budget, 1) * 4) except ExecError: # Its own catch, per name. A rung that raises must not end the # ladder -- that bug has already been paid for once in index.py. continue except Exception: # noqa: BLE001 - a warm-up must never kill a reply log.debug("could not read %s", name, exc_info=True) continue text = clean(raw) if text.strip(): return Instructions(filename=name, text=text, built_at=time.monotonic()) return Instructions(built_at=time.monotonic()) # --- The cache --------------------------------------------------------------- # Keyed on the connection and the directory, exactly as the listing is: two # chats on one tree are looking at the same file. _CACHE: dict[tuple[str, str], Instructions] = {} _BUILDING: dict[tuple[str, str], asyncio.Task] = {} def cached(profile_id: str, project_dir: str) -> Instructions | None: """What is already known, or None. Never does any work. A miss is not "there is no file" -- it is "nobody has looked yet", and the fragment's `requires` turns both into the same thing: no section at all. """ found = _CACHE.get((profile_id, project_dir)) if found is None: return None if time.monotonic() - found.built_at > TTL: _CACHE.pop((profile_id, project_dir), None) return None return found async def ensure( executor: Executor, profile_id: str, project_dir: str, *, budget: int = MAX_CHARS, refresh: bool = False, ) -> Instructions: key = (profile_id, project_dir) if refresh: _CACHE.pop(key, None) elif (found := cached(profile_id, project_dir)) is not None: return found if (running := _BUILDING.get(key)) is not None: return await asyncio.shield(running) task = asyncio.create_task(build(executor, budget)) _BUILDING[key] = task try: found = await task finally: _BUILDING.pop(key, None) _CACHE[key] = found while len(_CACHE) > MAX_CACHED: _CACHE.pop(next(iter(_CACHE))) return found def is_instruction_file(path: str, project_dir: str) -> bool: """Whether a written path is the file this module caches. Resolved against the project directory rather than matched on the basename, so `./AGENTS.md`, `AGENTS.md` and `/work/AGENTS.md` are all it and `docs/AGENTS.md` is not -- root only, the same rule `build` follows. A basename match would drop the cache every time any subdirectory's own AGENTS.md was touched, which is a fetch nobody asked for. """ wanted = path.strip() if not wanted: return False if not posixpath.isabs(wanted) and project_dir: wanted = posixpath.join(project_dir, wanted) wanted = posixpath.normpath(wanted) return any( wanted == posixpath.normpath(posixpath.join(project_dir or "", name)) for name in NAMES ) def forget(profile_id: str, project_dir: str) -> None: """Drop it, because something just rewrote it. The one case the TTL cannot cover: this process changing the file it has just quoted. Unlike the directory listing, an *edit* counts here as much as a write -- the listing only cares that the file exists, this cares what is in it. """ _CACHE.pop((profile_id, project_dir), None) def clear() -> None: _CACHE.clear() def render(found: Instructions | None, budget: int) -> str: """The text, within the budget, cut at a line boundary.""" if found is None or not found.ok or budget <= 0: return "" text = found.text.strip() if len(text) <= budget: return text cut = text[:budget] at = cut.rfind("\n") if at > budget // 2: cut = cut[:at] return f"{cut.rstrip()}\n… (truncated)" __all__ = [ "MAX_CHARS", "NAMES", "TTL", "Instructions", "build", "cached", "clean", "clear", "ensure", "forget", "is_instruction_file", "render", ]