Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/agent/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -192,9 +192,10 @@ pub async fn build_agent_inner<M: CompletionModel + 'static>(
ask_tx.clone(),
)),
Box::new(tools::MemoryTool::new(permission.clone(), ask_tx.clone())),
Box::new(tools::ApplyPatchTool::new(
Box::new(tools::ApplyPatchTool::with_cache(
permission.clone(),
ask_tx.clone(),
cache.clone(),
)),
];

Expand Down
9 changes: 8 additions & 1 deletion src/agent/prompt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,14 @@ Available tools:
- bash: Execute bash commands (supports timeout param)
- grep: Search file contents with regex. Respects .gitignore, skips binary files. Supports context_lines param for surrounding context (like grep -C).
- find_files: Find files by regex pattern on filename. Respects .gitignore.
- list_dir: List directory entries with types and sizes. Respects .gitignore. Shows entry count for subdirectories.";
- glob: Find files by glob pattern (e.g. \"**/*.rs\"). Respects .gitignore. Sorted by modification time. Returns empty string when no matches.
- list_dir: List directory entries with types and sizes. Respects .gitignore. Shows entry count for subdirectories.
- apply_patch: Multi-file operations in one call (create, update by text match, delete, rename). Operations run in order, stop on first failure.
- question: Ask the user structured questions when you need clarification, decisions, or preferences. Blocks until user answers.
- plan_enter / plan_exit: Suggest switching to/from plan mode for complex tasks. User must confirm.
- task: Spawn a subagent for research/analysis subtasks. Set background=true for async — completion arrives as <system-reminder> on your next turn. Do NOT poll task_status.
- memory: Persistent per-project knowledge. Actions: view (list or read one), write (create/update), delete.
- skill: Load a skill by name to get detailed instructions for a specific task or domain.";

pub const TODO_TOOLS_PROMPT: &str = "\
- write_todo_list: Create or update a structured task list to track progress in the current coding session. Use this for complex multi-step tasks. Replaces any existing todo list.";
Expand Down
27 changes: 25 additions & 2 deletions src/agent/tools/apply_patch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use rig::tool::Tool;
use serde::Deserialize;
use std::path::Path;

use crate::agent::tools::cache::ToolCache;
use crate::agent::tools::{AskSender, PermCheck, ToolError, check_perm_path};

/// Max content size for a single create operation (1MB).
Expand All @@ -28,11 +29,28 @@ pub enum PatchOp {
pub struct ApplyPatchTool {
pub permission: Option<PermCheck>,
pub ask_tx: Option<AskSender>,
cache: Option<ToolCache>,
}

impl ApplyPatchTool {
pub fn new(permission: Option<PermCheck>, ask_tx: Option<AskSender>) -> Self {
Self { permission, ask_tx }
Self {
permission,
ask_tx,
cache: None,
}
}

pub fn with_cache(
permission: Option<PermCheck>,
ask_tx: Option<AskSender>,
cache: ToolCache,
) -> Self {
Self {
permission,
ask_tx,
cache: Some(cache),
}
}
}

Expand Down Expand Up @@ -186,7 +204,12 @@ impl Tool for ApplyPatchTool {
};

match result {
Ok(msg) => results.push(msg),
Ok(msg) => {
if let Some(ref cache) = self.cache {
cache.clear();
}
results.push(msg);
}
Err(e) => {
results.push(format!("FAILED: {}", e));
break;
Expand Down
14 changes: 4 additions & 10 deletions src/agent/tools/edit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ use rig::completion::ToolDefinition;
use rig::tool::Tool;

use crate::agent::tools::cache::ToolCache;
use crate::agent::tools::{AskSender, EditArgs, PermCheck, ToolError, check_perm_path};
use crate::agent::tools::{
AskSender, EditArgs, PermCheck, ToolError, check_perm_path, is_plan_file,
};
#[cfg(feature = "lsp")]
use crate::lsp::manager::LspManager;

Expand Down Expand Up @@ -140,15 +142,7 @@ impl Tool for EditTool {
check_perm_path(&self.permission, &self.ask_tx, "edit", &args.path).await?;

if let Some(plan) = &self.plan_file {
let allowed = {
let path = std::path::Path::new(&args.path);
path == std::path::Path::new("PLAN.md") || {
let pc = std::fs::canonicalize(path).ok();
let pp = std::fs::canonicalize(plan).ok();
pc.is_some() && pp.is_some() && pc.as_ref() == pp.as_ref()
}
};
if !allowed {
if !is_plan_file(plan, &args.path) {
return Err(ToolError::Msg(
"Plan mode: edits restricted to PLAN.md only. Use /prompt default to exit plan mode."
.to_string(),
Expand Down
53 changes: 46 additions & 7 deletions src/agent/tools/grep.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ impl Tool for GrepTool {
.build();

let mut file_count = 0;
let mut match_count = 0usize;
let mut all_results: Vec<String> = Vec::new();

for entry in walker
Expand Down Expand Up @@ -183,6 +184,8 @@ impl Tool for GrepTool {
continue;
}

match_count += match_lines.len();

if context == 0 {
for &ml in &match_lines {
all_results.push(format!("{}:{}:{}", path_str, ml + 1, lines[ml]));
Expand Down Expand Up @@ -233,20 +236,21 @@ impl Tool for GrepTool {
let result = if all_results.is_empty() {
"No matches found.".to_string()
} else {
let total = all_results.len();
if total >= MAX_GREP_RESULTS {
let output_lines = all_results.len();
if output_lines >= MAX_GREP_RESULTS {
format!(
"{} results (showing first {}, searched {} files):\n{}\n\n... and {} more matches",
total,
"{} matches (showing first {} output lines, searched {} files):\n{}\n\n... and {} more",
match_count,
MAX_GREP_RESULTS,
file_count,
all_results.join("\n"),
total - MAX_GREP_RESULTS
output_lines - MAX_GREP_RESULTS
)
} else {
format!(
"{} results (searched {} files):\n{}",
total,
"{} matches ({} output lines, searched {} files):\n{}",
match_count,
output_lines,
file_count,
all_results.join("\n")
)
Expand All @@ -260,3 +264,38 @@ impl Tool for GrepTool {
Ok(result)
}
}

#[cfg(test)]
mod tests {
/// Regression: glob-to-regex must escape `.` so `*.rs` doesn't match
/// `fileXrs`.
#[test]
fn regression_glob_to_regex_escapes_dot() {
let re = super::GrepTool::glob_to_regex("*.rs");
assert_eq!(re, r".*\.rs", "dot must be escaped");
}

/// Regression: the match-count variable is independent of context lines.
/// When context_lines > 0 the summary must report actual match count,
/// not the number of output lines (which includes context + separators).
///
/// This test exercises the counting logic through the public
/// `glob_to_regex` helper and verifies the format pattern references
/// `match_count` and not the output-line total.
#[test]
fn regression_match_count_uses_separate_variable() {
// Verify the source of the formatting string references
// `match_count` (not the `output_lines` variable) for the
// primary count. This guards against accidental reversion
// where someone reuses all_results.len() for the count.
let src = include_str!("grep.rs");
assert!(
src.contains("match_count"),
"match_count variable must exist"
);
assert!(
src.contains("{} matches"),
"output format must say 'matches'"
);
}
}
36 changes: 36 additions & 0 deletions src/agent/tools/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ pub use websearch::WebSearchTool;
pub use write::WriteTool;

use std::io;
use std::path::{Path, PathBuf};

use serde::Deserialize;

Expand Down Expand Up @@ -208,3 +209,38 @@ pub async fn check_perm_path(
}
}
}

/// Check whether `candidate` refers to the plan file at `plan_file`.
///
/// Handles relative paths (`PLAN.md`, `./PLAN.md`), absolute paths,
/// and the case where the candidate file doesn't exist yet (the agent
/// is about to create it). Falls back to canonicalizing the parent
/// directory when the file itself can't be resolved.
pub fn is_plan_file(plan_file: &Path, candidate: &str) -> bool {
let candidate = Path::new(candidate);

// Canonicalize the candidate: if the file exists, resolve it.
// If it doesn't (the agent is about to create it), resolve
// the parent directory and join the file name.
let resolved = canonicalize_or_parent(candidate);

// Same for the plan file itself. Normally PLAN.md exists by the
// time the agent tries to edit it (it was created first), but
// be defensive in case canonicalize fails.
let plan_resolved = canonicalize_or_parent(plan_file);

resolved == plan_resolved
}

/// Canonicalize a path. If the path itself doesn't exist (e.g. a file
/// about to be created), canonicalize its parent directory and join
/// the file name back.
fn canonicalize_or_parent(path: &Path) -> PathBuf {
std::fs::canonicalize(path).unwrap_or_else(|_| {
let parent = path.parent().unwrap_or(Path::new("."));
let file_name = path.file_name().unwrap_or_default();
std::fs::canonicalize(parent)
.unwrap_or_else(|_| parent.to_path_buf())
.join(file_name)
})
}
14 changes: 4 additions & 10 deletions src/agent/tools/write.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ use rig::completion::ToolDefinition;
use rig::tool::Tool;

use crate::agent::tools::cache::ToolCache;
use crate::agent::tools::{AskSender, PermCheck, ToolError, WriteArgs, check_perm_path};
use crate::agent::tools::{
AskSender, PermCheck, ToolError, WriteArgs, check_perm_path, is_plan_file,
};
#[cfg(feature = "lsp")]
use crate::lsp::diagnostic;
#[cfg(feature = "lsp")]
Expand Down Expand Up @@ -93,15 +95,7 @@ impl Tool for WriteTool {
check_perm_path(&self.permission, &self.ask_tx, "write", &args.path).await?;

if let Some(plan) = &self.plan_file {
let allowed = {
let path = Path::new(&args.path);
path == Path::new("PLAN.md") || {
let pc = std::fs::canonicalize(path).ok();
let pp = std::fs::canonicalize(plan).ok();
pc.is_some() && pp.is_some() && pc.as_ref() == pp.as_ref()
}
};
if !allowed {
if !is_plan_file(plan, &args.path) {
return Err(ToolError::Msg(
"Plan mode: writes restricted to PLAN.md only. Use /prompt default to exit plan mode."
.to_string(),
Expand Down
Loading