fabro/crates/agent/src/project_docs.rs
Bryan Helmkamp 30cf3a6787 Rename coding-agent-loop crate to agent
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 09:17:48 -05:00

186 lines
6.4 KiB
Rust

use crate::execution_env::ExecutionEnvironment;
const BUDGET_BYTES: usize = 32768;
pub async fn discover_project_docs(
env: &dyn ExecutionEnvironment,
git_root: &str,
working_dir: &str,
provider_id: &str,
) -> Vec<String> {
let directories = build_directory_walk(git_root, working_dir);
let candidate_filenames: Vec<&str> = match provider_id {
"anthropic" => vec!["AGENTS.md", "CLAUDE.md"],
"openai" => vec!["AGENTS.md", ".codex/instructions.md"],
"gemini" => vec!["AGENTS.md", "GEMINI.md"],
_ => vec!["AGENTS.md"],
};
let mut results = Vec::new();
let mut budget_remaining = BUDGET_BYTES;
for dir in &directories {
for filename in &candidate_filenames {
let path = format!("{dir}/{filename}");
if let Ok(content) = env.read_file(&path, None, None).await {
if content.is_empty() {
continue;
}
if content.len() <= budget_remaining {
budget_remaining -= content.len();
results.push(content);
} else if budget_remaining > 0 {
let truncated = truncate_to_budget(&content, budget_remaining);
budget_remaining = 0;
results.push(truncated);
}
}
}
}
results
}
fn build_directory_walk(git_root: &str, working_dir: &str) -> Vec<String> {
let mut dirs = vec![git_root.to_string()];
if working_dir == git_root {
return dirs;
}
// Strip git_root prefix to get relative path components
let relative = working_dir
.strip_prefix(git_root)
.and_then(|s| s.strip_prefix('/'))
.unwrap_or("");
if relative.is_empty() {
return dirs;
}
let mut current = git_root.to_string();
let parts: Vec<&str> = relative.split('/').collect();
for part in parts {
current = format!("{current}/{part}");
dirs.push(current.clone());
}
dirs
}
fn truncate_to_budget(content: &str, budget: usize) -> String {
const MARKER: &str = "[Project instructions truncated at 32KB]";
if budget <= MARKER.len() {
return MARKER[..budget].to_string();
}
let usable = budget - MARKER.len();
// Find the last valid char boundary within usable bytes
let mut end = usable;
while end > 0 && !content.is_char_boundary(end) {
end -= 1;
}
format!("{}{MARKER}", &content[..end])
}
#[cfg(test)]
mod tests {
use super::*;
use crate::execution_env::ExecutionEnvironment;
use crate::test_support::MockExecutionEnvironment;
use std::collections::HashMap;
use std::sync::Arc;
#[tokio::test]
async fn discovers_agents_md() {
let mut files = HashMap::new();
files.insert("/repo/AGENTS.md".into(), "Agent instructions".into());
let env: Arc<dyn ExecutionEnvironment> = Arc::new(MockExecutionEnvironment {
files,
..Default::default()
});
let docs = discover_project_docs(env.as_ref(), "/repo", "/repo", "anthropic").await;
assert_eq!(docs.len(), 1);
assert_eq!(docs[0], "Agent instructions");
}
#[tokio::test]
async fn filters_by_provider() {
let mut files = HashMap::new();
files.insert("/repo/AGENTS.md".into(), "agents".into());
files.insert("/repo/CLAUDE.md".into(), "claude".into());
files.insert(
"/repo/.codex/instructions.md".into(),
"copilot".into(),
);
files.insert("/repo/GEMINI.md".into(), "gemini".into());
let env: Arc<dyn ExecutionEnvironment> = Arc::new(MockExecutionEnvironment {
files: files.clone(),
..Default::default()
});
let anthropic_docs =
discover_project_docs(env.as_ref(), "/repo", "/repo", "anthropic").await;
assert_eq!(anthropic_docs.len(), 2);
assert_eq!(anthropic_docs[0], "agents");
assert_eq!(anthropic_docs[1], "claude");
let env: Arc<dyn ExecutionEnvironment> = Arc::new(MockExecutionEnvironment {
files: files.clone(),
..Default::default()
});
let openai_docs = discover_project_docs(env.as_ref(), "/repo", "/repo", "openai").await;
assert_eq!(openai_docs.len(), 2);
assert_eq!(openai_docs[0], "agents");
assert_eq!(openai_docs[1], "copilot");
let env: Arc<dyn ExecutionEnvironment> = Arc::new(MockExecutionEnvironment {
files,
..Default::default()
});
let gemini_docs = discover_project_docs(env.as_ref(), "/repo", "/repo", "gemini").await;
assert_eq!(gemini_docs.len(), 2);
assert_eq!(gemini_docs[0], "agents");
assert_eq!(gemini_docs[1], "gemini");
}
#[tokio::test]
async fn truncates_at_budget() {
let mut files = HashMap::new();
// Create content that exceeds 32KB budget
let large_content = "x".repeat(30000);
let second_content = "y".repeat(5000);
files.insert("/repo/AGENTS.md".into(), large_content.clone());
files.insert("/repo/CLAUDE.md".into(), second_content);
let env: Arc<dyn ExecutionEnvironment> = Arc::new(MockExecutionEnvironment {
files,
..Default::default()
});
let docs = discover_project_docs(env.as_ref(), "/repo", "/repo", "anthropic").await;
assert_eq!(docs.len(), 2);
assert_eq!(docs[0], large_content);
// Second doc should be truncated to fit remaining budget
assert!(docs[1].ends_with("[Project instructions truncated at 32KB]"));
assert!(docs[0].len() + docs[1].len() <= BUDGET_BYTES);
}
#[tokio::test]
async fn walks_directory_hierarchy() {
let mut files = HashMap::new();
files.insert("/repo/AGENTS.md".into(), "root agents".into());
files.insert("/repo/src/AGENTS.md".into(), "src agents".into());
files.insert("/repo/src/app/AGENTS.md".into(), "app agents".into());
let env: Arc<dyn ExecutionEnvironment> = Arc::new(MockExecutionEnvironment {
files,
..Default::default()
});
let docs =
discover_project_docs(env.as_ref(), "/repo", "/repo/src/app", "anthropic").await;
assert_eq!(docs.len(), 3);
assert_eq!(docs[0], "root agents");
assert_eq!(docs[1], "src agents");
assert_eq!(docs[2], "app agents");
}
}