Port some more tools to `agent2` (#35973)

Antonio Scandurra created

Release Notes:

- N/A

Change summary

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 +++
crates/agent2/src/tools/create_directory_tool.rs |  89 ++
crates/agent2/src/tools/delete_path_tool.rs      | 137 +++
crates/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, 1,324 insertions(+), 1 deletion(-)

Detailed changes

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",

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"] }

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));

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::*;

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`).
+    ///
+    /// <example>
+    /// 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"
+    /// </example>
+    pub source_path: String,
+
+    /// The destination path where the file or directory should be copied to.
+    ///
+    /// <example>
+    /// To copy "directory1/a/something.txt" to "directory2/b/copy.txt",
+    /// provide a destination_path of "directory2/b/copy.txt"
+    /// </example>
+    pub destination_path: String,
+}
+
+pub struct CopyPathTool {
+    project: Entity<Project>,
+}
+
+impl CopyPathTool {
+    pub fn new(project: Entity<Project>) -> 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<Self::Input, serde_json::Value>) -> 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<Self>,
+        input: Self::Input,
+        _event_stream: ToolCallEventStream,
+        cx: &mut App,
+    ) -> Task<Result<Self::Output>> {
+        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
+            ))
+        })
+    }
+}

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.
+    ///
+    /// <example>
+    /// If the project has the following structure:
+    ///
+    /// - directory1/
+    /// - directory2/
+    ///
+    /// You can create a new directory by providing a path of "directory1/new_directory"
+    /// </example>
+    pub path: String,
+}
+
+pub struct CreateDirectoryTool {
+    project: Entity<Project>,
+}
+
+impl CreateDirectoryTool {
+    pub fn new(project: Entity<Project>) -> 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<Self::Input, serde_json::Value>) -> SharedString {
+        if let Ok(input) = input {
+            format!("Create directory {}", MarkdownInlineCode(&input.path)).into()
+        } else {
+            "Create directory".into()
+        }
+    }
+
+    fn run(
+        self: Arc<Self>,
+        input: Self::Input,
+        _event_stream: ToolCallEventStream,
+        cx: &mut App,
+    ) -> Task<Result<Self::Output>> {
+        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<str> = 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}"))
+        })
+    }
+}

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.
+    ///
+    /// <example>
+    /// 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"
+    /// </example>
+    pub path: String,
+}
+
+pub struct DeletePathTool {
+    project: Entity<Project>,
+    action_log: Entity<ActionLog>,
+}
+
+impl DeletePathTool {
+    pub fn new(project: Entity<Project>, action_log: Entity<ActionLog>) -> 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<Self::Input, serde_json::Value>) -> SharedString {
+        if let Ok(input) = input {
+            format!("Delete “`{}`”", input.path).into()
+        } else {
+            "Delete path".into()
+        }
+    }
+
+    fn run(
+        self: Arc<Self>,
+        input: Self::Input,
+        _event_stream: ToolCallEventStream,
+        cx: &mut App,
+    ) -> Task<Result<Self::Output>> {
+        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}"))
+        })
+    }
+}

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.
+    ///
+    /// <example>
+    /// If the project has the following root directories:
+    ///
+    /// - directory1
+    /// - directory2
+    ///
+    /// You can list the contents of `directory1` by using the path `directory1`.
+    /// </example>
+    ///
+    /// <example>
+    /// 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`.
+    /// </example>
+    pub path: String,
+}
+
+pub struct ListDirectoryTool {
+    project: Entity<Project>,
+}
+
+impl ListDirectoryTool {
+    pub fn new(project: Entity<Project>) -> 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<Self::Input, serde_json::Value>) -> 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<Self>,
+        input: Self::Input,
+        _event_stream: ToolCallEventStream,
+        cx: &mut App,
+    ) -> Task<Result<Self::Output>> {
+        // 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::<Vec<_>>()
+                .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::<WorktreeSettings>(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::<WorktreeSettings>(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"),
+        );
+    }
+}

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.
+    ///
+    /// <example>
+    /// 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"
+    /// </example>
+    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.
+    ///
+    /// <example>
+    /// To move "directory1/a/something.txt" to "directory2/b/renamed.txt",
+    /// provide a destination_path of "directory2/b/renamed.txt"
+    /// </example>
+    pub destination_path: String,
+}
+
+pub struct MovePathTool {
+    project: Entity<Project>,
+}
+
+impl MovePathTool {
+    pub fn new(project: Entity<Project>) -> 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<Self::Input, serde_json::Value>) -> 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<Self>,
+        input: Self::Input,
+        _event_stream: ToolCallEventStream,
+        cx: &mut App,
+    ) -> Task<Result<Self::Output>> {
+        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
+            ))
+        })
+    }
+}

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<Project>,
+}
+
+impl OpenTool {
+    pub fn new(project: Entity<Project>) -> 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<Self::Input, serde_json::Value>) -> SharedString {
+        if let Ok(input) = input {
+            format!("Open `{}`", MarkdownEscaped(&input.path_or_url)).into()
+        } else {
+            "Open file or URL".into()
+        }
+    }
+
+    fn run(
+        self: Arc<Self>,
+        input: Self::Input,
+        event_stream: crate::ToolCallEventStream,
+        cx: &mut App,
+    ) -> Task<Result<Self::Output>> {
+        // 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<Project>,
+    cx: &mut App,
+) -> Option<PathBuf> {
+    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);
+        });
+    }
+}