1use async_trait::async_trait;
26
27use crate::memory::{MemMessage, MemoryProvider, SessionContext};
28use crate::metrics::TurnMetrics;
29
30pub struct AgentsProvider {
33 enabled: bool,
35 path_override: Option<String>,
38 loaded: Vec<(String, String)>,
41}
42
43impl AgentsProvider {
44 pub const MAX_FILE_BYTES: usize = 65_536;
46
47 const DIR_CANDIDATES: [&'static str; 2] = ["AGENTS.md", "CLAUDE.md"];
49
50 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 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 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 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 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 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 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 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 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 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 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}