Skip to main content

harness/openai_compatible/
instructions.rs

1//! Instruction / rules files — `AGENTS.md`, `CLAUDE.md` — loaded into the
2//! system prompt. Unlike skills (a lazy catalog), these are injected **in
3//! full**: they're the conventions the model should always follow.
4//!
5//! Two rules keep that affordable.
6//!
7//! **First match wins**, per location. A directory holding both an `AGENTS.md`
8//! and a `CLAUDE.md` contributes one of them, not both — the two normally say
9//! the same thing, and stacking them charges twice for it. `AGENTS.md` is the
10//! cross-tool standard, so it comes first; `CLAUDE.md` is the fallback.
11//!
12//! **A running byte budget** across every file, so a large global file cannot
13//! crowd out the project's own rules and no chain can grow without bound. The
14//! budget is spent nearest-first for that reason.
15
16use std::collections::HashSet;
17use std::path::{Path, PathBuf};
18
19/// Candidate filenames in a directory, most preferred first. `AGENTS.md` is the
20/// standard shared across coding agents; `CLAUDE.md` predates it and is still
21/// what many repositories carry. Case variants appear because
22/// case-sensitive filesystems do not forgive them.
23const FILENAMES: &[&str] = &["AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"];
24
25/// Default cap on the instruction text taken from disk, matching Codex's
26/// `project_doc_max_bytes`. Large enough for real conventions, small enough
27/// that a runaway file cannot fill a local model's context.
28pub(crate) const DEFAULT_MAX_BYTES: usize = 32 * 1024;
29
30/// Where to look for instructions beyond the working tree.
31///
32/// `global` is empty by default. A library that reads a user's home directory
33/// on its own initiative is guessing, and the guess is wrong the moment the
34/// host has its own convention — so the host names the files it wants and
35/// nothing outside the working tree is read until it does.
36/// [`InstructionSources::discover_global`] supplies the usual suspects for a
37/// host that wants them.
38#[derive(Clone, Debug)]
39pub struct InstructionSources {
40    /// Global candidates, most preferred first. The first one that exists is
41    /// used; the rest are ignored.
42    pub global: Vec<PathBuf>,
43    /// Cap on the instruction text taken from disk. Files are read
44    /// nearest-first and the remainder is truncated once the budget runs out.
45    pub max_bytes: usize,
46}
47
48impl Default for InstructionSources {
49    fn default() -> Self {
50        Self { global: Vec::new(), max_bytes: DEFAULT_MAX_BYTES }
51    }
52}
53
54impl InstructionSources {
55    /// The conventional global instruction files, most preferred first:
56    /// `~/.config/AGENTS.md`, then the two agents that ship their own
57    /// (`~/.codex/AGENTS.md`, `~/.claude/CLAUDE.md`).
58    ///
59    /// Opt in by calling this — a host that wants a user's existing global
60    /// conventions honoured, wherever that user already keeps them.
61    pub fn discover_global() -> Self {
62        let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else {
63            return Self::default();
64        };
65        Self {
66            global: vec![
67                home.join(".config/AGENTS.md"),
68                home.join(".codex/AGENTS.md"),
69                home.join(".claude/CLAUDE.md"),
70            ],
71            ..Self::default()
72        }
73    }
74}
75
76/// The concatenated instruction text visible from `cwd`, or `None` if there is
77/// nothing to load.
78///
79/// Ordering is least- to most-specific, so a nearer file wins on conflict by
80/// being read last. The byte budget is spent in the opposite direction —
81/// nearest first — so a project's own rules survive a large global file.
82pub(crate) fn gather(cwd: &Path, sources: &InstructionSources) -> Option<String> {
83    let mut sections = Vec::new();
84    let mut remaining = sources.max_bytes;
85
86    for path in resolve(cwd, sources).into_iter().rev() {
87        if remaining == 0 {
88            break;
89        }
90        let Ok(content) = std::fs::read_to_string(&path) else { continue };
91        let trimmed = content.trim();
92        if trimmed.is_empty() {
93            continue;
94        }
95        sections.push(take_within(trimmed, &mut remaining));
96    }
97
98    if sections.is_empty() {
99        return None;
100    }
101    sections.reverse(); // back to least- → most-specific
102    Some(sections.join("\n\n"))
103}
104
105/// Up to `remaining` bytes of `text`, on a character boundary, decrementing the
106/// budget by what was taken.
107fn take_within(text: &str, remaining: &mut usize) -> String {
108    if text.len() <= *remaining {
109        *remaining -= text.len();
110        return text.to_owned();
111    }
112    // Walk back to a boundary. Index 0 is always one, so this terminates
113    // without a separate `end > 0` guard — which was redundant, and being
114    // redundant made it untestable: flipping it changed nothing observable.
115    let mut end = *remaining;
116    while !text.is_char_boundary(end) {
117        end -= 1;
118    }
119    *remaining = 0;
120    text[..end].to_owned()
121}
122
123/// Existing instruction files, least- to most-specific: the first global
124/// candidate that exists, then one file per directory from the project root
125/// down to `cwd`.
126fn resolve(cwd: &Path, sources: &InstructionSources) -> Vec<PathBuf> {
127    let mut files = Vec::new();
128    let mut seen = HashSet::new();
129
130    if let Some(global) = sources.global.iter().find(|path| path.is_file()) {
131        files.push(global.clone());
132        seen.insert(global.clone());
133    }
134    for dir in project_dirs(cwd) {
135        if let Some(found) = first_in(&dir) {
136            if seen.insert(found.clone()) {
137                files.push(found);
138            }
139        }
140    }
141    files
142}
143
144/// The preferred instruction file present in `dir`, if any.
145fn first_in(dir: &Path) -> Option<PathBuf> {
146    FILENAMES.iter().map(|name| dir.join(name)).find(|path| path.is_file())
147}
148
149/// The cwd's directory chain from the git root down to the cwd (so nearer dirs
150/// come last). If no `.git` is found walking up, just the cwd — we don't scan
151/// the whole filesystem.
152fn project_dirs(cwd: &Path) -> Vec<PathBuf> {
153    let mut chain = Vec::new();
154    let mut cur = cwd;
155    loop {
156        chain.push(cur.to_path_buf());
157        if cur.join(".git").exists() {
158            chain.reverse(); // root → cwd
159            return chain;
160        }
161        match cur.parent() {
162            Some(parent) => cur = parent,
163            None => return vec![cwd.to_path_buf()], // no git root → cwd only
164        }
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use proptest::prelude::*;
172
173    proptest! {
174        /// The budget exists to keep instruction files from crowding out the
175        /// conversation, and it is spent in bytes over text a user wrote — so
176        /// multi-byte characters are the normal case, not the edge one.
177        ///
178        /// Being a *prefix* is the property that matters and the examples never
179        /// asserted: within budget and on a boundary are both satisfied by
180        /// returning nothing, or by returning the wrong slice of the file.
181        #[test]
182        fn what_is_taken_is_a_prefix_of_what_was_offered(
183            text in "\\PC{0,64}",
184            budget in 0usize..192,
185        ) {
186            let mut remaining = budget;
187            let taken = take_within(&text, &mut remaining);
188
189            prop_assert!(text.starts_with(&taken), "{taken:?} is not a prefix of {text:?}");
190            prop_assert!(taken.len() <= budget, "over budget");
191            prop_assert!(
192                budget - remaining >= taken.len(),
193                "charged less than it took",
194            );
195        }
196
197        /// A file that fits is never trimmed. Silently dropping the tail of an
198        /// instruction file that had room is the failure a byte budget invites.
199        #[test]
200        fn text_that_fits_is_taken_whole(text in "\\PC{0,64}", slack in 0usize..32) {
201            let budget = text.len() + slack;
202            let mut remaining = budget;
203            prop_assert_eq!(take_within(&text, &mut remaining), text.clone());
204            prop_assert_eq!(remaining, slack, "charged for more than the text");
205        }
206
207        /// When it must trim, it takes as much as the budget allows: the walk
208        /// back to a character boundary gives up at most the last character,
209        /// never more. Retreating further would quietly shrink every budget.
210        #[test]
211        fn trimming_gives_up_no_more_than_one_character(
212            text in "\\PC{1,64}",
213            budget in 0usize..192,
214        ) {
215            prop_assume!(text.len() > budget);
216            let mut remaining = budget;
217            let taken = take_within(&text, &mut remaining);
218            let widest = text.chars().map(char::len_utf8).max().unwrap_or(1);
219            prop_assert!(
220                taken.len() + widest > budget,
221                "took {} of a {budget} byte budget",
222                taken.len(),
223            );
224        }
225    }
226
227    fn scratch(tag: &str) -> PathBuf {
228        let dir = std::env::temp_dir().join(format!("hl-instr-{tag}-{}", std::process::id()));
229        let _ = std::fs::remove_dir_all(&dir);
230        std::fs::create_dir_all(&dir).unwrap();
231        dir
232    }
233
234    /// Project files only — the default reads nothing outside the working tree.
235    fn project_only() -> InstructionSources {
236        InstructionSources::default()
237    }
238
239    #[test]
240    fn gathers_project_files_root_to_cwd() {
241        let root = scratch("proj");
242        std::fs::create_dir_all(root.join(".git")).unwrap();
243        std::fs::write(root.join("AGENTS.md"), "root rules").unwrap();
244        let sub = root.join("crate-a");
245        std::fs::create_dir_all(&sub).unwrap();
246        std::fs::write(sub.join("CLAUDE.md"), "crate rules").unwrap();
247
248        let text = gather(&sub, &project_only()).expect("found instructions");
249        assert!(text.contains("root rules") && text.contains("crate rules"));
250        // Nearer file comes after the root's (more specific wins on conflict).
251        assert!(text.find("root rules") < text.find("crate rules"));
252        let _ = std::fs::remove_dir_all(&root);
253    }
254
255    #[test]
256    fn a_directory_contributes_one_file_not_both() {
257        // The common case for a repo that supports several agents: the two
258        // files say the same thing, and reading both charged twice for it.
259        let root = scratch("both");
260        std::fs::create_dir_all(root.join(".git")).unwrap();
261        std::fs::write(root.join("AGENTS.md"), "the standard").unwrap();
262        std::fs::write(root.join("CLAUDE.md"), "the fallback").unwrap();
263
264        let text = gather(&root, &project_only()).expect("found instructions");
265        assert!(text.contains("the standard"), "AGENTS.md is preferred: {text}");
266        assert!(!text.contains("the fallback"), "CLAUDE.md must not stack: {text}");
267        let _ = std::fs::remove_dir_all(&root);
268    }
269
270    #[test]
271    fn discover_global_names_the_conventional_files() {
272        // The opt-in path for a host that wants a user's existing global
273        // conventions. Untested, the whole function could return an empty list
274        // and a host that opted in would silently get nothing — which looks
275        // exactly like a user who has no global instructions.
276        let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else {
277            return; // no HOME: the function documents itself as empty
278        };
279        let sources = InstructionSources::discover_global();
280
281        assert_eq!(
282            sources.global,
283            vec![
284                home.join(".config/AGENTS.md"),
285                home.join(".codex/AGENTS.md"),
286                home.join(".claude/CLAUDE.md"),
287            ],
288            "order is precedence: our own convention first, then the agents that ship their own"
289        );
290        assert_eq!(sources.max_bytes, DEFAULT_MAX_BYTES, "opting in must not change the budget");
291    }
292
293    #[test]
294    fn the_default_budget_is_32_kib() {
295        // A budget is only a budget at a specific size. Left unasserted, an
296        // arithmetic slip turns 32 KiB into 1 KiB and quietly truncates almost
297        // every real instruction file.
298        assert_eq!(DEFAULT_MAX_BYTES, 32 * 1024);
299        assert_eq!(InstructionSources::default().max_bytes, DEFAULT_MAX_BYTES);
300    }
301
302    #[test]
303    fn a_file_exactly_on_budget_is_kept_whole() {
304        // The boundary between "fits" and "truncate". `<=` relaxed to `<` only
305        // differs here, and only by one byte of a file that should be intact.
306        let root = scratch("exact");
307        std::fs::create_dir_all(root.join(".git")).unwrap();
308        std::fs::write(root.join("AGENTS.md"), "x".repeat(64)).unwrap();
309
310        let sources = InstructionSources { global: Vec::new(), max_bytes: 64 };
311        let text = gather(&root, &sources).expect("instructions");
312        assert_eq!(text.len(), 64, "a file the size of the budget is not truncated");
313        let _ = std::fs::remove_dir_all(&root);
314    }
315
316    #[test]
317    fn nothing_outside_the_working_tree_is_read_by_default() {
318        let home = scratch("home");
319        std::fs::write(home.join("global.md"), "global rules").unwrap();
320        let root = scratch("no-global");
321        std::fs::create_dir_all(root.join(".git")).unwrap();
322        std::fs::write(root.join("AGENTS.md"), "project rules").unwrap();
323
324        let text = gather(&root, &InstructionSources::default()).expect("instructions");
325        assert!(!text.contains("global rules"), "default must not reach outside: {text}");
326
327        // ...and does read it once the host asks.
328        let opted_in = InstructionSources {
329            global: vec![home.join("global.md")],
330            ..Default::default()
331        };
332        let text = gather(&root, &opted_in).expect("instructions");
333        assert!(text.contains("global rules"), "host opt-in must be honoured: {text}");
334        assert!(text.find("global rules") < text.find("project rules"), "global is least specific");
335        let _ = std::fs::remove_dir_all(&home);
336        let _ = std::fs::remove_dir_all(&root);
337    }
338
339    #[test]
340    fn the_first_global_candidate_that_exists_wins() {
341        let home = scratch("globals");
342        std::fs::write(home.join("second.md"), "second choice").unwrap();
343        std::fs::write(home.join("third.md"), "third choice").unwrap();
344        let root = scratch("g-proj");
345        std::fs::create_dir_all(root.join(".git")).unwrap();
346
347        let sources = InstructionSources {
348            global: vec![home.join("first.md"), home.join("second.md"), home.join("third.md")],
349            ..Default::default()
350        };
351        let text = gather(&root, &sources).expect("instructions");
352        assert!(text.contains("second choice"), "first existing candidate: {text}");
353        assert!(!text.contains("third choice"), "later candidates are ignored: {text}");
354        let _ = std::fs::remove_dir_all(&home);
355        let _ = std::fs::remove_dir_all(&root);
356    }
357
358    #[test]
359    fn the_budget_truncates_and_spends_it_on_the_nearest_file() {
360        // A large global file must not crowd out the project's own rules, so
361        // the budget is spent nearest-first even though output stays ordered
362        // least- to most-specific.
363        let home = scratch("budget-home");
364        std::fs::write(home.join("global.md"), "G".repeat(500)).unwrap();
365        let root = scratch("budget-proj");
366        std::fs::create_dir_all(root.join(".git")).unwrap();
367        std::fs::write(root.join("AGENTS.md"), "P".repeat(100)).unwrap();
368
369        let sources = InstructionSources { global: vec![home.join("global.md")], max_bytes: 300 };
370        let text = gather(&root, &sources).expect("instructions");
371
372        assert_eq!(text.matches('P').count(), 100, "the nearest file is kept whole: {}", text.len());
373        assert_eq!(text.matches('G').count(), 200, "the global file takes only what is left");
374        assert!(text.len() <= 300 + 2, "budget honoured, plus the joining separator");
375        let _ = std::fs::remove_dir_all(&home);
376        let _ = std::fs::remove_dir_all(&root);
377    }
378
379    #[test]
380    fn truncation_never_splits_a_character() {
381        let root = scratch("utf8");
382        std::fs::create_dir_all(root.join(".git")).unwrap();
383        std::fs::write(root.join("AGENTS.md"), "é".repeat(10)).unwrap(); // 2 bytes each
384
385        // An odd budget lands mid-character unless the cut is boundary-aware.
386        let sources = InstructionSources { global: Vec::new(), max_bytes: 5 };
387        let text = gather(&root, &sources).expect("instructions");
388        assert_eq!(text, "éé", "cut back to the boundary rather than panicking");
389        let _ = std::fs::remove_dir_all(&root);
390    }
391
392    #[test]
393    fn none_when_absent() {
394        let dir = scratch("empty");
395        std::fs::create_dir_all(dir.join(".git")).unwrap();
396        assert!(gather(&dir, &project_only()).is_none());
397        let _ = std::fs::remove_dir_all(&dir);
398    }
399}