Skip to main content

microclaw_engine/
subdirectory_hints.rs

1//! Lazily inject subdirectory `AGENTS.md` / `CLAUDE.md` / `.cursorrules`
2//! context when the agent starts working in a new area of the codebase.
3//!
4//! At session start, microclaw loads the project-root context once into the
5//! system prompt. But the model only sees subdirectory-specific guidance if
6//! the user mentioned it. This tracker watches tool calls (`read_file`,
7//! `edit_file`, `glob`, `grep`, `bash`'s `cwd` etc.), notices when the agent
8//! reaches into a new directory, and appends that subtree's nearest hint file
9//! to the tool result.
10//!
11//! Hermes reference: `agent/subdirectory_hints.py:SubdirectoryHintTracker`.
12//! Inspired upstream by Block/goose.
13//!
14//! The tracker remembers which directories it has already emitted for, so a
15//! single hint file is only injected once per chat turn. The tracker is
16//! constructed per agent loop.
17
18use std::collections::HashSet;
19use std::path::{Path, PathBuf};
20
21const HINT_FILENAMES: &[&str] = &[
22    "AGENTS.md",
23    "agents.md",
24    "CLAUDE.md",
25    "claude.md",
26    ".cursorrules",
27];
28
29/// Tool argument keys that typically carry a filesystem path.
30const PATH_ARG_KEYS: &[&str] = &["path", "file_path", "workdir", "cwd"];
31
32/// Tools whose primary argument is a shell command — we extract path-shaped
33/// tokens from it.
34const COMMAND_TOOLS: &[&str] = &["bash"];
35
36const MAX_HINT_CHARS: usize = 8_000;
37const MAX_ANCESTOR_WALK: usize = 5;
38
39pub struct SubdirectoryHintTracker {
40    working_dir: PathBuf,
41    loaded_dirs: HashSet<PathBuf>,
42}
43
44impl SubdirectoryHintTracker {
45    pub fn new(working_dir: PathBuf) -> Self {
46        let working_dir = working_dir.canonicalize().unwrap_or(working_dir);
47        let mut loaded_dirs = HashSet::new();
48        // The root is loaded as part of the system prompt — don't re-inject it.
49        loaded_dirs.insert(working_dir.clone());
50        Self {
51            working_dir,
52            loaded_dirs,
53        }
54    }
55
56    /// Called after a tool executes. Returns formatted hint text to append
57    /// to the tool's result content, or `None` if no new hint applies.
58    pub fn check_tool_call(&mut self, tool_name: &str, args: &serde_json::Value) -> Option<String> {
59        let dirs = self.extract_directories(tool_name, args);
60        if dirs.is_empty() {
61            return None;
62        }
63        let mut found = Vec::new();
64        for d in dirs {
65            if let Some(hint) = self.load_hints_for_directory(&d) {
66                found.push(hint);
67            }
68        }
69        if found.is_empty() {
70            None
71        } else {
72            Some(format!("\n\n{}", found.join("\n\n")))
73        }
74    }
75
76    fn extract_directories(&self, tool_name: &str, args: &serde_json::Value) -> Vec<PathBuf> {
77        let mut candidates: HashSet<PathBuf> = HashSet::new();
78        let obj = match args.as_object() {
79            Some(o) => o,
80            None => return Vec::new(),
81        };
82        for key in PATH_ARG_KEYS {
83            if let Some(s) = obj.get(*key).and_then(|v| v.as_str()) {
84                self.add_path_candidate(s, &mut candidates);
85            }
86        }
87        if COMMAND_TOOLS.contains(&tool_name) {
88            if let Some(cmd) = obj.get("command").and_then(|v| v.as_str()) {
89                self.extract_paths_from_command(cmd, &mut candidates);
90            }
91        }
92        candidates.into_iter().collect()
93    }
94
95    fn add_path_candidate(&self, raw: &str, candidates: &mut HashSet<PathBuf>) {
96        if raw.trim().is_empty() {
97            return;
98        }
99        let raw = raw.trim();
100        // Skip URLs and repository specs.
101        if raw.starts_with("http://") || raw.starts_with("https://") || raw.starts_with("git@") {
102            return;
103        }
104        let mut p = PathBuf::from(raw);
105        if !p.is_absolute() {
106            p = self.working_dir.join(p);
107        }
108        let resolved = p.canonicalize().unwrap_or(p);
109        // Use parent if it points at a file (or looks like one).
110        let mut current = if resolved.is_file() || resolved.extension().is_some() {
111            resolved
112                .parent()
113                .map(|p| p.to_path_buf())
114                .unwrap_or(resolved)
115        } else {
116            resolved
117        };
118        for _ in 0..MAX_ANCESTOR_WALK {
119            if self.loaded_dirs.contains(&current) {
120                break;
121            }
122            if current.is_dir() {
123                candidates.insert(current.clone());
124            }
125            let parent = current.parent().map(|p| p.to_path_buf());
126            match parent {
127                Some(p) if p != current => current = p,
128                _ => break,
129            }
130        }
131    }
132
133    fn extract_paths_from_command(&self, cmd: &str, candidates: &mut HashSet<PathBuf>) {
134        for token in cmd.split_whitespace() {
135            if token.starts_with('-') {
136                continue;
137            }
138            if !token.contains('/') && !token.contains('.') {
139                continue;
140            }
141            self.add_path_candidate(token, candidates);
142        }
143    }
144
145    fn load_hints_for_directory(&mut self, directory: &Path) -> Option<String> {
146        // Mark loaded BEFORE attempting the read so a missing-file directory
147        // isn't re-walked next call.
148        self.loaded_dirs.insert(directory.to_path_buf());
149        for filename in HINT_FILENAMES {
150            let hint_path = directory.join(filename);
151            if !hint_path.is_file() {
152                continue;
153            }
154            let content = match std::fs::read_to_string(&hint_path) {
155                Ok(s) => s.trim().to_string(),
156                Err(_) => continue,
157            };
158            if content.is_empty() {
159                continue;
160            }
161            let total_chars = content.chars().count();
162            let truncated = if total_chars > MAX_HINT_CHARS {
163                let prefix: String = content.chars().take(MAX_HINT_CHARS).collect();
164                format!(
165                    "{}\n\n[...truncated {}: {} chars total]",
166                    prefix, filename, total_chars
167                )
168            } else {
169                content
170            };
171            let display = hint_path
172                .strip_prefix(&self.working_dir)
173                .map(|p| p.to_string_lossy().into_owned())
174                .unwrap_or_else(|_| hint_path.to_string_lossy().into_owned());
175            return Some(format!(
176                "[Subdirectory context discovered: {display}]\n{truncated}"
177            ));
178        }
179        None
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use serde_json::json;
187
188    fn tmp_root() -> PathBuf {
189        let dir = std::env::temp_dir().join(format!("microclaw_subhint_{}", uuid::Uuid::new_v4()));
190        std::fs::create_dir_all(&dir).unwrap();
191        dir.canonicalize().unwrap()
192    }
193
194    #[test]
195    fn loads_subdir_agents_md_on_first_touch() {
196        let root = tmp_root();
197        let sub = root.join("backend");
198        std::fs::create_dir_all(&sub).unwrap();
199        std::fs::write(sub.join("AGENTS.md"), "# Backend conventions\nUse async.").unwrap();
200        std::fs::write(sub.join("main.py"), "print('hi')").unwrap();
201
202        let mut tracker = SubdirectoryHintTracker::new(root.clone());
203        let hint = tracker
204            .check_tool_call("read_file", &json!({"path": "backend/main.py"}))
205            .expect("should emit a hint");
206        assert!(hint.contains("Backend conventions"));
207        assert!(hint.contains("AGENTS.md") || hint.contains("backend"));
208
209        // Second touch in same dir → no duplicate hint.
210        let again = tracker.check_tool_call("read_file", &json!({"path": "backend/main.py"}));
211        assert!(again.is_none());
212
213        let _ = std::fs::remove_dir_all(&root);
214    }
215
216    #[test]
217    fn ignores_root_directory() {
218        let root = tmp_root();
219        std::fs::write(root.join("AGENTS.md"), "root context").unwrap();
220        std::fs::write(root.join("a.txt"), "data").unwrap();
221        let mut tracker = SubdirectoryHintTracker::new(root.clone());
222        let hint = tracker.check_tool_call("read_file", &json!({"path": "a.txt"}));
223        assert!(
224            hint.is_none(),
225            "root context should be loaded by system prompt, not hint"
226        );
227        let _ = std::fs::remove_dir_all(&root);
228    }
229
230    #[test]
231    fn walks_ancestors_until_hint_found() {
232        let root = tmp_root();
233        let deep = root.join("a/b/c");
234        std::fs::create_dir_all(&deep).unwrap();
235        std::fs::write(root.join("a/AGENTS.md"), "ancestor hint").unwrap();
236        std::fs::write(deep.join("file.txt"), "hi").unwrap();
237
238        let mut tracker = SubdirectoryHintTracker::new(root.clone());
239        let hint = tracker
240            .check_tool_call("read_file", &json!({"path": "a/b/c/file.txt"}))
241            .expect("should walk up to a/AGENTS.md");
242        assert!(hint.contains("ancestor hint"));
243        let _ = std::fs::remove_dir_all(&root);
244    }
245
246    #[test]
247    fn extracts_paths_from_bash_command() {
248        let root = tmp_root();
249        let sub = root.join("frontend");
250        std::fs::create_dir_all(&sub).unwrap();
251        std::fs::write(sub.join("CLAUDE.md"), "frontend rules").unwrap();
252        std::fs::write(sub.join("page.tsx"), "x").unwrap();
253
254        let mut tracker = SubdirectoryHintTracker::new(root.clone());
255        let hint = tracker
256            .check_tool_call("bash", &json!({"command": "cat frontend/page.tsx"}))
257            .expect("should pull frontend hint from bash command");
258        assert!(hint.contains("frontend rules"));
259        let _ = std::fs::remove_dir_all(&root);
260    }
261
262    #[test]
263    fn truncates_oversized_hint() {
264        let root = tmp_root();
265        let sub = root.join("big");
266        std::fs::create_dir_all(&sub).unwrap();
267        let huge = "x".repeat(MAX_HINT_CHARS + 5_000);
268        std::fs::write(sub.join("AGENTS.md"), &huge).unwrap();
269        std::fs::write(sub.join("file"), "y").unwrap();
270
271        let mut tracker = SubdirectoryHintTracker::new(root.clone());
272        let hint = tracker
273            .check_tool_call("read_file", &json!({"path": "big/file"}))
274            .unwrap();
275        assert!(hint.contains("[...truncated"));
276        let _ = std::fs::remove_dir_all(&root);
277    }
278
279    #[test]
280    fn no_path_arg_returns_none() {
281        let root = tmp_root();
282        let mut tracker = SubdirectoryHintTracker::new(root.clone());
283        let hint = tracker.check_tool_call("web_search", &json!({"query": "rust"}));
284        assert!(hint.is_none());
285        let _ = std::fs::remove_dir_all(&root);
286    }
287
288    #[test]
289    fn url_paths_are_skipped() {
290        let root = tmp_root();
291        let mut tracker = SubdirectoryHintTracker::new(root.clone());
292        let hint = tracker.check_tool_call(
293            "web_fetch",
294            &json!({"url": "https://example.com/some/path"}),
295        );
296        assert!(hint.is_none());
297        let _ = std::fs::remove_dir_all(&root);
298    }
299}