Skip to main content

newt_core/
agents.rs

1//! Project-instruction provider — loads `AGENTS.md` / `CLAUDE.md` from the
2//! workspace and injects them as a frozen system-prompt block.
3//!
4//! Modelled on [`crate::memory::SoulProvider`] (the soul provider), this is a
5//! [`MemoryProvider`] that contributes only a `system_prompt_block()` — it
6//! manages no conversation history.
7//!
8//! ## Resolution
9//!
10//! - If a `path_override` is configured (via `[agents] path` or
11//!   `--agents-file`), that target is used. A relative override is joined onto
12//!   the workspace dir; an absolute override is used as-is.
13//! - Otherwise the workspace root (`.`) is searched.
14//! - A **file** target is loaded as a single instruction block labelled by its
15//!   file name.
16//! - A **directory** target is scanned for `AGENTS.md` then `CLAUDE.md`; each
17//!   that exists is loaded (both if both exist).
18//!
19//! Files are read once at `initialize()` and **frozen** — mid-session changes
20//! don't rebuild the system prompt (preserves the KV/prefix cache). Each file
21//! is capped at [`AgentsProvider::MAX_FILE_BYTES`]; larger files are truncated
22//! with a `[... truncated]` marker. Empty or unreadable files are skipped
23//! gracefully and never error the session.
24
25use async_trait::async_trait;
26
27use crate::memory::{MemMessage, MemoryProvider, SessionContext};
28use crate::metrics::TurnMetrics;
29
30/// Loads project instructions (`AGENTS.md` / `CLAUDE.md`) and injects them as a
31/// frozen system-prompt block.
32pub struct AgentsProvider {
33    /// Whether the provider is active. When `false`, contributes nothing.
34    enabled: bool,
35    /// Optional explicit target: a directory to search or a specific file.
36    /// `None` → search the workspace root.
37    path_override: Option<String>,
38    /// Loaded instruction blocks as `(file_name, contents)`, populated at
39    /// `initialize()` and frozen.
40    loaded: Vec<(String, String)>,
41}
42
43impl AgentsProvider {
44    /// Cap each instruction file at this many bytes; larger files are truncated.
45    pub const MAX_FILE_BYTES: usize = 65_536;
46
47    /// The instruction file names searched inside a directory target, in order.
48    const DIR_CANDIDATES: [&'static str; 2] = ["AGENTS.md", "CLAUDE.md"];
49
50    /// Create a provider.
51    ///
52    /// `enabled` toggles the whole provider off; `path_override` is an optional
53    /// directory-or-file target resolved against the workspace at `initialize`.
54    pub fn new(enabled: bool, path_override: Option<String>) -> Self {
55        Self {
56            enabled,
57            path_override,
58            loaded: Vec::new(),
59        }
60    }
61
62    /// Read a file, trim it, skip if empty/unreadable, and truncate if oversize.
63    /// Returns the cleaned contents or `None`.
64    fn read_capped(path: &std::path::Path) -> Option<String> {
65        let raw = std::fs::read_to_string(path).ok()?;
66        let trimmed = raw.trim();
67        if trimmed.is_empty() {
68            return None;
69        }
70        if trimmed.len() > Self::MAX_FILE_BYTES {
71            // Truncate on a char boundary at or below the cap.
72            let mut end = Self::MAX_FILE_BYTES;
73            while end > 0 && !trimmed.is_char_boundary(end) {
74                end -= 1;
75            }
76            let mut out = trimmed[..end].to_string();
77            out.push_str("\n\n[... truncated]");
78            Some(out)
79        } else {
80            Some(trimmed.to_string())
81        }
82    }
83
84    /// Resolve the search target against the workspace and load instruction
85    /// files. Called from `initialize`.
86    pub fn load(&mut self, workspace: &str) {
87        self.loaded.clear();
88        if !self.enabled {
89            return;
90        }
91
92        let target = match &self.path_override {
93            Some(p) => {
94                let p = std::path::Path::new(p);
95                if p.is_absolute() {
96                    p.to_path_buf()
97                } else {
98                    std::path::Path::new(workspace).join(p)
99                }
100            }
101            None => std::path::PathBuf::from(workspace),
102        };
103
104        if target.is_file() {
105            let name = target
106                .file_name()
107                .map(|n| n.to_string_lossy().into_owned())
108                .unwrap_or_else(|| "instructions".to_string());
109            if let Some(text) = Self::read_capped(&target) {
110                self.loaded.push((name, text));
111            }
112        } else if target.is_dir() {
113            for candidate in Self::DIR_CANDIDATES {
114                let path = target.join(candidate);
115                if let Some(text) = Self::read_capped(&path) {
116                    self.loaded.push((candidate.to_string(), text));
117                }
118            }
119        }
120    }
121}
122
123#[async_trait]
124impl MemoryProvider for AgentsProvider {
125    fn name(&self) -> &str {
126        "agents"
127    }
128
129    async fn initialize(&mut self, ctx: &SessionContext) -> anyhow::Result<()> {
130        self.load(&ctx.workspace);
131        tracing::info!(files = self.loaded.len(), "project instructions loaded");
132        Ok(())
133    }
134
135    fn system_prompt_block(&self) -> Option<String> {
136        if self.loaded.is_empty() {
137            return None;
138        }
139        let mut block = String::from(
140            "# Project instructions\n\n\
141             Project-specific instructions found in the workspace. Follow them \
142             unless they conflict with a direct user request.",
143        );
144        for (name, contents) in &self.loaded {
145            block.push_str("\n\n## ");
146            block.push_str(name);
147            block.push('\n');
148            block.push_str(contents);
149        }
150        Some(block)
151    }
152
153    fn build_messages(&self, _system_prompt: &str, _new_task: &str) -> Vec<MemMessage> {
154        // System-prompt-only provider; history is managed elsewhere.
155        Vec::new()
156    }
157
158    async fn sync_turn(&mut self, _user: &str, _assistant: &str, _metrics: &TurnMetrics) {}
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    fn ctx(workspace: &str) -> SessionContext {
166        SessionContext {
167            workspace: workspace.to_string(),
168            session_id: "s".into(),
169        }
170    }
171
172    #[tokio::test]
173    async fn loads_agents_md_only() {
174        let dir = tempfile::tempdir().unwrap();
175        std::fs::write(dir.path().join("AGENTS.md"), "Use 4 spaces.").unwrap();
176
177        let mut p = AgentsProvider::new(true, None);
178        p.initialize(&ctx(dir.path().to_str().unwrap()))
179            .await
180            .unwrap();
181        let block = p.system_prompt_block().unwrap();
182        assert!(block.contains("# Project instructions"));
183        assert!(block.contains("## AGENTS.md"));
184        assert!(block.contains("Use 4 spaces."));
185        assert!(!block.contains("## CLAUDE.md"));
186    }
187
188    #[tokio::test]
189    async fn loads_claude_md_only() {
190        let dir = tempfile::tempdir().unwrap();
191        std::fs::write(dir.path().join("CLAUDE.md"), "Never push to main.").unwrap();
192
193        let mut p = AgentsProvider::new(true, None);
194        p.initialize(&ctx(dir.path().to_str().unwrap()))
195            .await
196            .unwrap();
197        let block = p.system_prompt_block().unwrap();
198        assert!(block.contains("## CLAUDE.md"));
199        assert!(block.contains("Never push to main."));
200        assert!(!block.contains("## AGENTS.md"));
201    }
202
203    #[tokio::test]
204    async fn loads_both_in_order() {
205        let dir = tempfile::tempdir().unwrap();
206        std::fs::write(dir.path().join("AGENTS.md"), "Agents content.").unwrap();
207        std::fs::write(dir.path().join("CLAUDE.md"), "Claude content.").unwrap();
208
209        let mut p = AgentsProvider::new(true, None);
210        p.initialize(&ctx(dir.path().to_str().unwrap()))
211            .await
212            .unwrap();
213        let block = p.system_prompt_block().unwrap();
214        let agents_at = block.find("## AGENTS.md").unwrap();
215        let claude_at = block.find("## CLAUDE.md").unwrap();
216        assert!(agents_at < claude_at, "AGENTS.md should come first");
217        assert!(block.contains("Agents content."));
218        assert!(block.contains("Claude content."));
219    }
220
221    #[tokio::test]
222    async fn explicit_file_override_uses_real_filename() {
223        let dir = tempfile::tempdir().unwrap();
224        let file = dir.path().join("INSTRUCTIONS.md");
225        std::fs::write(&file, "Explicit file body.").unwrap();
226
227        let mut p = AgentsProvider::new(true, Some(file.to_string_lossy().into_owned()));
228        // Workspace is unrelated; the absolute override wins.
229        p.initialize(&ctx("/nonexistent")).await.unwrap();
230        let block = p.system_prompt_block().unwrap();
231        assert!(block.contains("## INSTRUCTIONS.md"));
232        assert!(block.contains("Explicit file body."));
233    }
234
235    #[tokio::test]
236    async fn explicit_dir_override_searches_inside() {
237        let ws = tempfile::tempdir().unwrap();
238        let sub = ws.path().join("subdir");
239        std::fs::create_dir_all(&sub).unwrap();
240        std::fs::write(sub.join("AGENTS.md"), "Subdir agents.").unwrap();
241
242        let mut p = AgentsProvider::new(true, Some(sub.to_string_lossy().into_owned()));
243        p.initialize(&ctx(ws.path().to_str().unwrap()))
244            .await
245            .unwrap();
246        let block = p.system_prompt_block().unwrap();
247        assert!(block.contains("## AGENTS.md"));
248        assert!(block.contains("Subdir agents."));
249    }
250
251    #[tokio::test]
252    async fn disabled_produces_no_block() {
253        let dir = tempfile::tempdir().unwrap();
254        std::fs::write(dir.path().join("AGENTS.md"), "ignored").unwrap();
255
256        let mut p = AgentsProvider::new(false, None);
257        p.initialize(&ctx(dir.path().to_str().unwrap()))
258            .await
259            .unwrap();
260        assert!(p.system_prompt_block().is_none());
261    }
262
263    #[tokio::test]
264    async fn missing_and_empty_produce_no_block() {
265        // Missing: empty workspace dir.
266        let dir = tempfile::tempdir().unwrap();
267        let mut p = AgentsProvider::new(true, None);
268        p.initialize(&ctx(dir.path().to_str().unwrap()))
269            .await
270            .unwrap();
271        assert!(p.system_prompt_block().is_none());
272
273        // Empty (whitespace-only) AGENTS.md is skipped.
274        let dir2 = tempfile::tempdir().unwrap();
275        std::fs::write(dir2.path().join("AGENTS.md"), "   \n\t  ").unwrap();
276        let mut p2 = AgentsProvider::new(true, None);
277        p2.initialize(&ctx(dir2.path().to_str().unwrap()))
278            .await
279            .unwrap();
280        assert!(p2.system_prompt_block().is_none());
281    }
282
283    #[tokio::test]
284    async fn oversize_file_is_truncated() {
285        let dir = tempfile::tempdir().unwrap();
286        let big = "a".repeat(AgentsProvider::MAX_FILE_BYTES + 5_000);
287        std::fs::write(dir.path().join("AGENTS.md"), &big).unwrap();
288
289        let mut p = AgentsProvider::new(true, None);
290        p.initialize(&ctx(dir.path().to_str().unwrap()))
291            .await
292            .unwrap();
293        let block = p.system_prompt_block().unwrap();
294        assert!(block.contains("[... truncated]"));
295        // The contents portion should not carry the full oversize payload.
296        let (_, contents) = &p.loaded[0];
297        assert!(contents.len() < big.len());
298        assert!(contents.ends_with("[... truncated]"));
299    }
300
301    #[tokio::test]
302    async fn relative_override_joined_to_workspace() {
303        let ws = tempfile::tempdir().unwrap();
304        let docs = ws.path().join("docs");
305        std::fs::create_dir_all(&docs).unwrap();
306        std::fs::write(docs.join("CLAUDE.md"), "Docs claude.").unwrap();
307
308        // Relative override "docs" is joined onto the workspace.
309        let mut p = AgentsProvider::new(true, Some("docs".to_string()));
310        p.initialize(&ctx(ws.path().to_str().unwrap()))
311            .await
312            .unwrap();
313        let block = p.system_prompt_block().unwrap();
314        assert!(block.contains("## CLAUDE.md"));
315        assert!(block.contains("Docs claude."));
316    }
317
318    #[tokio::test]
319    async fn absolute_override_honored() {
320        let other = tempfile::tempdir().unwrap();
321        std::fs::write(other.path().join("AGENTS.md"), "Absolute target.").unwrap();
322
323        // Absolute override ignores the (unrelated) workspace.
324        let mut p = AgentsProvider::new(true, Some(other.path().to_string_lossy().into_owned()));
325        p.initialize(&ctx("/some/other/workspace")).await.unwrap();
326        let block = p.system_prompt_block().unwrap();
327        assert!(block.contains("Absolute target."));
328    }
329
330    #[tokio::test]
331    async fn build_messages_is_empty() {
332        let p = AgentsProvider::new(true, None);
333        assert!(p.build_messages("sys", "task").is_empty());
334    }
335}