Skip to main content

speccoder_runtime/
instructions.rs

1//! The repository's instructions for agents: the committed
2//! `AGENTS.md` files that apply to a task, as agents.md defines them —
3//! "the closest AGENTS.md to the edited file wins".
4//!
5//! Per directory from the repository root down to each directory the task
6//! names (else down to the project directory), the first of the configured
7//! file names that exists; root first, nearest last, so the nearest has the
8//! last word. At most [`BUDGET`] bytes in all: the nearest files are kept,
9//! farther ones cut, and every cut is said. The text is the repository's,
10//! never the person's: callers label it as worktree content.
11
12use std::collections::BTreeSet;
13use std::path::{Path, PathBuf};
14
15use serde::{Deserialize, Serialize};
16
17/// Bytes the instructions may take in a prompt (Codex's default too).
18pub const BUDGET: usize = 32 * 1024;
19
20/// One file given to the model.
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22pub struct Instruction {
23    /// Repository-relative path, `/`-separated.
24    pub path: String,
25    /// sha256 of the file as committed.
26    pub sha256: String,
27    /// Its size.
28    pub bytes: usize,
29    /// Cut to fit the budget.
30    pub cut: bool,
31    /// What the model is given (cut files end in a marker).
32    pub text: String,
33}
34
35/// The directories from the repository root down to `leaf` (both included),
36/// as repository-relative paths (`""` is the root).
37fn chain(leaf: &Path) -> Vec<PathBuf> {
38    let mut out = vec![PathBuf::new()];
39    let mut at = PathBuf::new();
40    for c in leaf.components() {
41        if let std::path::Component::Normal(part) = c {
42            at.push(part);
43            out.push(at.clone());
44        }
45    }
46    out
47}
48
49/// The instructions for a task in `project` (inside the repository at
50/// `repo`) that names `named` (project-relative paths), reading the first
51/// of `names` per directory.
52#[must_use]
53pub fn collect(
54    repo: &Path,
55    project: &Path,
56    named: &[String],
57    names: &[String],
58) -> Vec<Instruction> {
59    if names.is_empty() {
60        return Vec::new();
61    }
62    let Ok(repo) = std::fs::canonicalize(repo) else {
63        return Vec::new();
64    };
65    let project = std::fs::canonicalize(project).unwrap_or_else(|_| repo.clone());
66    let rel = project
67        .strip_prefix(&repo)
68        .map(Path::to_path_buf)
69        .unwrap_or_default();
70    let leaves: Vec<PathBuf> = if named.is_empty() {
71        vec![rel.clone()]
72    } else {
73        named
74            .iter()
75            .map(|p| {
76                rel.join(p)
77                    .parent()
78                    .map(Path::to_path_buf)
79                    .unwrap_or_default()
80            })
81            .collect()
82    };
83    let mut dirs: BTreeSet<(usize, PathBuf)> = BTreeSet::new();
84    for leaf in &leaves {
85        for d in chain(leaf) {
86            dirs.insert((d.components().count(), d));
87        }
88    }
89    let mut found: Vec<(usize, String, String)> = Vec::new();
90    for (depth, dir) in &dirs {
91        for name in names {
92            let file = repo.join(dir).join(name);
93            // Inside the repository only: a symlink out of it is not read.
94            let Ok(real) = std::fs::canonicalize(&file) else {
95                continue;
96            };
97            if !real.starts_with(&repo) || !real.is_file() {
98                continue;
99            }
100            let Ok(text) = std::fs::read_to_string(&real) else {
101                continue; // not UTF-8
102            };
103            if text.trim().is_empty() {
104                continue;
105            }
106            let path = dir.join(name).to_string_lossy().replace('\\', "/");
107            found.push((*depth, path, text));
108            break;
109        }
110    }
111    fit(found)
112}
113
114/// Root first in the result; the budget is spent nearest first.
115fn fit(mut found: Vec<(usize, String, String)>) -> Vec<Instruction> {
116    found.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
117    let mut left = BUDGET;
118    let mut out: Vec<Instruction> = Vec::with_capacity(found.len());
119    for (_, path, text) in found.into_iter().rev() {
120        let sha256 = crate::integrity::sha256_hex(text.as_bytes());
121        let bytes = text.len();
122        let (text, cut) = if bytes <= left {
123            left -= bytes;
124            (text, false)
125        } else {
126            let mut end = left;
127            while end > 0 && !text.is_char_boundary(end) {
128                end -= 1;
129            }
130            left = 0;
131            (
132                format!(
133                    "{}\n\n[… cut: the repository's instructions are limited to {} KiB, and nearer files come first]\n",
134                    &text[..end],
135                    BUDGET / 1024
136                ),
137                true,
138            )
139        };
140        out.push(Instruction {
141            path,
142            sha256,
143            bytes,
144            cut,
145            text,
146        });
147    }
148    out.reverse();
149    out
150}
151
152/// The prompt section: each file under its path, with what it ranks below.
153#[must_use]
154pub fn section(instructions: &[Instruction]) -> Option<String> {
155    if instructions.is_empty() {
156        return None;
157    }
158    let mut out = String::from(
159        "# Repository instructions\n\nThe repository's own conventions for agents (AGENTS.md), root first, the nearest last — a nearer file overrides a farther one. Follow them; where they conflict with the task, the spec, the checks or the rules above, those win.\n",
160    );
161    for i in instructions {
162        out.push_str(&format!("\n## {}\n\n{}\n", i.path, i.text.trim_end()));
163    }
164    Some(out)
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    fn tree(files: &[(&str, &str)]) -> tempfile::TempDir {
172        let dir = tempfile::tempdir().unwrap();
173        for (p, text) in files {
174            let f = dir.path().join(p);
175            std::fs::create_dir_all(f.parent().unwrap()).unwrap();
176            std::fs::write(f, text).unwrap();
177        }
178        dir
179    }
180
181    fn paths(v: &[Instruction]) -> Vec<&str> {
182        v.iter().map(|i| i.path.as_str()).collect()
183    }
184
185    const AGENTS: &[&str] = &[];
186
187    #[test]
188    fn the_nearest_file_comes_last_and_others_are_not_read() {
189        let t = tree(&[
190            ("AGENTS.md", "root"),
191            ("services/api/AGENTS.md", "api"),
192            ("web/AGENTS.md", "web"),
193        ]);
194        let names = vec!["AGENTS.md".to_owned()];
195        let got = collect(
196            t.path(),
197            t.path(),
198            &["services/api/src/handler.rs".to_owned()],
199            &names,
200        );
201        assert_eq!(paths(&got), ["AGENTS.md", "services/api/AGENTS.md"]);
202        let none = collect(t.path(), t.path(), &[], &names);
203        assert_eq!(paths(&none), ["AGENTS.md"]);
204        let both = collect(
205            t.path(),
206            t.path(),
207            &["web/x.ts".to_owned(), "services/api/y.rs".to_owned()],
208            &names,
209        );
210        assert_eq!(
211            paths(&both),
212            ["AGENTS.md", "web/AGENTS.md", "services/api/AGENTS.md"],
213            "by depth"
214        );
215        assert!(
216            collect(
217                t.path(),
218                t.path(),
219                &[],
220                &AGENTS.iter().map(|s| (*s).to_owned()).collect::<Vec<_>>()
221            )
222            .is_empty()
223        );
224    }
225
226    #[test]
227    fn fallback_names_are_read_one_per_directory() {
228        let t = tree(&[
229            ("CLAUDE.md", "claude"),
230            ("pkg/AGENTS.md", "a"),
231            ("pkg/CLAUDE.md", "c"),
232        ]);
233        let names = vec!["AGENTS.md".to_owned(), "CLAUDE.md".to_owned()];
234        let got = collect(t.path(), t.path(), &["pkg/lib.rs".to_owned()], &names);
235        assert_eq!(paths(&got), ["CLAUDE.md", "pkg/AGENTS.md"]);
236    }
237
238    #[test]
239    fn a_project_in_a_subdirectory_gets_the_root_and_its_own() {
240        let t = tree(&[
241            ("AGENTS.md", "root"),
242            ("app/AGENTS.md", "app"),
243            ("app/specs/x", ""),
244        ]);
245        let got = collect(
246            t.path(),
247            &t.path().join("app"),
248            &[],
249            &["AGENTS.md".to_owned()],
250        );
251        assert_eq!(paths(&got), ["AGENTS.md", "app/AGENTS.md"]);
252    }
253
254    #[test]
255    fn the_budget_keeps_the_nearest_and_says_what_was_cut() {
256        let big = "x".repeat(BUDGET);
257        let t = tree(&[("AGENTS.md", &big), ("a/AGENTS.md", "near")]);
258        let got = collect(
259            t.path(),
260            t.path(),
261            &["a/f.rs".to_owned()],
262            &["AGENTS.md".to_owned()],
263        );
264        assert_eq!(paths(&got), ["AGENTS.md", "a/AGENTS.md"]);
265        assert!(!got[1].cut && got[1].text == "near");
266        assert!(got[0].cut && got[0].text.contains("cut:"));
267        assert_eq!(got[0].bytes, BUDGET);
268        let total: usize = got.iter().map(|i| i.text.len()).sum();
269        assert!(total <= BUDGET + 200, "{total}");
270    }
271
272    #[test]
273    fn symlinks_out_of_the_repository_and_binary_files_are_not_read() {
274        let outside = tree(&[("secret.md", "outside")]);
275        let t = tree(&[("pkg/AGENTS.md", "")]);
276        std::os::unix::fs::symlink(outside.path().join("secret.md"), t.path().join("AGENTS.md"))
277            .unwrap();
278        std::fs::write(t.path().join("pkg/AGENTS.md"), [0xff, 0xfe, 0x00]).unwrap();
279        assert!(
280            collect(
281                t.path(),
282                t.path(),
283                &["pkg/x".to_owned()],
284                &["AGENTS.md".to_owned()]
285            )
286            .is_empty()
287        );
288    }
289
290    #[test]
291    fn the_section_ranks_the_instructions_below_the_task() {
292        let i = Instruction {
293            path: "AGENTS.md".to_owned(),
294            sha256: String::new(),
295            bytes: 3,
296            cut: false,
297            text: "use nextest".to_owned(),
298        };
299        let s = section(&[i]).unwrap();
300        assert!(s.contains("## AGENTS.md\n\nuse nextest") && s.contains("those win"));
301        assert!(section(&[]).is_none());
302    }
303}