Skip to content

Commit e0fcf08

Browse files
committed
feat: load hints in nested subdirs
fixes #5840
1 parent 12eac72 commit e0fcf08

6 files changed

Lines changed: 300 additions & 11 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/goose/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ unbinder = "0.1.7"
142142
pulldown-cmark = "0.13.0"
143143
llama-cpp-2 = { version = "0.1.137", features = ["sampler"] }
144144
encoding_rs = "0.8.35"
145+
shell-words = "1.1.1"
145146

146147
[target.'cfg(target_os = "windows")'.dependencies]
147148
winapi = { version = "0.3", features = ["wincred"] }

crates/goose/src/agents/agent.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ pub struct Agent {
141141
pub(super) frontend_tools: Mutex<HashMap<String, FrontendTool>>,
142142
pub(super) frontend_instructions: Mutex<Option<String>>,
143143
pub(super) prompt_manager: Mutex<PromptManager>,
144+
subdirectory_hint_tracker: Mutex<crate::hints::SubdirectoryHintTracker>,
144145
pub(super) confirmation_tx: mpsc::Sender<(String, PermissionConfirmation)>,
145146
pub(super) confirmation_rx: Mutex<mpsc::Receiver<(String, PermissionConfirmation)>>,
146147
pub(super) tool_result_tx: mpsc::Sender<(String, ToolResult<CallToolResult>)>,
@@ -240,6 +241,7 @@ impl Agent {
240241
frontend_tools: Mutex::new(HashMap::new()),
241242
frontend_instructions: Mutex::new(None),
242243
prompt_manager: Mutex::new(PromptManager::new()),
244+
subdirectory_hint_tracker: Mutex::new(crate::hints::SubdirectoryHintTracker::new()),
243245
confirmation_tx: confirm_tx,
244246
confirmation_rx: Mutex::new(confirm_rx),
245247
tool_result_tx: tool_tx,
@@ -509,6 +511,11 @@ impl Agent {
509511
});
510512
tracing::Span::current().record("input", tracing::field::display(&input_summary));
511513

514+
self.subdirectory_hint_tracker
515+
.lock()
516+
.await
517+
.record_tool_arguments(&tool_call.arguments, &session.working_dir);
518+
512519
if tool_call.name == PLATFORM_MANAGE_SCHEDULE_TOOL_NAME {
513520
let arguments = tool_call
514521
.arguments
@@ -1572,6 +1579,27 @@ impl Agent {
15721579
(tools, toolshim_tools, system_prompt) =
15731580
self.prepare_tools_and_prompt(&session_config.id, &session.working_dir).await?;
15741581
}
1582+
1583+
{
1584+
let new_hints = self
1585+
.subdirectory_hint_tracker
1586+
.lock()
1587+
.await
1588+
.load_new_hints(&working_dir);
1589+
if !new_hints.is_empty() {
1590+
{
1591+
let mut pm = self.prompt_manager.lock().await;
1592+
for (key, content) in new_hints {
1593+
pm.add_system_prompt_extra(key, content);
1594+
}
1595+
}
1596+
if !tools_updated {
1597+
(tools, toolshim_tools, system_prompt) =
1598+
self.prepare_tools_and_prompt(&session_config.id, &session.working_dir).await?;
1599+
}
1600+
}
1601+
}
1602+
15751603
let mut exit_chat = false;
15761604
if no_tools_called {
15771605
if let Some(final_output_tool) = self.final_output_tool.lock().await.as_ref() {

crates/goose/src/agents/prompt_manager.rs

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use serde_json::Value;
77
use std::collections::HashMap;
88

99
use crate::agents::extension::ExtensionInfo;
10-
use crate::hints::load_hints::{load_hint_files, AGENTS_MD_FILENAME, GOOSE_HINTS_FILENAME};
10+
use crate::hints::{get_context_filenames, load_hint_files};
1111
use crate::{
1212
config::{Config, GooseMode},
1313
prompt_template,
@@ -88,15 +88,7 @@ impl<'a> SystemPromptBuilder<'a, PromptManager> {
8888
}
8989

9090
pub fn with_hints(mut self, working_dir: &Path) -> Self {
91-
let config = Config::global();
92-
let hints_filenames = config
93-
.get_param::<Vec<String>>("CONTEXT_FILE_NAMES")
94-
.unwrap_or_else(|_| {
95-
vec![
96-
GOOSE_HINTS_FILENAME.to_string(),
97-
AGENTS_MD_FILENAME.to_string(),
98-
]
99-
});
91+
let hints_filenames = get_context_filenames();
10092
let ignore_patterns = {
10193
let builder = ignore::gitignore::GitignoreBuilder::new(working_dir);
10294
builder.build().unwrap_or_else(|_| {

crates/goose/src/hints/load_hints.rs

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,163 @@ use crate::hints::import_files::read_referenced_files;
1010
pub const GOOSE_HINTS_FILENAME: &str = ".goosehints";
1111
pub const AGENTS_MD_FILENAME: &str = "AGENTS.md";
1212

13+
pub fn get_context_filenames() -> Vec<String> {
14+
use crate::config::Config;
15+
16+
Config::global()
17+
.get_param::<Vec<String>>("CONTEXT_FILE_NAMES")
18+
.unwrap_or_else(|_| {
19+
vec![
20+
GOOSE_HINTS_FILENAME.to_string(),
21+
AGENTS_MD_FILENAME.to_string(),
22+
]
23+
})
24+
}
25+
26+
/// Tracks which subdirectories have been accessed during a session and loads
27+
/// hint files from those directories on demand.
28+
#[derive(Default)]
29+
pub struct SubdirectoryHintTracker {
30+
loaded_dirs: HashSet<PathBuf>,
31+
pending_dirs: Vec<PathBuf>,
32+
hints_filenames: Vec<String>,
33+
}
34+
35+
impl SubdirectoryHintTracker {
36+
pub fn new() -> Self {
37+
Self {
38+
loaded_dirs: HashSet::new(),
39+
pending_dirs: Vec::new(),
40+
hints_filenames: get_context_filenames(),
41+
}
42+
}
43+
44+
/// Record directories referenced by a tool call's arguments.
45+
/// Extracts parent directories from `path` arguments and path-like tokens
46+
/// in `command` arguments.
47+
pub fn record_tool_arguments(
48+
&mut self,
49+
arguments: &Option<serde_json::Map<String, serde_json::Value>>,
50+
working_dir: &Path,
51+
) {
52+
let args = match arguments.as_ref() {
53+
Some(a) => a,
54+
None => return,
55+
};
56+
57+
if let Some(path_str) = args.get("path").and_then(|v| v.as_str()) {
58+
if let Some(dir) = resolve_to_parent_dir(path_str, working_dir) {
59+
self.pending_dirs.push(dir);
60+
}
61+
}
62+
63+
if let Some(cmd) = args.get("command").and_then(|v| v.as_str()) {
64+
for token in shell_words::split(cmd).unwrap_or_default() {
65+
if token.starts_with('-') {
66+
continue;
67+
}
68+
if token.contains(std::path::MAIN_SEPARATOR) || token.contains('.') {
69+
if let Some(dir) = resolve_to_parent_dir(&token, working_dir) {
70+
self.pending_dirs.push(dir);
71+
}
72+
}
73+
}
74+
}
75+
}
76+
77+
/// Process pending directories and return any newly discovered hints as
78+
/// `(key, content)` pairs suitable for adding to the system prompt.
79+
pub fn load_new_hints(&mut self, working_dir: &Path) -> Vec<(String, String)> {
80+
let pending = std::mem::take(&mut self.pending_dirs);
81+
if pending.is_empty() {
82+
return Vec::new();
83+
}
84+
85+
let mut results = Vec::new();
86+
for dir in pending {
87+
if !dir.starts_with(working_dir) || dir == working_dir {
88+
continue;
89+
}
90+
if self.loaded_dirs.contains(&dir) {
91+
continue;
92+
}
93+
if let Some(content) =
94+
load_hints_from_directory(&dir, working_dir, &self.hints_filenames)
95+
{
96+
let key = format!("subdir_hints:{}", dir.display());
97+
results.push((key, content));
98+
}
99+
self.loaded_dirs.insert(dir);
100+
}
101+
results
102+
}
103+
}
104+
105+
fn resolve_to_parent_dir(token: &str, working_dir: &Path) -> Option<PathBuf> {
106+
let path = Path::new(token);
107+
let resolved = if path.is_absolute() {
108+
path.to_path_buf()
109+
} else {
110+
working_dir.join(path)
111+
};
112+
resolved.parent().map(|d| d.to_path_buf())
113+
}
114+
115+
fn load_hints_from_directory(
116+
directory: &Path,
117+
working_dir: &Path,
118+
hints_filenames: &[String],
119+
) -> Option<String> {
120+
if !directory.is_dir() || !directory.is_absolute() {
121+
return None;
122+
}
123+
124+
if !directory.starts_with(working_dir) || directory == working_dir {
125+
return None;
126+
}
127+
128+
let git_root = find_git_root(working_dir);
129+
let import_boundary = git_root.unwrap_or(working_dir);
130+
let gitignore = Gitignore::empty();
131+
132+
let mut directories: Vec<PathBuf> = directory
133+
.ancestors()
134+
.take_while(|d| d.starts_with(working_dir) && *d != working_dir)
135+
.map(|d| d.to_path_buf())
136+
.collect();
137+
directories.reverse();
138+
139+
let mut contents = Vec::new();
140+
for dir in &directories {
141+
for hints_filename in hints_filenames {
142+
let hints_path = dir.join(hints_filename);
143+
if hints_path.is_file() {
144+
let mut visited = HashSet::new();
145+
let expanded = read_referenced_files(
146+
&hints_path,
147+
import_boundary,
148+
&mut visited,
149+
0,
150+
&gitignore,
151+
);
152+
if !expanded.is_empty() {
153+
contents.push(expanded);
154+
}
155+
}
156+
}
157+
}
158+
159+
if contents.is_empty() {
160+
None
161+
} else {
162+
Some(format!(
163+
"### Subdirectory Hints ({})\n{}",
164+
directory.display(),
165+
contents.join("\n")
166+
))
167+
}
168+
}
169+
13170
fn find_git_root(start_dir: &Path) -> Option<&Path> {
14171
let mut check_dir = start_dir;
15172

@@ -466,4 +623,111 @@ End of hints"#;
466623
assert!(hints.contains("Root file content"));
467624
assert!(hints.contains("--- Content from ../root_file.md ---"));
468625
}
626+
627+
#[test]
628+
fn resolve_to_parent_dir_relative() {
629+
let wd = Path::new("/home/user/project");
630+
assert_eq!(
631+
resolve_to_parent_dir("src/main.rs", wd),
632+
Some(PathBuf::from("/home/user/project/src"))
633+
);
634+
}
635+
636+
#[test]
637+
fn resolve_to_parent_dir_absolute() {
638+
let wd = Path::new("/home/user/project");
639+
assert_eq!(
640+
resolve_to_parent_dir("/tmp/foo.rs", wd),
641+
Some(PathBuf::from("/tmp"))
642+
);
643+
}
644+
645+
#[test]
646+
fn tracker_records_path_argument() {
647+
let wd = PathBuf::from("/home/user/project");
648+
let mut tracker = SubdirectoryHintTracker::new();
649+
let args: serde_json::Map<String, serde_json::Value> =
650+
serde_json::from_str(r#"{"path": "src/main.rs"}"#).unwrap();
651+
tracker.record_tool_arguments(&Some(args), &wd);
652+
// pending_dirs is private, so verify via load_new_hints returning empty
653+
// (directories don't exist on disk, so no hints loaded, but dirs get marked)
654+
let hints = tracker.load_new_hints(&wd);
655+
assert!(hints.is_empty());
656+
// The dir should now be in loaded_dirs
657+
assert!(tracker
658+
.loaded_dirs
659+
.contains(&PathBuf::from("/home/user/project/src")));
660+
}
661+
662+
#[test]
663+
fn tracker_records_command_argument() {
664+
let wd = PathBuf::from("/home/user/project");
665+
let mut tracker = SubdirectoryHintTracker::new();
666+
let args: serde_json::Map<String, serde_json::Value> =
667+
serde_json::from_str(r#"{"command": "cat nested/doc.md"}"#).unwrap();
668+
tracker.record_tool_arguments(&Some(args), &wd);
669+
let hints = tracker.load_new_hints(&wd);
670+
assert!(hints.is_empty());
671+
assert!(tracker
672+
.loaded_dirs
673+
.contains(&PathBuf::from("/home/user/project/nested")));
674+
}
675+
676+
#[test]
677+
fn tracker_skips_flags_in_command() {
678+
let wd = PathBuf::from("/home/user/project");
679+
let mut tracker = SubdirectoryHintTracker::new();
680+
let args: serde_json::Map<String, serde_json::Value> =
681+
serde_json::from_str(r#"{"command": "grep -rn pattern src/lib.rs"}"#).unwrap();
682+
tracker.record_tool_arguments(&Some(args), &wd);
683+
let _ = tracker.load_new_hints(&wd);
684+
assert!(tracker
685+
.loaded_dirs
686+
.contains(&PathBuf::from("/home/user/project/src")));
687+
// flags like -rn should not produce entries
688+
assert_eq!(tracker.loaded_dirs.len(), 1);
689+
}
690+
691+
#[test]
692+
fn tracker_loads_subdirectory_hints() {
693+
let temp_dir = TempDir::new().unwrap();
694+
let project_root = temp_dir.path().to_path_buf();
695+
let subdir = project_root.join("nested");
696+
fs::create_dir_all(&subdir).unwrap();
697+
fs::write(
698+
subdir.join(GOOSE_HINTS_FILENAME),
699+
"nested subdirectory hints",
700+
)
701+
.unwrap();
702+
703+
let mut tracker = SubdirectoryHintTracker::new();
704+
let args: serde_json::Map<String, serde_json::Value> =
705+
serde_json::from_str(r#"{"path": "nested/foo.rs"}"#).unwrap();
706+
tracker.record_tool_arguments(&Some(args), &project_root);
707+
let hints = tracker.load_new_hints(&project_root);
708+
assert_eq!(hints.len(), 1);
709+
assert!(hints[0].0.contains("nested"));
710+
assert!(hints[0].1.contains("nested subdirectory hints"));
711+
}
712+
713+
#[test]
714+
fn tracker_deduplicates_directories() {
715+
let temp_dir = TempDir::new().unwrap();
716+
let project_root = temp_dir.path().to_path_buf();
717+
let subdir = project_root.join("nested");
718+
fs::create_dir_all(&subdir).unwrap();
719+
fs::write(subdir.join(GOOSE_HINTS_FILENAME), "nested hints").unwrap();
720+
721+
let mut tracker = SubdirectoryHintTracker::new();
722+
let args: serde_json::Map<String, serde_json::Value> =
723+
serde_json::from_str(r#"{"path": "nested/foo.rs"}"#).unwrap();
724+
tracker.record_tool_arguments(&Some(args.clone()), &project_root);
725+
let hints = tracker.load_new_hints(&project_root);
726+
assert_eq!(hints.len(), 1);
727+
728+
// Second time same dir — should return nothing
729+
tracker.record_tool_arguments(&Some(args), &project_root);
730+
let hints = tracker.load_new_hints(&project_root);
731+
assert!(hints.is_empty());
732+
}
469733
}

crates/goose/src/hints/mod.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
mod import_files;
22
pub mod load_hints;
33

4-
pub use load_hints::{load_hint_files, AGENTS_MD_FILENAME, GOOSE_HINTS_FILENAME};
4+
pub use load_hints::{
5+
get_context_filenames, load_hint_files, SubdirectoryHintTracker, AGENTS_MD_FILENAME,
6+
GOOSE_HINTS_FILENAME,
7+
};

0 commit comments

Comments
 (0)