From 8869098de61d9837c710c604627551261fcbe0ca Mon Sep 17 00:00:00 2001 From: Alex Hancock Date: Wed, 11 Mar 2026 10:06:22 -0400 Subject: [PATCH] feat: load hints in nested subdirs (#7772) --- Cargo.lock | 1 + crates/goose/Cargo.toml | 1 + crates/goose/src/agents/agent.rs | 18 ++ crates/goose/src/agents/prompt_manager.rs | 33 ++- crates/goose/src/hints/load_hints.rs | 252 ++++++++++++++++++++++ crates/goose/src/hints/mod.rs | 5 +- 6 files changed, 299 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b0b248e7..4f486578 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4338,6 +4338,7 @@ dependencies = [ "serde_yaml", "serial_test", "sha2", + "shell-words", "shellexpand", "sqlx", "strum", diff --git a/crates/goose/Cargo.toml b/crates/goose/Cargo.toml index 6b635af7..a0fe7f82 100644 --- a/crates/goose/Cargo.toml +++ b/crates/goose/Cargo.toml @@ -144,6 +144,7 @@ pulldown-cmark = "0.13.0" llama-cpp-2 = { version = "0.1.137", features = ["sampler"] } encoding_rs = "0.8.35" pastey = "0.2.1" +shell-words = "1.1.1" [target.'cfg(target_os = "windows")'.dependencies] winapi = { version = "0.3", features = ["wincred"] } diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 0b0dd4cd..609d0644 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -509,6 +509,11 @@ impl Agent { }); tracing::Span::current().record("input", tracing::field::display(&input_summary)); + self.prompt_manager + .lock() + .await + .record_tool_arguments(&tool_call.arguments, &session.working_dir); + if tool_call.name == PLATFORM_MANAGE_SCHEDULE_TOOL_NAME { let arguments = tool_call .arguments @@ -1572,6 +1577,19 @@ impl Agent { (tools, toolshim_tools, system_prompt) = self.prepare_tools_and_prompt(&session_config.id, &session.working_dir).await?; } + + { + let has_new_hints = self + .prompt_manager + .lock() + .await + .load_subdirectory_hints(&working_dir); + if has_new_hints && !tools_updated { + (tools, toolshim_tools, system_prompt) = + self.prepare_tools_and_prompt(&session_config.id, &session.working_dir).await?; + } + } + let mut exit_chat = false; if no_tools_called { if let Some(final_output_tool) = self.final_output_tool.lock().await.as_ref() { diff --git a/crates/goose/src/agents/prompt_manager.rs b/crates/goose/src/agents/prompt_manager.rs index d3bfc115..c3cd3ccf 100644 --- a/crates/goose/src/agents/prompt_manager.rs +++ b/crates/goose/src/agents/prompt_manager.rs @@ -7,7 +7,7 @@ use serde_json::Value; use std::collections::HashMap; use crate::agents::extension::ExtensionInfo; -use crate::hints::load_hints::{load_hint_files, AGENTS_MD_FILENAME, GOOSE_HINTS_FILENAME}; +use crate::hints::{get_context_filenames, load_hint_files, SubdirectoryHintTracker}; use crate::{ config::{Config, GooseMode}, prompt_template, @@ -22,6 +22,7 @@ pub struct PromptManager { system_prompt_override: Option, system_prompt_extras: IndexMap, current_date_timestamp: String, + subdirectory_hint_tracker: SubdirectoryHintTracker, } impl Default for PromptManager { @@ -88,15 +89,7 @@ impl<'a> SystemPromptBuilder<'a, PromptManager> { } pub fn with_hints(mut self, working_dir: &Path) -> Self { - let config = Config::global(); - let hints_filenames = config - .get_param::>("CONTEXT_FILE_NAMES") - .unwrap_or_else(|_| { - vec![ - GOOSE_HINTS_FILENAME.to_string(), - AGENTS_MD_FILENAME.to_string(), - ] - }); + let hints_filenames = get_context_filenames(); let ignore_patterns = { let builder = ignore::gitignore::GitignoreBuilder::new(working_dir); builder.build().unwrap_or_else(|_| { @@ -210,6 +203,7 @@ impl PromptManager { // Use the fixed current date time so that prompt cache can be used. // Filtering to an hour to balance user time accuracy and multi session prompt cache hits. current_date_timestamp: Utc::now().format("%Y-%m-%d %H:00").to_string(), + subdirectory_hint_tracker: SubdirectoryHintTracker::new(), } } @@ -219,6 +213,7 @@ impl PromptManager { system_prompt_override: None, system_prompt_extras: IndexMap::new(), current_date_timestamp: dt.format("%Y-%m-%d %H:%M:%S").to_string(), + subdirectory_hint_tracker: SubdirectoryHintTracker::new(), } } @@ -228,6 +223,24 @@ impl PromptManager { self.system_prompt_extras.insert(key, instruction); } + pub fn record_tool_arguments( + &mut self, + arguments: &Option>, + working_dir: &Path, + ) { + self.subdirectory_hint_tracker + .record_tool_arguments(arguments, working_dir); + } + + pub fn load_subdirectory_hints(&mut self, working_dir: &Path) -> bool { + let new_hints = self.subdirectory_hint_tracker.load_new_hints(working_dir); + let has_new = !new_hints.is_empty(); + for (key, content) in new_hints { + self.system_prompt_extras.insert(key, content); + } + has_new + } + /// Override the system prompt with custom text pub fn set_system_prompt_override(&mut self, template: String) { self.system_prompt_override = Some(template); diff --git a/crates/goose/src/hints/load_hints.rs b/crates/goose/src/hints/load_hints.rs index be7e2d7e..4b9b2cde 100644 --- a/crates/goose/src/hints/load_hints.rs +++ b/crates/goose/src/hints/load_hints.rs @@ -10,6 +10,156 @@ use crate::hints::import_files::read_referenced_files; pub const GOOSE_HINTS_FILENAME: &str = ".goosehints"; pub const AGENTS_MD_FILENAME: &str = "AGENTS.md"; +pub fn get_context_filenames() -> Vec { + use crate::config::Config; + + Config::global() + .get_param::>("CONTEXT_FILE_NAMES") + .unwrap_or_else(|_| { + vec![ + GOOSE_HINTS_FILENAME.to_string(), + AGENTS_MD_FILENAME.to_string(), + ] + }) +} + +#[derive(Default)] +pub struct SubdirectoryHintTracker { + loaded_dirs: HashSet, + pending_dirs: Vec, + hints_filenames: Vec, +} + +impl SubdirectoryHintTracker { + pub fn new() -> Self { + Self { + loaded_dirs: HashSet::new(), + pending_dirs: Vec::new(), + hints_filenames: get_context_filenames(), + } + } + + pub fn record_tool_arguments( + &mut self, + arguments: &Option>, + working_dir: &Path, + ) { + let args = match arguments.as_ref() { + Some(a) => a, + None => return, + }; + + if let Some(path_str) = args.get("path").and_then(|v| v.as_str()) { + if let Some(dir) = resolve_to_parent_dir(path_str, working_dir) { + self.pending_dirs.push(dir); + } + } + + if let Some(cmd) = args.get("command").and_then(|v| v.as_str()) { + for token in shell_words::split(cmd).unwrap_or_default() { + if token.starts_with('-') { + continue; + } + if token.contains(std::path::MAIN_SEPARATOR) || token.contains('.') { + if let Some(dir) = resolve_to_parent_dir(&token, working_dir) { + self.pending_dirs.push(dir); + } + } + } + } + } + + pub fn load_new_hints(&mut self, working_dir: &Path) -> Vec<(String, String)> { + let pending = std::mem::take(&mut self.pending_dirs); + if pending.is_empty() { + return Vec::new(); + } + + let mut results = Vec::new(); + for dir in pending { + if !dir.starts_with(working_dir) || dir == working_dir { + continue; + } + if self.loaded_dirs.contains(&dir) { + continue; + } + if let Some(content) = + load_hints_from_directory(&dir, working_dir, &self.hints_filenames) + { + let key = format!("subdir_hints:{}", dir.display()); + results.push((key, content)); + } + self.loaded_dirs.insert(dir); + } + results + } +} + +fn resolve_to_parent_dir(token: &str, working_dir: &Path) -> Option { + let path = Path::new(token); + let resolved = if path.is_absolute() { + path.to_path_buf() + } else { + working_dir.join(path) + }; + resolved.parent().map(|d| d.to_path_buf()) +} + +fn load_hints_from_directory( + directory: &Path, + working_dir: &Path, + hints_filenames: &[String], +) -> Option { + if !directory.is_dir() || !directory.is_absolute() { + return None; + } + + if !directory.starts_with(working_dir) || directory == working_dir { + return None; + } + + let git_root = find_git_root(working_dir); + let import_boundary = git_root.unwrap_or(working_dir); + let gitignore = Gitignore::empty(); + + let mut directories: Vec = directory + .ancestors() + .take_while(|d| d.starts_with(working_dir) && *d != working_dir) + .map(|d| d.to_path_buf()) + .collect(); + directories.reverse(); + + let mut contents = Vec::new(); + for dir in &directories { + for hints_filename in hints_filenames { + let hints_path = dir.join(hints_filename); + if hints_path.is_file() { + let mut visited = HashSet::new(); + let expanded = read_referenced_files( + &hints_path, + import_boundary, + &mut visited, + 0, + &gitignore, + ); + if !expanded.is_empty() { + contents.push(expanded); + } + } + } + } + + if contents.is_empty() { + None + } else { + Some(format!( + "### Subdirectory Hints ({})\n{}", + directory.display(), + contents.join("\n") + )) + } +} + fn find_git_root(start_dir: &Path) -> Option<&Path> { let mut check_dir = start_dir; @@ -466,4 +616,106 @@ End of hints"#; assert!(hints.contains("Root file content")); assert!(hints.contains("--- Content from ../root_file.md ---")); } + + #[test] + fn resolve_to_parent_dir_relative() { + let wd = Path::new("/home/user/project"); + assert_eq!( + resolve_to_parent_dir("src/main.rs", wd), + Some(PathBuf::from("/home/user/project/src")) + ); + } + + #[test] + fn resolve_to_parent_dir_absolute() { + let wd = Path::new("/home/user/project"); + assert_eq!( + resolve_to_parent_dir("/tmp/foo.rs", wd), + Some(PathBuf::from("/tmp")) + ); + } + + #[test] + fn tracker_records_path_argument() { + let wd = PathBuf::from("/home/user/project"); + let mut tracker = SubdirectoryHintTracker::new(); + let args: serde_json::Map = + serde_json::from_str(r#"{"path": "src/main.rs"}"#).unwrap(); + tracker.record_tool_arguments(&Some(args), &wd); + let hints = tracker.load_new_hints(&wd); + assert!(hints.is_empty()); + assert!(tracker + .loaded_dirs + .contains(&PathBuf::from("/home/user/project/src"))); + } + + #[test] + fn tracker_records_command_argument() { + let wd = PathBuf::from("/home/user/project"); + let mut tracker = SubdirectoryHintTracker::new(); + let args: serde_json::Map = + serde_json::from_str(r#"{"command": "cat nested/doc.md"}"#).unwrap(); + tracker.record_tool_arguments(&Some(args), &wd); + let hints = tracker.load_new_hints(&wd); + assert!(hints.is_empty()); + assert!(tracker + .loaded_dirs + .contains(&PathBuf::from("/home/user/project/nested"))); + } + + #[test] + fn tracker_skips_flags_in_command() { + let wd = PathBuf::from("/home/user/project"); + let mut tracker = SubdirectoryHintTracker::new(); + let args: serde_json::Map = + serde_json::from_str(r#"{"command": "grep -rn pattern src/lib.rs"}"#).unwrap(); + tracker.record_tool_arguments(&Some(args), &wd); + let _ = tracker.load_new_hints(&wd); + assert!(tracker + .loaded_dirs + .contains(&PathBuf::from("/home/user/project/src"))); + assert_eq!(tracker.loaded_dirs.len(), 1); + } + + #[test] + fn tracker_loads_subdirectory_hints() { + let temp_dir = TempDir::new().unwrap(); + let project_root = temp_dir.path().to_path_buf(); + let subdir = project_root.join("nested"); + fs::create_dir_all(&subdir).unwrap(); + fs::write( + subdir.join(GOOSE_HINTS_FILENAME), + "nested subdirectory hints", + ) + .unwrap(); + + let mut tracker = SubdirectoryHintTracker::new(); + let args: serde_json::Map = + serde_json::from_str(r#"{"path": "nested/foo.rs"}"#).unwrap(); + tracker.record_tool_arguments(&Some(args), &project_root); + let hints = tracker.load_new_hints(&project_root); + assert_eq!(hints.len(), 1); + assert!(hints[0].0.contains("nested")); + assert!(hints[0].1.contains("nested subdirectory hints")); + } + + #[test] + fn tracker_deduplicates_directories() { + let temp_dir = TempDir::new().unwrap(); + let project_root = temp_dir.path().to_path_buf(); + let subdir = project_root.join("nested"); + fs::create_dir_all(&subdir).unwrap(); + fs::write(subdir.join(GOOSE_HINTS_FILENAME), "nested hints").unwrap(); + + let mut tracker = SubdirectoryHintTracker::new(); + let args: serde_json::Map = + serde_json::from_str(r#"{"path": "nested/foo.rs"}"#).unwrap(); + tracker.record_tool_arguments(&Some(args.clone()), &project_root); + let hints = tracker.load_new_hints(&project_root); + assert_eq!(hints.len(), 1); + + tracker.record_tool_arguments(&Some(args), &project_root); + let hints = tracker.load_new_hints(&project_root); + assert!(hints.is_empty()); + } } diff --git a/crates/goose/src/hints/mod.rs b/crates/goose/src/hints/mod.rs index b8c0ddb9..4d20006b 100644 --- a/crates/goose/src/hints/mod.rs +++ b/crates/goose/src/hints/mod.rs @@ -1,4 +1,7 @@ mod import_files; pub mod load_hints; -pub use load_hints::{load_hint_files, AGENTS_MD_FILENAME, GOOSE_HINTS_FILENAME}; +pub use load_hints::{ + get_context_filenames, load_hint_files, SubdirectoryHintTracker, AGENTS_MD_FILENAME, + GOOSE_HINTS_FILENAME, +};