diff --git a/Cargo.lock b/Cargo.lock index bac588b..81425de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -466,6 +466,12 @@ dependencies = [ "wasip2", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "h2" version = "0.4.13" @@ -1216,6 +1222,7 @@ dependencies = [ "chrono", "clap", "dirs", + "glob", "regex", "reqwest", "rusqlite", @@ -1230,6 +1237,7 @@ dependencies = [ "tracing-appender", "tracing-subscriber", "uuid", + "walkdir", ] [[package]] @@ -1290,6 +1298,15 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.28" @@ -1923,6 +1940,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -2016,6 +2043,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-core" version = "0.62.2" diff --git a/Cargo.toml b/Cargo.toml index 9c768fe..bbc6dec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,8 @@ uuid = { version = "1.0", features = ["v4"] } rusqlite = { version = "0.32", features = ["bundled"] } tokio-rusqlite = "0.6" blake3 = "1" +walkdir = "2" +glob = "0.3" [dev-dependencies] tempfile = "3.15" diff --git a/src/context/agents_md.rs b/src/context/agents_md.rs new file mode 100644 index 0000000..bff5faf --- /dev/null +++ b/src/context/agents_md.rs @@ -0,0 +1,165 @@ +use anyhow::Result; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +/// Resolve AGENTS.md files for a given scope of files +/// +/// This walks the directory hierarchy from the project root towards each file in scope, +/// collecting all AGENTS.md files encountered. Returns tuples of (path, heading_summary) where +/// heading_summary is a comma-separated list of top-level headings. Results are deduplicated +/// and ordered with closest-to-file first. +pub fn resolve_agents_md(project_root: &Path, file_scope: &[PathBuf]) -> Result> { + let mut summaries: Vec<(String, String)> = Vec::new(); + let mut seen_paths: HashSet = HashSet::new(); + + // For each file in scope, walk from project root to the file + for file_path in file_scope { + // Normalize the file path relative to project root + let absolute_path = if file_path.is_absolute() { + file_path.clone() + } else { + project_root.join(file_path) + }; + + // Walk from project root to the file's parent, collecting AGENTS.md files + let mut current = project_root.to_path_buf(); + + // Collect all directories from root to file's parent + let mut dirs_to_check = vec![current.clone()]; + + loop { + if let Some(parent) = absolute_path.parent() { + if parent != current && current.starts_with(project_root) { + current = parent.to_path_buf(); + dirs_to_check.push(current.clone()); + } else { + break; + } + } else { + break; + } + + if current == project_root { + break; + } + } + + // Check each directory for AGENTS.md (reverse order: closest to file first) + for dir in dirs_to_check.iter().rev() { + let agents_md_path = dir.join("AGENTS.md"); + if agents_md_path.exists() && !seen_paths.contains(&agents_md_path) { + seen_paths.insert(agents_md_path.clone()); + let headings = extract_headings(&agents_md_path)?; + let heading_summary = headings.join(", "); + let path_str = agents_md_path.to_string_lossy().to_string(); + summaries.push((path_str, heading_summary)); + } + } + } + + Ok(summaries) +} + +/// Extract top-level headings (lines starting with "# ") from a markdown file +fn extract_headings(path: &Path) -> Result> { + let content = std::fs::read_to_string(path)?; + let mut headings = Vec::new(); + + for line in content.lines() { + if let Some(heading) = line.strip_prefix("# ") { + headings.push(heading.trim().to_string()); + } + } + + Ok(headings) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + #[test] + fn test_extract_headings() -> Result<()> { + let tmpdir = TempDir::new()?; + let agents_md_path = tmpdir.path().join("AGENTS.md"); + fs::write( + &agents_md_path, + "# Introduction\n# Getting Started\n## Subsection\n# Advanced", + )?; + + let headings = extract_headings(&agents_md_path)?; + assert_eq!(headings, vec!["Introduction", "Getting Started", "Advanced"]); + Ok(()) + } + + #[test] + fn test_resolve_agents_md_single_file() -> Result<()> { + let tmpdir = TempDir::new()?; + let project_root = tmpdir.path(); + + // Create AGENTS.md at root + fs::write( + project_root.join("AGENTS.md"), + "# Root\n# Guidelines", + )?; + + // Create a file to scope + fs::write(project_root.join("main.rs"), "fn main() {}")?; + + let summaries = resolve_agents_md(project_root, &[PathBuf::from("main.rs")])?; + assert_eq!(summaries.len(), 1); + assert_eq!(summaries[0].1, "Root, Guidelines"); + Ok(()) + } + + #[test] + fn test_resolve_agents_md_hierarchy() -> Result<()> { + let tmpdir = TempDir::new()?; + let project_root = tmpdir.path(); + + // Create root AGENTS.md + fs::write(project_root.join("AGENTS.md"), "# Root Guidelines")?; + + // Create src directory with AGENTS.md + fs::create_dir(project_root.join("src"))?; + fs::write( + project_root.join("src/AGENTS.md"), + "# Rust Guidelines", + )?; + + // Create a file in src + fs::write(project_root.join("src/main.rs"), "fn main() {}")?; + + let summaries = resolve_agents_md(project_root, &[PathBuf::from("src/main.rs")])?; + + // Should have both files, with src/AGENTS.md first (closest to file) + assert_eq!(summaries.len(), 2); + assert!(summaries[0].0.contains("src/AGENTS.md")); + assert!(summaries[1].0.contains("AGENTS.md")); + Ok(()) + } + + #[test] + fn test_resolve_agents_md_deduplication() -> Result<()> { + let tmpdir = TempDir::new()?; + let project_root = tmpdir.path(); + + // Create root AGENTS.md + fs::write(project_root.join("AGENTS.md"), "# Root Guidelines")?; + + // Create two files in root + fs::write(project_root.join("file1.rs"), "fn main() {}")?; + fs::write(project_root.join("file2.rs"), "fn main() {}")?; + + let summaries = resolve_agents_md( + project_root, + &[PathBuf::from("file1.rs"), PathBuf::from("file2.rs")], + )?; + + // Should have AGENTS.md only once despite two files in scope + assert_eq!(summaries.len(), 1); + Ok(()) + } +} diff --git a/src/context/mod.rs b/src/context/mod.rs new file mode 100644 index 0000000..37bded7 --- /dev/null +++ b/src/context/mod.rs @@ -0,0 +1,211 @@ +pub mod agents_md; + +use crate::agent::AgentContext; +use crate::tools::Tool; +use anyhow::Result; +use async_trait::async_trait; +use serde_json::json; +use std::path::PathBuf; + +pub use agents_md::resolve_agents_md; + +/// Builds a compact structured system prompt from an AgentContext +pub struct ContextBuilder; + +impl ContextBuilder { + /// Build a system prompt string from the given agent context + pub fn build_system_prompt(ctx: &AgentContext) -> String { + let mut prompt = String::new(); + + // Role section + prompt.push_str("## Role\n"); + prompt.push_str(&ctx.profile.role); + prompt.push_str("\n\n"); + + // Task section - show work package tasks + if !ctx.work_package_tasks.is_empty() { + prompt.push_str("## Task\n"); + for task in &ctx.work_package_tasks { + prompt.push_str(&format!( + "[TASK] {} | {} | priority={}\n", + task.id, + task.title, + task.priority + .map(|p| p.to_string()) + .unwrap_or_else(|| "medium".to_string()) + )); + + // Add acceptance criteria if present in metadata + if let Some(criteria) = task.metadata.get("acceptance_criteria") { + prompt.push_str(&format!("[CRITERIA] {}\n", criteria)); + } + } + prompt.push_str("\n"); + } + + // Session continuity - handoff notes + if let Some(handoff) = &ctx.handoff_notes { + prompt.push_str("## Session Continuity\n"); + prompt.push_str(&format!("[HANDOFF] {}\n\n", handoff)); + } + + // Active decisions section + if !ctx.relevant_decisions.is_empty() { + prompt.push_str("## Active Decisions\n"); + for decision in &ctx.relevant_decisions { + prompt.push_str(&format!( + "[DECISION] {} | {} | status={}\n", + decision.id, decision.title, decision.status + )); + + // Add chosen option if present + if let Some(chosen) = decision.metadata.get("chosen_option") { + prompt.push_str(&format!(" chosen: {}\n", chosen)); + } + } + prompt.push_str("\n"); + } + + // Relevant observations + if !ctx.work_package_tasks.is_empty() { + prompt.push_str("## Relevant Observations (use query_nodes(id) for full detail)\n"); + for task in &ctx.work_package_tasks { + prompt.push_str(&format!("- {}: {}\n", task.id, task.description)); + } + prompt.push_str("\n"); + } + + // Project conventions + if !ctx.agents_md_summaries.is_empty() { + prompt.push_str("## Project Conventions (use read_agents_md(path) for full text)\n"); + for (path, heading_summary) in &ctx.agents_md_summaries { + prompt.push_str(&format!("- {}: {}\n", path, heading_summary)); + } + prompt.push_str("\n"); + } + + // Rules from the profile + prompt.push_str("## Rules\n"); + prompt.push_str(&ctx.profile.system_prompt); + prompt.push_str("\n"); + + prompt + } +} + +/// Tool for reading AGENTS.md files +pub struct ReadAgentsMdTool; + +impl ReadAgentsMdTool { + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl Tool for ReadAgentsMdTool { + fn name(&self) -> &str { + "read_agents_md" + } + + fn description(&self) -> &str { + "Read the full contents of an AGENTS.md file to see detailed project conventions and guidelines" + } + + fn parameters(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Path to the AGENTS.md file to read" + } + }, + "required": ["path"] + }) + } + + async fn execute(&self, params: serde_json::Value) -> Result { + let path = params + .get("path") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("missing 'path' parameter"))?; + + let path_buf = PathBuf::from(path); + let content = std::fs::read_to_string(&path_buf) + .map_err(|e| anyhow::anyhow!("failed to read {}: {}", path, e))?; + + Ok(content) + } +} + +impl Default for ReadAgentsMdTool { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_read_agents_md_tool_name() { + let tool = ReadAgentsMdTool::new(); + assert_eq!(tool.name(), "read_agents_md"); + } + + #[test] + fn test_read_agents_md_tool_description() { + let tool = ReadAgentsMdTool::new(); + let desc = tool.description(); + assert!(!desc.is_empty()); + assert!(desc.contains("AGENTS.md")); + } + + #[test] + fn test_read_agents_md_tool_parameters() { + let tool = ReadAgentsMdTool::new(); + let params = tool.parameters(); + assert!(params.is_object()); + assert!(params["properties"]["path"].is_object()); + assert_eq!(params["required"][0], "path"); + } + + #[tokio::test] + async fn test_read_agents_md_tool_execute() -> Result<()> { + let tmpdir = tempfile::TempDir::new()?; + let agents_md = tmpdir.path().join("AGENTS.md"); + std::fs::write(&agents_md, "# Test Guidelines\n\nContent here")?; + + let tool = ReadAgentsMdTool::new(); + let result = tool + .execute(json!({ + "path": agents_md.to_string_lossy().to_string() + })) + .await?; + + assert!(result.contains("Test Guidelines")); + assert!(result.contains("Content here")); + Ok(()) + } + + #[tokio::test] + async fn test_read_agents_md_tool_missing_file() { + let tool = ReadAgentsMdTool::new(); + let result = tool + .execute(json!({ + "path": "/nonexistent/AGENTS.md" + })) + .await; + + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_read_agents_md_tool_missing_path_param() { + let tool = ReadAgentsMdTool::new(); + let result = tool.execute(json!({})).await; + assert!(result.is_err()); + } +} diff --git a/src/lib.rs b/src/lib.rs index bb58ce8..5c0715d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,6 @@ pub mod agent; pub mod config; +pub mod context; pub mod db; pub mod graph; pub mod llm; diff --git a/src/tools/factory.rs b/src/tools/factory.rs index f78035c..39b80da 100644 --- a/src/tools/factory.rs +++ b/src/tools/factory.rs @@ -1,3 +1,4 @@ +use crate::context::ReadAgentsMdTool; use crate::graph::store::GraphStore; use crate::security::SecurityValidator; use crate::security::permission::PermissionHandler; @@ -59,5 +60,8 @@ pub fn create_v2_registry( registry.register(Arc::new(RecordObservationTool::new(graph_store.clone()))); registry.register(Arc::new(RevisitTool::new(graph_store))); + // Register context tools + registry.register(Arc::new(ReadAgentsMdTool::new())); + registry }