assistant_tools: Add `list-worktrees` and `read-file` tools (#26147)

Marshall Bowers created

This PR adds two new tools to Assistant 2:

- `list-worktrees` - Lists the worktrees in a project
- `read-file` - Reads a file at the given path in the project

I don't see `list-worktrees` sticking around long-term, as when we have
tools for listing files those will include the worktree IDs along with
the path, but making this tool available allows the model to utilize
`read-file` when it otherwise wouldn't be able to.

Release Notes:

- N/A

Change summary

Cargo.lock                                        |  1 
crates/assistant_tools/Cargo.toml                 |  1 
crates/assistant_tools/src/assistant_tools.rs     |  6 +
crates/assistant_tools/src/list_worktrees_tool.rs | 84 +++++++++++++++++
crates/assistant_tools/src/read_file_tool.rs      | 69 +++++++++++++
5 files changed, 161 insertions(+)

Detailed changes

Cargo.lock 🔗

@@ -658,6 +658,7 @@ dependencies = [
  "assistant_tool",
  "chrono",
  "gpui",
+ "project",
  "schemars",
  "serde",
  "serde_json",

crates/assistant_tools/Cargo.toml 🔗

@@ -16,6 +16,7 @@ anyhow.workspace = true
 assistant_tool.workspace = true
 chrono.workspace = true
 gpui.workspace = true
+project.workspace = true
 schemars.workspace = true
 serde.workspace = true
 serde_json.workspace = true

crates/assistant_tools/src/assistant_tools.rs 🔗

@@ -1,13 +1,19 @@
+mod list_worktrees_tool;
 mod now_tool;
+mod read_file_tool;
 
 use assistant_tool::ToolRegistry;
 use gpui::App;
 
+use crate::list_worktrees_tool::ListWorktreesTool;
 use crate::now_tool::NowTool;
+use crate::read_file_tool::ReadFileTool;
 
 pub fn init(cx: &mut App) {
     assistant_tool::init(cx);
 
     let registry = ToolRegistry::global(cx);
     registry.register_tool(NowTool);
+    registry.register_tool(ListWorktreesTool);
+    registry.register_tool(ReadFileTool);
 }

crates/assistant_tools/src/list_worktrees_tool.rs 🔗

@@ -0,0 +1,84 @@
+use std::sync::Arc;
+
+use anyhow::{anyhow, Result};
+use assistant_tool::Tool;
+use gpui::{App, Task, WeakEntity, Window};
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use workspace::Workspace;
+
+#[derive(Debug, Serialize, Deserialize, JsonSchema)]
+pub struct ListWorktreesToolInput {}
+
+pub struct ListWorktreesTool;
+
+impl Tool for ListWorktreesTool {
+    fn name(&self) -> String {
+        "list-worktrees".into()
+    }
+
+    fn description(&self) -> String {
+        "Lists all worktrees in the current project. Use this tool when you need to find available worktrees and their IDs.".into()
+    }
+
+    fn input_schema(&self) -> serde_json::Value {
+        serde_json::json!(
+            {
+                "type": "object",
+                "properties": {},
+                "required": []
+            }
+        )
+    }
+
+    fn run(
+        self: Arc<Self>,
+        _input: serde_json::Value,
+        workspace: WeakEntity<Workspace>,
+        _window: &mut Window,
+        cx: &mut App,
+    ) -> Task<Result<String>> {
+        let Some(workspace) = workspace.upgrade() else {
+            return Task::ready(Err(anyhow!("workspace dropped")));
+        };
+
+        let project = workspace.read(cx).project().clone();
+
+        cx.spawn(|cx| async move {
+            cx.update(|cx| {
+                #[derive(Debug, Serialize)]
+                struct WorktreeInfo {
+                    id: usize,
+                    root_name: String,
+                    root_dir: Option<String>,
+                }
+
+                let worktrees = project.update(cx, |project, cx| {
+                    project
+                        .visible_worktrees(cx)
+                        .map(|worktree| {
+                            worktree.read_with(cx, |worktree, _cx| WorktreeInfo {
+                                id: worktree.id().to_usize(),
+                                root_dir: worktree
+                                    .root_dir()
+                                    .map(|root_dir| root_dir.to_string_lossy().to_string()),
+                                root_name: worktree.root_name().to_string(),
+                            })
+                        })
+                        .collect::<Vec<_>>()
+                });
+
+                if worktrees.is_empty() {
+                    return Ok("No worktrees found in the current project.".to_string());
+                }
+
+                let mut result = String::from("Worktrees in the current project:\n\n");
+                for worktree in worktrees {
+                    result.push_str(&serde_json::to_string(&worktree)?);
+                }
+
+                Ok(result)
+            })?
+        })
+    }
+}

crates/assistant_tools/src/read_file_tool.rs 🔗

@@ -0,0 +1,69 @@
+use std::path::Path;
+use std::sync::Arc;
+
+use anyhow::{anyhow, Result};
+use assistant_tool::Tool;
+use gpui::{App, Task, WeakEntity, Window};
+use project::{ProjectPath, WorktreeId};
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use workspace::Workspace;
+
+#[derive(Debug, Serialize, Deserialize, JsonSchema)]
+pub struct ReadFileToolInput {
+    /// The ID of the worktree in which the file resides.
+    pub worktree_id: usize,
+    /// The path to the file to read.
+    ///
+    /// This path is relative to the worktree root, it must not be an absolute path.
+    pub path: Arc<Path>,
+}
+
+pub struct ReadFileTool;
+
+impl Tool for ReadFileTool {
+    fn name(&self) -> String {
+        "read-file".into()
+    }
+
+    fn description(&self) -> String {
+        "Reads the content of a file specified by a worktree ID and path. Use this tool when you need to access the contents of a file in the project.".into()
+    }
+
+    fn input_schema(&self) -> serde_json::Value {
+        let schema = schemars::schema_for!(ReadFileToolInput);
+        serde_json::to_value(&schema).unwrap()
+    }
+
+    fn run(
+        self: Arc<Self>,
+        input: serde_json::Value,
+        workspace: WeakEntity<Workspace>,
+        _window: &mut Window,
+        cx: &mut App,
+    ) -> Task<Result<String>> {
+        let Some(workspace) = workspace.upgrade() else {
+            return Task::ready(Err(anyhow!("workspace dropped")));
+        };
+
+        let input = match serde_json::from_value::<ReadFileToolInput>(input) {
+            Ok(input) => input,
+            Err(err) => return Task::ready(Err(anyhow!(err))),
+        };
+
+        let project = workspace.read(cx).project().clone();
+        let project_path = ProjectPath {
+            worktree_id: WorktreeId::from_usize(input.worktree_id),
+            path: input.path,
+        };
+        cx.spawn(|cx| async move {
+            let buffer = cx
+                .update(|cx| {
+                    project.update(cx, |project, cx| project.open_buffer(project_path, cx))
+                })?
+                .await?;
+
+            cx.update(|cx| buffer.read(cx).text())
+        })
+    }
+}