From ebcce8730dee6f611f729c922d1a8f5793d68257 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Mon, 11 Aug 2025 15:10:46 +0200 Subject: [PATCH] Port some more tools to `agent2` (#35973) Release Notes: - N/A --- Cargo.lock | 2 + crates/agent2/Cargo.toml | 2 + crates/agent2/src/agent.rs | 8 +- crates/agent2/src/tools.rs | 12 + crates/agent2/src/tools/copy_path_tool.rs | 118 ++++ .../agent2/src/tools/create_directory_tool.rs | 89 +++ crates/agent2/src/tools/delete_path_tool.rs | 137 ++++ .../agent2/src/tools/list_directory_tool.rs | 664 ++++++++++++++++++ crates/agent2/src/tools/move_path_tool.rs | 123 ++++ crates/agent2/src/tools/open_tool.rs | 170 +++++ 10 files changed, 1324 insertions(+), 1 deletion(-) create mode 100644 crates/agent2/src/tools/copy_path_tool.rs create mode 100644 crates/agent2/src/tools/create_directory_tool.rs create mode 100644 crates/agent2/src/tools/delete_path_tool.rs create mode 100644 crates/agent2/src/tools/list_directory_tool.rs create mode 100644 crates/agent2/src/tools/move_path_tool.rs create mode 100644 crates/agent2/src/tools/open_tool.rs diff --git a/Cargo.lock b/Cargo.lock index 634bacd0f3..f0d21381fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -210,6 +210,7 @@ dependencies = [ "language_models", "log", "lsp", + "open", "paths", "portable-pty", "pretty_assertions", @@ -223,6 +224,7 @@ dependencies = [ "settings", "smol", "task", + "tempfile", "terminal", "theme", "ui", diff --git a/crates/agent2/Cargo.toml b/crates/agent2/Cargo.toml index 65452f60fc..a288ff30b2 100644 --- a/crates/agent2/Cargo.toml +++ b/crates/agent2/Cargo.toml @@ -32,6 +32,7 @@ language.workspace = true language_model.workspace = true language_models.workspace = true log.workspace = true +open.workspace = true paths.workspace = true portable-pty.workspace = true project.workspace = true @@ -67,6 +68,7 @@ pretty_assertions.workspace = true project = { workspace = true, "features" = ["test-support"] } reqwest_client.workspace = true settings = { workspace = true, "features" = ["test-support"] } +tempfile.workspace = true terminal = { workspace = true, "features" = ["test-support"] } theme = { workspace = true, "features" = ["test-support"] } worktree = { workspace = true, "features" = ["test-support"] } diff --git a/crates/agent2/src/agent.rs b/crates/agent2/src/agent.rs index edb79003b4..398ea6ad50 100644 --- a/crates/agent2/src/agent.rs +++ b/crates/agent2/src/agent.rs @@ -1,6 +1,7 @@ use crate::{AgentResponseEvent, Thread, templates::Templates}; use crate::{ - EditFileTool, FindPathTool, ReadFileTool, TerminalTool, ThinkingTool, ToolCallAuthorization, + CopyPathTool, CreateDirectoryTool, EditFileTool, FindPathTool, ListDirectoryTool, MovePathTool, + OpenTool, ReadFileTool, TerminalTool, ThinkingTool, ToolCallAuthorization, }; use acp_thread::ModelSelector; use agent_client_protocol as acp; @@ -416,6 +417,11 @@ impl acp_thread::AgentConnection for NativeAgentConnection { let thread = cx.new(|cx| { let mut thread = Thread::new(project.clone(), agent.project_context.clone(), action_log.clone(), agent.templates.clone(), default_model); + thread.add_tool(CreateDirectoryTool::new(project.clone())); + thread.add_tool(CopyPathTool::new(project.clone())); + thread.add_tool(MovePathTool::new(project.clone())); + thread.add_tool(ListDirectoryTool::new(project.clone())); + thread.add_tool(OpenTool::new(project.clone())); thread.add_tool(ThinkingTool); thread.add_tool(FindPathTool::new(project.clone())); thread.add_tool(ReadFileTool::new(project.clone(), action_log)); diff --git a/crates/agent2/src/tools.rs b/crates/agent2/src/tools.rs index df4a7a9580..5c3920fcbb 100644 --- a/crates/agent2/src/tools.rs +++ b/crates/agent2/src/tools.rs @@ -1,11 +1,23 @@ +mod copy_path_tool; +mod create_directory_tool; +mod delete_path_tool; mod edit_file_tool; mod find_path_tool; +mod list_directory_tool; +mod move_path_tool; +mod open_tool; mod read_file_tool; mod terminal_tool; mod thinking_tool; +pub use copy_path_tool::*; +pub use create_directory_tool::*; +pub use delete_path_tool::*; pub use edit_file_tool::*; pub use find_path_tool::*; +pub use list_directory_tool::*; +pub use move_path_tool::*; +pub use open_tool::*; pub use read_file_tool::*; pub use terminal_tool::*; pub use thinking_tool::*; diff --git a/crates/agent2/src/tools/copy_path_tool.rs b/crates/agent2/src/tools/copy_path_tool.rs new file mode 100644 index 0000000000..f973b86990 --- /dev/null +++ b/crates/agent2/src/tools/copy_path_tool.rs @@ -0,0 +1,118 @@ +use crate::{AgentTool, ToolCallEventStream}; +use agent_client_protocol::ToolKind; +use anyhow::{Context as _, Result, anyhow}; +use gpui::{App, AppContext, Entity, SharedString, Task}; +use project::Project; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use util::markdown::MarkdownInlineCode; + +/// Copies a file or directory in the project, and returns confirmation that the +/// copy succeeded. +/// +/// Directory contents will be copied recursively (like `cp -r`). +/// +/// This tool should be used when it's desirable to create a copy of a file or +/// directory without modifying the original. It's much more efficient than +/// doing this by separately reading and then writing the file or directory's +/// contents, so this tool should be preferred over that approach whenever +/// copying is the goal. +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct CopyPathToolInput { + /// The source path of the file or directory to copy. + /// If a directory is specified, its contents will be copied recursively (like `cp -r`). + /// + /// + /// If the project has the following files: + /// + /// - directory1/a/something.txt + /// - directory2/a/things.txt + /// - directory3/a/other.txt + /// + /// You can copy the first file by providing a source_path of "directory1/a/something.txt" + /// + pub source_path: String, + + /// The destination path where the file or directory should be copied to. + /// + /// + /// To copy "directory1/a/something.txt" to "directory2/b/copy.txt", + /// provide a destination_path of "directory2/b/copy.txt" + /// + pub destination_path: String, +} + +pub struct CopyPathTool { + project: Entity, +} + +impl CopyPathTool { + pub fn new(project: Entity) -> Self { + Self { project } + } +} + +impl AgentTool for CopyPathTool { + type Input = CopyPathToolInput; + type Output = String; + + fn name(&self) -> SharedString { + "copy_path".into() + } + + fn kind(&self) -> ToolKind { + ToolKind::Move + } + + fn initial_title(&self, input: Result) -> ui::SharedString { + if let Ok(input) = input { + let src = MarkdownInlineCode(&input.source_path); + let dest = MarkdownInlineCode(&input.destination_path); + format!("Copy {src} to {dest}").into() + } else { + "Copy path".into() + } + } + + fn run( + self: Arc, + input: Self::Input, + _event_stream: ToolCallEventStream, + cx: &mut App, + ) -> Task> { + let copy_task = self.project.update(cx, |project, cx| { + match project + .find_project_path(&input.source_path, cx) + .and_then(|project_path| project.entry_for_path(&project_path, cx)) + { + Some(entity) => match project.find_project_path(&input.destination_path, cx) { + Some(project_path) => { + project.copy_entry(entity.id, None, project_path.path, cx) + } + None => Task::ready(Err(anyhow!( + "Destination path {} was outside the project.", + input.destination_path + ))), + }, + None => Task::ready(Err(anyhow!( + "Source path {} was not found in the project.", + input.source_path + ))), + } + }); + + cx.background_spawn(async move { + let _ = copy_task.await.with_context(|| { + format!( + "Copying {} to {}", + input.source_path, input.destination_path + ) + })?; + Ok(format!( + "Copied {} to {}", + input.source_path, input.destination_path + )) + }) + } +} diff --git a/crates/agent2/src/tools/create_directory_tool.rs b/crates/agent2/src/tools/create_directory_tool.rs new file mode 100644 index 0000000000..c173c5ae67 --- /dev/null +++ b/crates/agent2/src/tools/create_directory_tool.rs @@ -0,0 +1,89 @@ +use agent_client_protocol::ToolKind; +use anyhow::{Context as _, Result, anyhow}; +use gpui::{App, Entity, SharedString, Task}; +use project::Project; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use util::markdown::MarkdownInlineCode; + +use crate::{AgentTool, ToolCallEventStream}; + +/// Creates a new directory at the specified path within the project. Returns +/// confirmation that the directory was created. +/// +/// This tool creates a directory and all necessary parent directories (similar +/// to `mkdir -p`). It should be used whenever you need to create new +/// directories within the project. +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct CreateDirectoryToolInput { + /// The path of the new directory. + /// + /// + /// If the project has the following structure: + /// + /// - directory1/ + /// - directory2/ + /// + /// You can create a new directory by providing a path of "directory1/new_directory" + /// + pub path: String, +} + +pub struct CreateDirectoryTool { + project: Entity, +} + +impl CreateDirectoryTool { + pub fn new(project: Entity) -> Self { + Self { project } + } +} + +impl AgentTool for CreateDirectoryTool { + type Input = CreateDirectoryToolInput; + type Output = String; + + fn name(&self) -> SharedString { + "create_directory".into() + } + + fn kind(&self) -> ToolKind { + ToolKind::Read + } + + fn initial_title(&self, input: Result) -> SharedString { + if let Ok(input) = input { + format!("Create directory {}", MarkdownInlineCode(&input.path)).into() + } else { + "Create directory".into() + } + } + + fn run( + self: Arc, + input: Self::Input, + _event_stream: ToolCallEventStream, + cx: &mut App, + ) -> Task> { + let project_path = match self.project.read(cx).find_project_path(&input.path, cx) { + Some(project_path) => project_path, + None => { + return Task::ready(Err(anyhow!("Path to create was outside the project"))); + } + }; + let destination_path: Arc = input.path.as_str().into(); + + let create_entry = self.project.update(cx, |project, cx| { + project.create_entry(project_path.clone(), true, cx) + }); + + cx.spawn(async move |_cx| { + create_entry + .await + .with_context(|| format!("Creating directory {destination_path}"))?; + + Ok(format!("Created directory {destination_path}")) + }) + } +} diff --git a/crates/agent2/src/tools/delete_path_tool.rs b/crates/agent2/src/tools/delete_path_tool.rs new file mode 100644 index 0000000000..e013b3a3e7 --- /dev/null +++ b/crates/agent2/src/tools/delete_path_tool.rs @@ -0,0 +1,137 @@ +use crate::{AgentTool, ToolCallEventStream}; +use action_log::ActionLog; +use agent_client_protocol::ToolKind; +use anyhow::{Context as _, Result, anyhow}; +use futures::{SinkExt, StreamExt, channel::mpsc}; +use gpui::{App, AppContext, Entity, SharedString, Task}; +use project::{Project, ProjectPath}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +/// Deletes the file or directory (and the directory's contents, recursively) at +/// the specified path in the project, and returns confirmation of the deletion. +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct DeletePathToolInput { + /// The path of the file or directory to delete. + /// + /// + /// If the project has the following files: + /// + /// - directory1/a/something.txt + /// - directory2/a/things.txt + /// - directory3/a/other.txt + /// + /// You can delete the first file by providing a path of "directory1/a/something.txt" + /// + pub path: String, +} + +pub struct DeletePathTool { + project: Entity, + action_log: Entity, +} + +impl DeletePathTool { + pub fn new(project: Entity, action_log: Entity) -> Self { + Self { + project, + action_log, + } + } +} + +impl AgentTool for DeletePathTool { + type Input = DeletePathToolInput; + type Output = String; + + fn name(&self) -> SharedString { + "delete_path".into() + } + + fn kind(&self) -> ToolKind { + ToolKind::Delete + } + + fn initial_title(&self, input: Result) -> SharedString { + if let Ok(input) = input { + format!("Delete “`{}`”", input.path).into() + } else { + "Delete path".into() + } + } + + fn run( + self: Arc, + input: Self::Input, + _event_stream: ToolCallEventStream, + cx: &mut App, + ) -> Task> { + let path = input.path; + let Some(project_path) = self.project.read(cx).find_project_path(&path, cx) else { + return Task::ready(Err(anyhow!( + "Couldn't delete {path} because that path isn't in this project." + ))); + }; + + let Some(worktree) = self + .project + .read(cx) + .worktree_for_id(project_path.worktree_id, cx) + else { + return Task::ready(Err(anyhow!( + "Couldn't delete {path} because that path isn't in this project." + ))); + }; + + let worktree_snapshot = worktree.read(cx).snapshot(); + let (mut paths_tx, mut paths_rx) = mpsc::channel(256); + cx.background_spawn({ + let project_path = project_path.clone(); + async move { + for entry in + worktree_snapshot.traverse_from_path(true, false, false, &project_path.path) + { + if !entry.path.starts_with(&project_path.path) { + break; + } + paths_tx + .send(ProjectPath { + worktree_id: project_path.worktree_id, + path: entry.path.clone(), + }) + .await?; + } + anyhow::Ok(()) + } + }) + .detach(); + + let project = self.project.clone(); + let action_log = self.action_log.clone(); + cx.spawn(async move |cx| { + while let Some(path) = paths_rx.next().await { + if let Ok(buffer) = project + .update(cx, |project, cx| project.open_buffer(path, cx))? + .await + { + action_log.update(cx, |action_log, cx| { + action_log.will_delete_buffer(buffer.clone(), cx) + })?; + } + } + + let deletion_task = project + .update(cx, |project, cx| { + project.delete_file(project_path, false, cx) + })? + .with_context(|| { + format!("Couldn't delete {path} because that path isn't in this project.") + })?; + deletion_task + .await + .with_context(|| format!("Deleting {path}"))?; + Ok(format!("Deleted {path}")) + }) + } +} diff --git a/crates/agent2/src/tools/list_directory_tool.rs b/crates/agent2/src/tools/list_directory_tool.rs new file mode 100644 index 0000000000..61f21d8f95 --- /dev/null +++ b/crates/agent2/src/tools/list_directory_tool.rs @@ -0,0 +1,664 @@ +use crate::{AgentTool, ToolCallEventStream}; +use agent_client_protocol::ToolKind; +use anyhow::{Result, anyhow}; +use gpui::{App, Entity, SharedString, Task}; +use project::{Project, WorktreeSettings}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use settings::Settings; +use std::fmt::Write; +use std::{path::Path, sync::Arc}; +use util::markdown::MarkdownInlineCode; + +/// Lists files and directories in a given path. Prefer the `grep` or +/// `find_path` tools when searching the codebase. +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct ListDirectoryToolInput { + /// The fully-qualified path of the directory to list in the project. + /// + /// This path should never be absolute, and the first component + /// of the path should always be a root directory in a project. + /// + /// + /// If the project has the following root directories: + /// + /// - directory1 + /// - directory2 + /// + /// You can list the contents of `directory1` by using the path `directory1`. + /// + /// + /// + /// If the project has the following root directories: + /// + /// - foo + /// - bar + /// + /// If you wanna list contents in the directory `foo/baz`, you should use the path `foo/baz`. + /// + pub path: String, +} + +pub struct ListDirectoryTool { + project: Entity, +} + +impl ListDirectoryTool { + pub fn new(project: Entity) -> Self { + Self { project } + } +} + +impl AgentTool for ListDirectoryTool { + type Input = ListDirectoryToolInput; + type Output = String; + + fn name(&self) -> SharedString { + "list_directory".into() + } + + fn kind(&self) -> ToolKind { + ToolKind::Read + } + + fn initial_title(&self, input: Result) -> SharedString { + if let Ok(input) = input { + let path = MarkdownInlineCode(&input.path); + format!("List the {path} directory's contents").into() + } else { + "List directory".into() + } + } + + fn run( + self: Arc, + input: Self::Input, + _event_stream: ToolCallEventStream, + cx: &mut App, + ) -> Task> { + // Sometimes models will return these even though we tell it to give a path and not a glob. + // When this happens, just list the root worktree directories. + if matches!(input.path.as_str(), "." | "" | "./" | "*") { + let output = self + .project + .read(cx) + .worktrees(cx) + .filter_map(|worktree| { + worktree.read(cx).root_entry().and_then(|entry| { + if entry.is_dir() { + entry.path.to_str() + } else { + None + } + }) + }) + .collect::>() + .join("\n"); + + return Task::ready(Ok(output)); + } + + let Some(project_path) = self.project.read(cx).find_project_path(&input.path, cx) else { + return Task::ready(Err(anyhow!("Path {} not found in project", input.path))); + }; + let Some(worktree) = self + .project + .read(cx) + .worktree_for_id(project_path.worktree_id, cx) + else { + return Task::ready(Err(anyhow!("Worktree not found"))); + }; + + // Check if the directory whose contents we're listing is itself excluded or private + let global_settings = WorktreeSettings::get_global(cx); + if global_settings.is_path_excluded(&project_path.path) { + return Task::ready(Err(anyhow!( + "Cannot list directory because its path matches the user's global `file_scan_exclusions` setting: {}", + &input.path + ))); + } + + if global_settings.is_path_private(&project_path.path) { + return Task::ready(Err(anyhow!( + "Cannot list directory because its path matches the user's global `private_files` setting: {}", + &input.path + ))); + } + + let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx); + if worktree_settings.is_path_excluded(&project_path.path) { + return Task::ready(Err(anyhow!( + "Cannot list directory because its path matches the user's worktree`file_scan_exclusions` setting: {}", + &input.path + ))); + } + + if worktree_settings.is_path_private(&project_path.path) { + return Task::ready(Err(anyhow!( + "Cannot list directory because its path matches the user's worktree `private_paths` setting: {}", + &input.path + ))); + } + + let worktree_snapshot = worktree.read(cx).snapshot(); + let worktree_root_name = worktree.read(cx).root_name().to_string(); + + let Some(entry) = worktree_snapshot.entry_for_path(&project_path.path) else { + return Task::ready(Err(anyhow!("Path not found: {}", input.path))); + }; + + if !entry.is_dir() { + return Task::ready(Err(anyhow!("{} is not a directory.", input.path))); + } + let worktree_snapshot = worktree.read(cx).snapshot(); + + let mut folders = Vec::new(); + let mut files = Vec::new(); + + for entry in worktree_snapshot.child_entries(&project_path.path) { + // Skip private and excluded files and directories + if global_settings.is_path_private(&entry.path) + || global_settings.is_path_excluded(&entry.path) + { + continue; + } + + if self + .project + .read(cx) + .find_project_path(&entry.path, cx) + .map(|project_path| { + let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx); + + worktree_settings.is_path_excluded(&project_path.path) + || worktree_settings.is_path_private(&project_path.path) + }) + .unwrap_or(false) + { + continue; + } + + let full_path = Path::new(&worktree_root_name) + .join(&entry.path) + .display() + .to_string(); + if entry.is_dir() { + folders.push(full_path); + } else { + files.push(full_path); + } + } + + let mut output = String::new(); + + if !folders.is_empty() { + writeln!(output, "# Folders:\n{}", folders.join("\n")).unwrap(); + } + + if !files.is_empty() { + writeln!(output, "\n# Files:\n{}", files.join("\n")).unwrap(); + } + + if output.is_empty() { + writeln!(output, "{} is empty.", input.path).unwrap(); + } + + Task::ready(Ok(output)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{TestAppContext, UpdateGlobal}; + use indoc::indoc; + use project::{FakeFs, Project, WorktreeSettings}; + use serde_json::json; + use settings::SettingsStore; + use util::path; + + fn platform_paths(path_str: &str) -> String { + if cfg!(target_os = "windows") { + path_str.replace("/", "\\") + } else { + path_str.to_string() + } + } + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + language::init(cx); + Project::init_settings(cx); + }); + } + + #[gpui::test] + async fn test_list_directory_separates_files_and_dirs(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + "src": { + "main.rs": "fn main() {}", + "lib.rs": "pub fn hello() {}", + "models": { + "user.rs": "struct User {}", + "post.rs": "struct Post {}" + }, + "utils": { + "helper.rs": "pub fn help() {}" + } + }, + "tests": { + "integration_test.rs": "#[test] fn test() {}" + }, + "README.md": "# Project", + "Cargo.toml": "[package]" + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let tool = Arc::new(ListDirectoryTool::new(project)); + + // Test listing root directory + let input = ListDirectoryToolInput { + path: "project".into(), + }; + let output = cx + .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx)) + .await + .unwrap(); + assert_eq!( + output, + platform_paths(indoc! {" + # Folders: + project/src + project/tests + + # Files: + project/Cargo.toml + project/README.md + "}) + ); + + // Test listing src directory + let input = ListDirectoryToolInput { + path: "project/src".into(), + }; + let output = cx + .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx)) + .await + .unwrap(); + assert_eq!( + output, + platform_paths(indoc! {" + # Folders: + project/src/models + project/src/utils + + # Files: + project/src/lib.rs + project/src/main.rs + "}) + ); + + // Test listing directory with only files + let input = ListDirectoryToolInput { + path: "project/tests".into(), + }; + let output = cx + .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx)) + .await + .unwrap(); + assert!(!output.contains("# Folders:")); + assert!(output.contains("# Files:")); + assert!(output.contains(&platform_paths("project/tests/integration_test.rs"))); + } + + #[gpui::test] + async fn test_list_directory_empty_directory(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + "empty_dir": {} + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let tool = Arc::new(ListDirectoryTool::new(project)); + + let input = ListDirectoryToolInput { + path: "project/empty_dir".into(), + }; + let output = cx + .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx)) + .await + .unwrap(); + assert_eq!(output, "project/empty_dir is empty.\n"); + } + + #[gpui::test] + async fn test_list_directory_error_cases(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + "file.txt": "content" + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let tool = Arc::new(ListDirectoryTool::new(project)); + + // Test non-existent path + let input = ListDirectoryToolInput { + path: "project/nonexistent".into(), + }; + let output = cx + .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx)) + .await; + assert!(output.unwrap_err().to_string().contains("Path not found")); + + // Test trying to list a file instead of directory + let input = ListDirectoryToolInput { + path: "project/file.txt".into(), + }; + let output = cx + .update(|cx| tool.run(input, ToolCallEventStream::test().0, cx)) + .await; + assert!( + output + .unwrap_err() + .to_string() + .contains("is not a directory") + ); + } + + #[gpui::test] + async fn test_list_directory_security(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + "normal_dir": { + "file1.txt": "content", + "file2.txt": "content" + }, + ".mysecrets": "SECRET_KEY=abc123", + ".secretdir": { + "config": "special configuration", + "secret.txt": "secret content" + }, + ".mymetadata": "custom metadata", + "visible_dir": { + "normal.txt": "normal content", + "special.privatekey": "private key content", + "data.mysensitive": "sensitive data", + ".hidden_subdir": { + "hidden_file.txt": "hidden content" + } + } + }), + ) + .await; + + // Configure settings explicitly + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings::(cx, |settings| { + settings.file_scan_exclusions = Some(vec![ + "**/.secretdir".to_string(), + "**/.mymetadata".to_string(), + "**/.hidden_subdir".to_string(), + ]); + settings.private_files = Some(vec![ + "**/.mysecrets".to_string(), + "**/*.privatekey".to_string(), + "**/*.mysensitive".to_string(), + ]); + }); + }); + }); + + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let tool = Arc::new(ListDirectoryTool::new(project)); + + // Listing root directory should exclude private and excluded files + let input = ListDirectoryToolInput { + path: "project".into(), + }; + let output = cx + .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx)) + .await + .unwrap(); + + // Should include normal directories + assert!(output.contains("normal_dir"), "Should list normal_dir"); + assert!(output.contains("visible_dir"), "Should list visible_dir"); + + // Should NOT include excluded or private files + assert!( + !output.contains(".secretdir"), + "Should not list .secretdir (file_scan_exclusions)" + ); + assert!( + !output.contains(".mymetadata"), + "Should not list .mymetadata (file_scan_exclusions)" + ); + assert!( + !output.contains(".mysecrets"), + "Should not list .mysecrets (private_files)" + ); + + // Trying to list an excluded directory should fail + let input = ListDirectoryToolInput { + path: "project/.secretdir".into(), + }; + let output = cx + .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx)) + .await; + assert!( + output + .unwrap_err() + .to_string() + .contains("file_scan_exclusions"), + "Error should mention file_scan_exclusions" + ); + + // Listing a directory should exclude private files within it + let input = ListDirectoryToolInput { + path: "project/visible_dir".into(), + }; + let output = cx + .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx)) + .await + .unwrap(); + + // Should include normal files + assert!(output.contains("normal.txt"), "Should list normal.txt"); + + // Should NOT include private files + assert!( + !output.contains("privatekey"), + "Should not list .privatekey files (private_files)" + ); + assert!( + !output.contains("mysensitive"), + "Should not list .mysensitive files (private_files)" + ); + + // Should NOT include subdirectories that match exclusions + assert!( + !output.contains(".hidden_subdir"), + "Should not list .hidden_subdir (file_scan_exclusions)" + ); + } + + #[gpui::test] + async fn test_list_directory_with_multiple_worktree_settings(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + + // Create first worktree with its own private files + fs.insert_tree( + path!("/worktree1"), + json!({ + ".zed": { + "settings.json": r#"{ + "file_scan_exclusions": ["**/fixture.*"], + "private_files": ["**/secret.rs", "**/config.toml"] + }"# + }, + "src": { + "main.rs": "fn main() { println!(\"Hello from worktree1\"); }", + "secret.rs": "const API_KEY: &str = \"secret_key_1\";", + "config.toml": "[database]\nurl = \"postgres://localhost/db1\"" + }, + "tests": { + "test.rs": "mod tests { fn test_it() {} }", + "fixture.sql": "CREATE TABLE users (id INT, name VARCHAR(255));" + } + }), + ) + .await; + + // Create second worktree with different private files + fs.insert_tree( + path!("/worktree2"), + json!({ + ".zed": { + "settings.json": r#"{ + "file_scan_exclusions": ["**/internal.*"], + "private_files": ["**/private.js", "**/data.json"] + }"# + }, + "lib": { + "public.js": "export function greet() { return 'Hello from worktree2'; }", + "private.js": "const SECRET_TOKEN = \"private_token_2\";", + "data.json": "{\"api_key\": \"json_secret_key\"}" + }, + "docs": { + "README.md": "# Public Documentation", + "internal.md": "# Internal Secrets and Configuration" + } + }), + ) + .await; + + // Set global settings + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings::(cx, |settings| { + settings.file_scan_exclusions = + Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]); + settings.private_files = Some(vec!["**/.env".to_string()]); + }); + }); + }); + + let project = Project::test( + fs.clone(), + [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()], + cx, + ) + .await; + + // Wait for worktrees to be fully scanned + cx.executor().run_until_parked(); + + let tool = Arc::new(ListDirectoryTool::new(project)); + + // Test listing worktree1/src - should exclude secret.rs and config.toml based on local settings + let input = ListDirectoryToolInput { + path: "worktree1/src".into(), + }; + let output = cx + .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx)) + .await + .unwrap(); + assert!(output.contains("main.rs"), "Should list main.rs"); + assert!( + !output.contains("secret.rs"), + "Should not list secret.rs (local private_files)" + ); + assert!( + !output.contains("config.toml"), + "Should not list config.toml (local private_files)" + ); + + // Test listing worktree1/tests - should exclude fixture.sql based on local settings + let input = ListDirectoryToolInput { + path: "worktree1/tests".into(), + }; + let output = cx + .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx)) + .await + .unwrap(); + assert!(output.contains("test.rs"), "Should list test.rs"); + assert!( + !output.contains("fixture.sql"), + "Should not list fixture.sql (local file_scan_exclusions)" + ); + + // Test listing worktree2/lib - should exclude private.js and data.json based on local settings + let input = ListDirectoryToolInput { + path: "worktree2/lib".into(), + }; + let output = cx + .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx)) + .await + .unwrap(); + assert!(output.contains("public.js"), "Should list public.js"); + assert!( + !output.contains("private.js"), + "Should not list private.js (local private_files)" + ); + assert!( + !output.contains("data.json"), + "Should not list data.json (local private_files)" + ); + + // Test listing worktree2/docs - should exclude internal.md based on local settings + let input = ListDirectoryToolInput { + path: "worktree2/docs".into(), + }; + let output = cx + .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx)) + .await + .unwrap(); + assert!(output.contains("README.md"), "Should list README.md"); + assert!( + !output.contains("internal.md"), + "Should not list internal.md (local file_scan_exclusions)" + ); + + // Test trying to list an excluded directory directly + let input = ListDirectoryToolInput { + path: "worktree1/src/secret.rs".into(), + }; + let output = cx + .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx)) + .await; + assert!( + output + .unwrap_err() + .to_string() + .contains("Cannot list directory"), + ); + } +} diff --git a/crates/agent2/src/tools/move_path_tool.rs b/crates/agent2/src/tools/move_path_tool.rs new file mode 100644 index 0000000000..f8d5d0d176 --- /dev/null +++ b/crates/agent2/src/tools/move_path_tool.rs @@ -0,0 +1,123 @@ +use crate::{AgentTool, ToolCallEventStream}; +use agent_client_protocol::ToolKind; +use anyhow::{Context as _, Result, anyhow}; +use gpui::{App, AppContext, Entity, SharedString, Task}; +use project::Project; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::{path::Path, sync::Arc}; +use util::markdown::MarkdownInlineCode; + +/// Moves or rename a file or directory in the project, and returns confirmation +/// that the move succeeded. +/// +/// If the source and destination directories are the same, but the filename is +/// different, this performs a rename. Otherwise, it performs a move. +/// +/// This tool should be used when it's desirable to move or rename a file or +/// directory without changing its contents at all. +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct MovePathToolInput { + /// The source path of the file or directory to move/rename. + /// + /// + /// If the project has the following files: + /// + /// - directory1/a/something.txt + /// - directory2/a/things.txt + /// - directory3/a/other.txt + /// + /// You can move the first file by providing a source_path of "directory1/a/something.txt" + /// + pub source_path: String, + + /// The destination path where the file or directory should be moved/renamed to. + /// If the paths are the same except for the filename, then this will be a rename. + /// + /// + /// To move "directory1/a/something.txt" to "directory2/b/renamed.txt", + /// provide a destination_path of "directory2/b/renamed.txt" + /// + pub destination_path: String, +} + +pub struct MovePathTool { + project: Entity, +} + +impl MovePathTool { + pub fn new(project: Entity) -> Self { + Self { project } + } +} + +impl AgentTool for MovePathTool { + type Input = MovePathToolInput; + type Output = String; + + fn name(&self) -> SharedString { + "move_path".into() + } + + fn kind(&self) -> ToolKind { + ToolKind::Move + } + + fn initial_title(&self, input: Result) -> SharedString { + if let Ok(input) = input { + let src = MarkdownInlineCode(&input.source_path); + let dest = MarkdownInlineCode(&input.destination_path); + let src_path = Path::new(&input.source_path); + let dest_path = Path::new(&input.destination_path); + + match dest_path + .file_name() + .and_then(|os_str| os_str.to_os_string().into_string().ok()) + { + Some(filename) if src_path.parent() == dest_path.parent() => { + let filename = MarkdownInlineCode(&filename); + format!("Rename {src} to {filename}").into() + } + _ => format!("Move {src} to {dest}").into(), + } + } else { + "Move path".into() + } + } + + fn run( + self: Arc, + input: Self::Input, + _event_stream: ToolCallEventStream, + cx: &mut App, + ) -> Task> { + let rename_task = self.project.update(cx, |project, cx| { + match project + .find_project_path(&input.source_path, cx) + .and_then(|project_path| project.entry_for_path(&project_path, cx)) + { + Some(entity) => match project.find_project_path(&input.destination_path, cx) { + Some(project_path) => project.rename_entry(entity.id, project_path.path, cx), + None => Task::ready(Err(anyhow!( + "Destination path {} was outside the project.", + input.destination_path + ))), + }, + None => Task::ready(Err(anyhow!( + "Source path {} was not found in the project.", + input.source_path + ))), + } + }); + + cx.background_spawn(async move { + let _ = rename_task.await.with_context(|| { + format!("Moving {} to {}", input.source_path, input.destination_path) + })?; + Ok(format!( + "Moved {} to {}", + input.source_path, input.destination_path + )) + }) + } +} diff --git a/crates/agent2/src/tools/open_tool.rs b/crates/agent2/src/tools/open_tool.rs new file mode 100644 index 0000000000..0860b62a51 --- /dev/null +++ b/crates/agent2/src/tools/open_tool.rs @@ -0,0 +1,170 @@ +use crate::AgentTool; +use agent_client_protocol::ToolKind; +use anyhow::{Context as _, Result}; +use gpui::{App, AppContext, Entity, SharedString, Task}; +use project::Project; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::{path::PathBuf, sync::Arc}; +use util::markdown::MarkdownEscaped; + +/// This tool opens a file or URL with the default application associated with +/// it on the user's operating system: +/// +/// - On macOS, it's equivalent to the `open` command +/// - On Windows, it's equivalent to `start` +/// - On Linux, it uses something like `xdg-open`, `gio open`, `gnome-open`, `kde-open`, `wslview` as appropriate +/// +/// For example, it can open a web browser with a URL, open a PDF file with the +/// default PDF viewer, etc. +/// +/// You MUST ONLY use this tool when the user has explicitly requested opening +/// something. You MUST NEVER assume that the user would like for you to use +/// this tool. +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] +pub struct OpenToolInput { + /// The path or URL to open with the default application. + path_or_url: String, +} + +pub struct OpenTool { + project: Entity, +} + +impl OpenTool { + pub fn new(project: Entity) -> Self { + Self { project } + } +} + +impl AgentTool for OpenTool { + type Input = OpenToolInput; + type Output = String; + + fn name(&self) -> SharedString { + "open".into() + } + + fn kind(&self) -> ToolKind { + ToolKind::Execute + } + + fn initial_title(&self, input: Result) -> SharedString { + if let Ok(input) = input { + format!("Open `{}`", MarkdownEscaped(&input.path_or_url)).into() + } else { + "Open file or URL".into() + } + } + + fn run( + self: Arc, + input: Self::Input, + event_stream: crate::ToolCallEventStream, + cx: &mut App, + ) -> Task> { + // If path_or_url turns out to be a path in the project, make it absolute. + let abs_path = to_absolute_path(&input.path_or_url, self.project.clone(), cx); + let authorize = event_stream.authorize(self.initial_title(Ok(input.clone())).to_string()); + cx.background_spawn(async move { + authorize.await?; + + match abs_path { + Some(path) => open::that(path), + None => open::that(&input.path_or_url), + } + .context("Failed to open URL or file path")?; + + Ok(format!("Successfully opened {}", input.path_or_url)) + }) + } +} + +fn to_absolute_path( + potential_path: &str, + project: Entity, + cx: &mut App, +) -> Option { + let project = project.read(cx); + project + .find_project_path(PathBuf::from(potential_path), cx) + .and_then(|project_path| project.absolute_path(&project_path, cx)) +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::TestAppContext; + use project::{FakeFs, Project}; + use settings::SettingsStore; + use std::path::Path; + use tempfile::TempDir; + + #[gpui::test] + async fn test_to_absolute_path(cx: &mut TestAppContext) { + init_test(cx); + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let temp_path = temp_dir.path().to_string_lossy().to_string(); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + &temp_path, + serde_json::json!({ + "src": { + "main.rs": "fn main() {}", + "lib.rs": "pub fn lib_fn() {}" + }, + "docs": { + "readme.md": "# Project Documentation" + } + }), + ) + .await; + + // Use the temp_path as the root directory, not just its filename + let project = Project::test(fs.clone(), [temp_dir.path()], cx).await; + + // Test cases where the function should return Some + cx.update(|cx| { + // Project-relative paths should return Some + // Create paths using the last segment of the temp path to simulate a project-relative path + let root_dir_name = Path::new(&temp_path) + .file_name() + .unwrap_or_else(|| std::ffi::OsStr::new("temp")) + .to_string_lossy(); + + assert!( + to_absolute_path(&format!("{root_dir_name}/src/main.rs"), project.clone(), cx) + .is_some(), + "Failed to resolve main.rs path" + ); + + assert!( + to_absolute_path( + &format!("{root_dir_name}/docs/readme.md",), + project.clone(), + cx, + ) + .is_some(), + "Failed to resolve readme.md path" + ); + + // External URL should return None + let result = to_absolute_path("https://example.com", project.clone(), cx); + assert_eq!(result, None, "External URLs should return None"); + + // Path outside project + let result = to_absolute_path("../invalid/path", project.clone(), cx); + assert_eq!(result, None, "Paths outside the project should return None"); + }); + } + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + language::init(cx); + Project::init_settings(cx); + }); + } +}