feat: load hints in nested subdirs (#7772)
This commit is contained in:
Generated
+1
@@ -4338,6 +4338,7 @@ dependencies = [
|
|||||||
"serde_yaml",
|
"serde_yaml",
|
||||||
"serial_test",
|
"serial_test",
|
||||||
"sha2",
|
"sha2",
|
||||||
|
"shell-words",
|
||||||
"shellexpand",
|
"shellexpand",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"strum",
|
"strum",
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ pulldown-cmark = "0.13.0"
|
|||||||
llama-cpp-2 = { version = "0.1.137", features = ["sampler"] }
|
llama-cpp-2 = { version = "0.1.137", features = ["sampler"] }
|
||||||
encoding_rs = "0.8.35"
|
encoding_rs = "0.8.35"
|
||||||
pastey = "0.2.1"
|
pastey = "0.2.1"
|
||||||
|
shell-words = "1.1.1"
|
||||||
|
|
||||||
[target.'cfg(target_os = "windows")'.dependencies]
|
[target.'cfg(target_os = "windows")'.dependencies]
|
||||||
winapi = { version = "0.3", features = ["wincred"] }
|
winapi = { version = "0.3", features = ["wincred"] }
|
||||||
|
|||||||
@@ -509,6 +509,11 @@ impl Agent {
|
|||||||
});
|
});
|
||||||
tracing::Span::current().record("input", tracing::field::display(&input_summary));
|
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 {
|
if tool_call.name == PLATFORM_MANAGE_SCHEDULE_TOOL_NAME {
|
||||||
let arguments = tool_call
|
let arguments = tool_call
|
||||||
.arguments
|
.arguments
|
||||||
@@ -1572,6 +1577,19 @@ impl Agent {
|
|||||||
(tools, toolshim_tools, system_prompt) =
|
(tools, toolshim_tools, system_prompt) =
|
||||||
self.prepare_tools_and_prompt(&session_config.id, &session.working_dir).await?;
|
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;
|
let mut exit_chat = false;
|
||||||
if no_tools_called {
|
if no_tools_called {
|
||||||
if let Some(final_output_tool) = self.final_output_tool.lock().await.as_ref() {
|
if let Some(final_output_tool) = self.final_output_tool.lock().await.as_ref() {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use serde_json::Value;
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use crate::agents::extension::ExtensionInfo;
|
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::{
|
use crate::{
|
||||||
config::{Config, GooseMode},
|
config::{Config, GooseMode},
|
||||||
prompt_template,
|
prompt_template,
|
||||||
@@ -22,6 +22,7 @@ pub struct PromptManager {
|
|||||||
system_prompt_override: Option<String>,
|
system_prompt_override: Option<String>,
|
||||||
system_prompt_extras: IndexMap<String, String>,
|
system_prompt_extras: IndexMap<String, String>,
|
||||||
current_date_timestamp: String,
|
current_date_timestamp: String,
|
||||||
|
subdirectory_hint_tracker: SubdirectoryHintTracker,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for PromptManager {
|
impl Default for PromptManager {
|
||||||
@@ -88,15 +89,7 @@ impl<'a> SystemPromptBuilder<'a, PromptManager> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn with_hints(mut self, working_dir: &Path) -> Self {
|
pub fn with_hints(mut self, working_dir: &Path) -> Self {
|
||||||
let config = Config::global();
|
let hints_filenames = get_context_filenames();
|
||||||
let hints_filenames = config
|
|
||||||
.get_param::<Vec<String>>("CONTEXT_FILE_NAMES")
|
|
||||||
.unwrap_or_else(|_| {
|
|
||||||
vec![
|
|
||||||
GOOSE_HINTS_FILENAME.to_string(),
|
|
||||||
AGENTS_MD_FILENAME.to_string(),
|
|
||||||
]
|
|
||||||
});
|
|
||||||
let ignore_patterns = {
|
let ignore_patterns = {
|
||||||
let builder = ignore::gitignore::GitignoreBuilder::new(working_dir);
|
let builder = ignore::gitignore::GitignoreBuilder::new(working_dir);
|
||||||
builder.build().unwrap_or_else(|_| {
|
builder.build().unwrap_or_else(|_| {
|
||||||
@@ -210,6 +203,7 @@ impl PromptManager {
|
|||||||
// Use the fixed current date time so that prompt cache can be used.
|
// 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.
|
// 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(),
|
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_override: None,
|
||||||
system_prompt_extras: IndexMap::new(),
|
system_prompt_extras: IndexMap::new(),
|
||||||
current_date_timestamp: dt.format("%Y-%m-%d %H:%M:%S").to_string(),
|
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);
|
self.system_prompt_extras.insert(key, instruction);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn record_tool_arguments(
|
||||||
|
&mut self,
|
||||||
|
arguments: &Option<serde_json::Map<String, serde_json::Value>>,
|
||||||
|
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
|
/// Override the system prompt with custom text
|
||||||
pub fn set_system_prompt_override(&mut self, template: String) {
|
pub fn set_system_prompt_override(&mut self, template: String) {
|
||||||
self.system_prompt_override = Some(template);
|
self.system_prompt_override = Some(template);
|
||||||
|
|||||||
@@ -10,6 +10,156 @@ use crate::hints::import_files::read_referenced_files;
|
|||||||
pub const GOOSE_HINTS_FILENAME: &str = ".goosehints";
|
pub const GOOSE_HINTS_FILENAME: &str = ".goosehints";
|
||||||
pub const AGENTS_MD_FILENAME: &str = "AGENTS.md";
|
pub const AGENTS_MD_FILENAME: &str = "AGENTS.md";
|
||||||
|
|
||||||
|
pub fn get_context_filenames() -> Vec<String> {
|
||||||
|
use crate::config::Config;
|
||||||
|
|
||||||
|
Config::global()
|
||||||
|
.get_param::<Vec<String>>("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<PathBuf>,
|
||||||
|
pending_dirs: Vec<PathBuf>,
|
||||||
|
hints_filenames: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<serde_json::Map<String, serde_json::Value>>,
|
||||||
|
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<PathBuf> {
|
||||||
|
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<String> {
|
||||||
|
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<PathBuf> = 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> {
|
fn find_git_root(start_dir: &Path) -> Option<&Path> {
|
||||||
let mut check_dir = start_dir;
|
let mut check_dir = start_dir;
|
||||||
|
|
||||||
@@ -466,4 +616,106 @@ End of hints"#;
|
|||||||
assert!(hints.contains("Root file content"));
|
assert!(hints.contains("Root file content"));
|
||||||
assert!(hints.contains("--- Content from ../root_file.md ---"));
|
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<String, serde_json::Value> =
|
||||||
|
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<String, serde_json::Value> =
|
||||||
|
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<String, serde_json::Value> =
|
||||||
|
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<String, serde_json::Value> =
|
||||||
|
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<String, serde_json::Value> =
|
||||||
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
mod import_files;
|
mod import_files;
|
||||||
pub mod load_hints;
|
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,
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user