agent2: Port read_file tool (#35840)

Agus Zubiaga created

Ports the read_file tool from `assistant_tools` to `agent2`. 

Note: Image support not implemented.

Release Notes:

- N/A

Change summary

Cargo.lock                                |   2 
crates/agent2/Cargo.toml                  |   3 
crates/agent2/src/agent.rs                |   5 
crates/agent2/src/thread.rs               |  33 
crates/agent2/src/tools.rs                |   2 
crates/agent2/src/tools/read_file_tool.rs | 970 +++++++++++++++++++++++++
6 files changed, 1,011 insertions(+), 4 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -172,10 +172,12 @@ dependencies = [
  "gpui_tokio",
  "handlebars 4.5.0",
  "indoc",
+ "itertools 0.14.0",
  "language",
  "language_model",
  "language_models",
  "log",
+ "pretty_assertions",
  "project",
  "prompt_store",
  "reqwest_client",

crates/agent2/Cargo.toml 🔗

@@ -24,6 +24,8 @@ futures.workspace = true
 gpui.workspace = true
 handlebars = { workspace = true, features = ["rust-embed"] }
 indoc.workspace = true
+itertools.workspace = true
+language.workspace = true
 language_model.workspace = true
 language_models.workspace = true
 log.workspace = true
@@ -55,3 +57,4 @@ project = { workspace = true, "features" = ["test-support"] }
 reqwest_client.workspace = true
 settings = { workspace = true, "features" = ["test-support"] }
 worktree = { workspace = true, "features" = ["test-support"] }
+pretty_assertions.workspace = true

crates/agent2/src/agent.rs 🔗

@@ -1,5 +1,5 @@
 use crate::{templates::Templates, AgentResponseEvent, Thread};
-use crate::{FindPathTool, ThinkingTool, ToolCallAuthorization};
+use crate::{FindPathTool, ReadFileTool, ThinkingTool, ToolCallAuthorization};
 use acp_thread::ModelSelector;
 use agent_client_protocol as acp;
 use anyhow::{anyhow, Context as _, Result};
@@ -413,9 +413,10 @@ impl acp_thread::AgentConnection for NativeAgentConnection {
                         })?;
 
                     let thread = cx.new(|_| {
-                        let mut thread = Thread::new(project.clone(), agent.project_context.clone(), action_log, agent.templates.clone(), default_model);
+                        let mut thread = Thread::new(project.clone(), agent.project_context.clone(), action_log.clone(), agent.templates.clone(), default_model);
                         thread.add_tool(ThinkingTool);
                         thread.add_tool(FindPathTool::new(project.clone()));
+                        thread.add_tool(ReadFileTool::new(project.clone(), action_log));
                         thread
                     });
 

crates/agent2/src/thread.rs 🔗

@@ -125,7 +125,7 @@ pub struct Thread {
     project_context: Rc<RefCell<ProjectContext>>,
     templates: Arc<Templates>,
     pub selected_model: Arc<dyn LanguageModel>,
-    _action_log: Entity<ActionLog>,
+    action_log: Entity<ActionLog>,
 }
 
 impl Thread {
@@ -145,7 +145,7 @@ impl Thread {
             project_context,
             templates,
             selected_model: default_model,
-            _action_log: action_log,
+            action_log,
         }
     }
 
@@ -315,6 +315,10 @@ impl Thread {
         events_rx
     }
 
+    pub fn action_log(&self) -> &Entity<ActionLog> {
+        &self.action_log
+    }
+
     pub fn build_system_message(&self) -> AgentMessage {
         log::debug!("Building system message");
         let prompt = SystemPromptTemplate {
@@ -924,3 +928,28 @@ impl ToolCallEventStream {
             .authorize_tool_call(&self.tool_use_id, title, kind, input)
     }
 }
+
+#[cfg(test)]
+pub struct TestToolCallEventStream {
+    stream: ToolCallEventStream,
+    _events_rx: mpsc::UnboundedReceiver<Result<AgentResponseEvent, LanguageModelCompletionError>>,
+}
+
+#[cfg(test)]
+impl TestToolCallEventStream {
+    pub fn new() -> Self {
+        let (events_tx, events_rx) =
+            mpsc::unbounded::<Result<AgentResponseEvent, LanguageModelCompletionError>>();
+
+        let stream = ToolCallEventStream::new("test".into(), AgentResponseEventStream(events_tx));
+
+        Self {
+            stream,
+            _events_rx: events_rx,
+        }
+    }
+
+    pub fn stream(&self) -> ToolCallEventStream {
+        self.stream.clone()
+    }
+}

crates/agent2/src/tools.rs 🔗

@@ -1,5 +1,7 @@
 mod find_path_tool;
+mod read_file_tool;
 mod thinking_tool;
 
 pub use find_path_tool::*;
+pub use read_file_tool::*;
 pub use thinking_tool::*;

crates/agent2/src/tools/read_file_tool.rs 🔗

@@ -0,0 +1,970 @@
+use agent_client_protocol::{self as acp};
+use anyhow::{anyhow, Result};
+use assistant_tool::{outline, ActionLog};
+use gpui::{Entity, Task};
+use indoc::formatdoc;
+use language::{Anchor, Point};
+use project::{AgentLocation, Project, WorktreeSettings};
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use settings::Settings;
+use std::sync::Arc;
+use ui::{App, SharedString};
+
+use crate::{AgentTool, ToolCallEventStream};
+
+/// Reads the content of the given file in the project.
+///
+/// - Never attempt to read a path that hasn't been previously mentioned.
+#[derive(Debug, Serialize, Deserialize, JsonSchema)]
+pub struct ReadFileToolInput {
+    /// The relative path of the file to read.
+    ///
+    /// 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:
+    ///
+    /// - /a/b/directory1
+    /// - /c/d/directory2
+    ///
+    /// If you want to access `file.txt` in `directory1`, you should use the path `directory1/file.txt`.
+    /// If you want to access `file.txt` in `directory2`, you should use the path `directory2/file.txt`.
+    /// </example>
+    pub path: String,
+
+    /// Optional line number to start reading on (1-based index)
+    #[serde(default)]
+    pub start_line: Option<u32>,
+
+    /// Optional line number to end reading on (1-based index, inclusive)
+    #[serde(default)]
+    pub end_line: Option<u32>,
+}
+
+pub struct ReadFileTool {
+    project: Entity<Project>,
+    action_log: Entity<ActionLog>,
+}
+
+impl ReadFileTool {
+    pub fn new(project: Entity<Project>, action_log: Entity<ActionLog>) -> Self {
+        Self {
+            project,
+            action_log,
+        }
+    }
+}
+
+impl AgentTool for ReadFileTool {
+    type Input = ReadFileToolInput;
+
+    fn name(&self) -> SharedString {
+        "read_file".into()
+    }
+
+    fn kind(&self) -> acp::ToolKind {
+        acp::ToolKind::Read
+    }
+
+    fn initial_title(&self, input: Self::Input) -> SharedString {
+        let path = &input.path;
+        match (input.start_line, input.end_line) {
+            (Some(start), Some(end)) => {
+                format!(
+                    "[Read file `{}` (lines {}-{})](@selection:{}:({}-{}))",
+                    path, start, end, path, start, end
+                )
+            }
+            (Some(start), None) => {
+                format!(
+                    "[Read file `{}` (from line {})](@selection:{}:({}-{}))",
+                    path, start, path, start, start
+                )
+            }
+            _ => format!("[Read file `{}`](@file:{})", path, path),
+        }
+        .into()
+    }
+
+    fn run(
+        self: Arc<Self>,
+        input: Self::Input,
+        event_stream: ToolCallEventStream,
+        cx: &mut App,
+    ) -> Task<Result<String>> {
+        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)));
+        };
+
+        // Error out if this path is either excluded or private in global settings
+        let global_settings = WorktreeSettings::get_global(cx);
+        if global_settings.is_path_excluded(&project_path.path) {
+            return Task::ready(Err(anyhow!(
+                "Cannot read file because its path matches the global `file_scan_exclusions` setting: {}",
+                &input.path
+            )));
+        }
+
+        if global_settings.is_path_private(&project_path.path) {
+            return Task::ready(Err(anyhow!(
+                "Cannot read file because its path matches the global `private_files` setting: {}",
+                &input.path
+            )));
+        }
+
+        // Error out if this path is either excluded or private in worktree settings
+        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 read file because its path matches the worktree `file_scan_exclusions` setting: {}",
+                &input.path
+            )));
+        }
+
+        if worktree_settings.is_path_private(&project_path.path) {
+            return Task::ready(Err(anyhow!(
+                "Cannot read file because its path matches the worktree `private_files` setting: {}",
+                &input.path
+            )));
+        }
+
+        let file_path = input.path.clone();
+
+        event_stream.send_update(acp::ToolCallUpdateFields {
+            locations: Some(vec![acp::ToolCallLocation {
+                path: project_path.path.to_path_buf(),
+                line: input.start_line,
+                // TODO (tracked): use full range
+            }]),
+            ..Default::default()
+        });
+
+        // TODO (tracked): images
+        // if image_store::is_image_file(&self.project, &project_path, cx) {
+        //     let model = &self.thread.read(cx).selected_model;
+
+        //     if !model.supports_images() {
+        //         return Task::ready(Err(anyhow!(
+        //             "Attempted to read an image, but Zed doesn't currently support sending images to {}.",
+        //             model.name().0
+        //         )))
+        //         .into();
+        //     }
+
+        //     return cx.spawn(async move |cx| -> Result<ToolResultOutput> {
+        //         let image_entity: Entity<ImageItem> = cx
+        //             .update(|cx| {
+        //                 self.project.update(cx, |project, cx| {
+        //                     project.open_image(project_path.clone(), cx)
+        //                 })
+        //             })?
+        //             .await?;
+
+        //         let image =
+        //             image_entity.read_with(cx, |image_item, _| Arc::clone(&image_item.image))?;
+
+        //         let language_model_image = cx
+        //             .update(|cx| LanguageModelImage::from_image(image, cx))?
+        //             .await
+        //             .context("processing image")?;
+
+        //         Ok(ToolResultOutput {
+        //             content: ToolResultContent::Image(language_model_image),
+        //             output: None,
+        //         })
+        //     });
+        // }
+        //
+
+        let project = self.project.clone();
+        let action_log = self.action_log.clone();
+
+        cx.spawn(async move |cx| {
+            let buffer = cx
+                .update(|cx| {
+                    project.update(cx, |project, cx| project.open_buffer(project_path, cx))
+                })?
+                .await?;
+            if buffer.read_with(cx, |buffer, _| {
+                buffer
+                    .file()
+                    .as_ref()
+                    .map_or(true, |file| !file.disk_state().exists())
+            })? {
+                anyhow::bail!("{file_path} not found");
+            }
+
+            project.update(cx, |project, cx| {
+                project.set_agent_location(
+                    Some(AgentLocation {
+                        buffer: buffer.downgrade(),
+                        position: Anchor::MIN,
+                    }),
+                    cx,
+                );
+            })?;
+
+            // Check if specific line ranges are provided
+            if input.start_line.is_some() || input.end_line.is_some() {
+                let mut anchor = None;
+                let result = buffer.read_with(cx, |buffer, _cx| {
+                    let text = buffer.text();
+                    // .max(1) because despite instructions to be 1-indexed, sometimes the model passes 0.
+                    let start = input.start_line.unwrap_or(1).max(1);
+                    let start_row = start - 1;
+                    if start_row <= buffer.max_point().row {
+                        let column = buffer.line_indent_for_row(start_row).raw_len();
+                        anchor = Some(buffer.anchor_before(Point::new(start_row, column)));
+                    }
+
+                    let lines = text.split('\n').skip(start_row as usize);
+                    if let Some(end) = input.end_line {
+                        let count = end.saturating_sub(start).saturating_add(1); // Ensure at least 1 line
+                        itertools::intersperse(lines.take(count as usize), "\n").collect::<String>()
+                    } else {
+                        itertools::intersperse(lines, "\n").collect::<String>()
+                    }
+                })?;
+
+                action_log.update(cx, |log, cx| {
+                    log.buffer_read(buffer.clone(), cx);
+                })?;
+
+                if let Some(anchor) = anchor {
+                    project.update(cx, |project, cx| {
+                        project.set_agent_location(
+                            Some(AgentLocation {
+                                buffer: buffer.downgrade(),
+                                position: anchor,
+                            }),
+                            cx,
+                        );
+                    })?;
+                }
+
+                Ok(result)
+            } else {
+                // No line ranges specified, so check file size to see if it's too big.
+                let file_size = buffer.read_with(cx, |buffer, _cx| buffer.text().len())?;
+
+                if file_size <= outline::AUTO_OUTLINE_SIZE {
+                    // File is small enough, so return its contents.
+                    let result = buffer.read_with(cx, |buffer, _cx| buffer.text())?;
+
+                    action_log.update(cx, |log, cx| {
+                        log.buffer_read(buffer, cx);
+                    })?;
+
+                    Ok(result)
+                } else {
+                    // File is too big, so return the outline
+                    // and a suggestion to read again with line numbers.
+                    let outline =
+                        outline::file_outline(project, file_path, action_log, None, cx).await?;
+                    Ok(formatdoc! {"
+                        This file was too big to read all at once.
+
+                        Here is an outline of its symbols:
+
+                        {outline}
+
+                        Using the line numbers in this outline, you can call this tool again
+                        while specifying the start_line and end_line fields to see the
+                        implementations of symbols in the outline.
+
+                        Alternatively, you can fall back to the `grep` tool (if available)
+                        to search the file for specific content."
+                    })
+                }
+            }
+        })
+    }
+}
+
+#[cfg(test)]
+mod test {
+    use crate::TestToolCallEventStream;
+
+    use super::*;
+    use gpui::{AppContext, TestAppContext, UpdateGlobal as _};
+    use language::{tree_sitter_rust, Language, LanguageConfig, LanguageMatcher};
+    use project::{FakeFs, Project};
+    use serde_json::json;
+    use settings::SettingsStore;
+    use util::path;
+
+    #[gpui::test]
+    async fn test_read_nonexistent_file(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(path!("/root"), json!({})).await;
+        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
+        let action_log = cx.new(|_| ActionLog::new(project.clone()));
+        let tool = Arc::new(ReadFileTool::new(project, action_log));
+        let event_stream = TestToolCallEventStream::new();
+
+        let result = cx
+            .update(|cx| {
+                let input = ReadFileToolInput {
+                    path: "root/nonexistent_file.txt".to_string(),
+                    start_line: None,
+                    end_line: None,
+                };
+                tool.run(input, event_stream.stream(), cx)
+            })
+            .await;
+        assert_eq!(
+            result.unwrap_err().to_string(),
+            "root/nonexistent_file.txt not found"
+        );
+    }
+    #[gpui::test]
+    async fn test_read_small_file(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            path!("/root"),
+            json!({
+                "small_file.txt": "This is a small file content"
+            }),
+        )
+        .await;
+        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
+        let action_log = cx.new(|_| ActionLog::new(project.clone()));
+        let tool = Arc::new(ReadFileTool::new(project, action_log));
+        let event_stream = TestToolCallEventStream::new();
+        let result = cx
+            .update(|cx| {
+                let input = ReadFileToolInput {
+                    path: "root/small_file.txt".into(),
+                    start_line: None,
+                    end_line: None,
+                };
+                tool.run(input, event_stream.stream(), cx)
+            })
+            .await;
+        assert_eq!(result.unwrap(), "This is a small file content");
+    }
+
+    #[gpui::test]
+    async fn test_read_large_file(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            path!("/root"),
+            json!({
+                "large_file.rs": (0..1000).map(|i| format!("struct Test{} {{\n    a: u32,\n    b: usize,\n}}", i)).collect::<Vec<_>>().join("\n")
+            }),
+        )
+        .await;
+        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
+        let language_registry = project.read_with(cx, |project, _| project.languages().clone());
+        language_registry.add(Arc::new(rust_lang()));
+        let action_log = cx.new(|_| ActionLog::new(project.clone()));
+        let tool = Arc::new(ReadFileTool::new(project, action_log));
+        let event_stream = TestToolCallEventStream::new();
+        let content = cx
+            .update(|cx| {
+                let input = ReadFileToolInput {
+                    path: "root/large_file.rs".into(),
+                    start_line: None,
+                    end_line: None,
+                };
+                tool.clone().run(input, event_stream.stream(), cx)
+            })
+            .await
+            .unwrap();
+
+        assert_eq!(
+            content.lines().skip(4).take(6).collect::<Vec<_>>(),
+            vec![
+                "struct Test0 [L1-4]",
+                " a [L2]",
+                " b [L3]",
+                "struct Test1 [L5-8]",
+                " a [L6]",
+                " b [L7]",
+            ]
+        );
+
+        let result = cx
+            .update(|cx| {
+                let input = ReadFileToolInput {
+                    path: "root/large_file.rs".into(),
+                    start_line: None,
+                    end_line: None,
+                };
+                tool.run(input, event_stream.stream(), cx)
+            })
+            .await;
+        let content = result.unwrap();
+        let expected_content = (0..1000)
+            .flat_map(|i| {
+                vec![
+                    format!("struct Test{} [L{}-{}]", i, i * 4 + 1, i * 4 + 4),
+                    format!(" a [L{}]", i * 4 + 2),
+                    format!(" b [L{}]", i * 4 + 3),
+                ]
+            })
+            .collect::<Vec<_>>();
+        pretty_assertions::assert_eq!(
+            content
+                .lines()
+                .skip(4)
+                .take(expected_content.len())
+                .collect::<Vec<_>>(),
+            expected_content
+        );
+    }
+
+    #[gpui::test]
+    async fn test_read_file_with_line_range(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            path!("/root"),
+            json!({
+                "multiline.txt": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
+            }),
+        )
+        .await;
+        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
+
+        let action_log = cx.new(|_| ActionLog::new(project.clone()));
+        let tool = Arc::new(ReadFileTool::new(project, action_log));
+        let event_stream = TestToolCallEventStream::new();
+        let result = cx
+            .update(|cx| {
+                let input = ReadFileToolInput {
+                    path: "root/multiline.txt".to_string(),
+                    start_line: Some(2),
+                    end_line: Some(4),
+                };
+                tool.run(input, event_stream.stream(), cx)
+            })
+            .await;
+        assert_eq!(result.unwrap(), "Line 2\nLine 3\nLine 4");
+    }
+
+    #[gpui::test]
+    async fn test_read_file_line_range_edge_cases(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            path!("/root"),
+            json!({
+                "multiline.txt": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
+            }),
+        )
+        .await;
+        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
+        let action_log = cx.new(|_| ActionLog::new(project.clone()));
+        let tool = Arc::new(ReadFileTool::new(project, action_log));
+        let event_stream = TestToolCallEventStream::new();
+
+        // start_line of 0 should be treated as 1
+        let result = cx
+            .update(|cx| {
+                let input = ReadFileToolInput {
+                    path: "root/multiline.txt".to_string(),
+                    start_line: Some(0),
+                    end_line: Some(2),
+                };
+                tool.clone().run(input, event_stream.stream(), cx)
+            })
+            .await;
+        assert_eq!(result.unwrap(), "Line 1\nLine 2");
+
+        // end_line of 0 should result in at least 1 line
+        let result = cx
+            .update(|cx| {
+                let input = ReadFileToolInput {
+                    path: "root/multiline.txt".to_string(),
+                    start_line: Some(1),
+                    end_line: Some(0),
+                };
+                tool.clone().run(input, event_stream.stream(), cx)
+            })
+            .await;
+        assert_eq!(result.unwrap(), "Line 1");
+
+        // when start_line > end_line, should still return at least 1 line
+        let result = cx
+            .update(|cx| {
+                let input = ReadFileToolInput {
+                    path: "root/multiline.txt".to_string(),
+                    start_line: Some(3),
+                    end_line: Some(2),
+                };
+                tool.clone().run(input, event_stream.stream(), cx)
+            })
+            .await;
+        assert_eq!(result.unwrap(), "Line 3");
+    }
+
+    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);
+        });
+    }
+
+    fn rust_lang() -> Language {
+        Language::new(
+            LanguageConfig {
+                name: "Rust".into(),
+                matcher: LanguageMatcher {
+                    path_suffixes: vec!["rs".to_string()],
+                    ..Default::default()
+                },
+                ..Default::default()
+            },
+            Some(tree_sitter_rust::LANGUAGE.into()),
+        )
+        .with_outline_query(
+            r#"
+            (line_comment) @annotation
+
+            (struct_item
+                "struct" @context
+                name: (_) @name) @item
+            (enum_item
+                "enum" @context
+                name: (_) @name) @item
+            (enum_variant
+                name: (_) @name) @item
+            (field_declaration
+                name: (_) @name) @item
+            (impl_item
+                "impl" @context
+                trait: (_)? @name
+                "for"? @context
+                type: (_) @name
+                body: (_ "{" (_)* "}")) @item
+            (function_item
+                "fn" @context
+                name: (_) @name) @item
+            (mod_item
+                "mod" @context
+                name: (_) @name) @item
+            "#,
+        )
+        .unwrap()
+    }
+
+    #[gpui::test]
+    async fn test_read_file_security(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor());
+
+        fs.insert_tree(
+            path!("/"),
+            json!({
+                "project_root": {
+                    "allowed_file.txt": "This file is in the project",
+                    ".mysecrets": "SECRET_KEY=abc123",
+                    ".secretdir": {
+                        "config": "special configuration"
+                    },
+                    ".mymetadata": "custom metadata",
+                    "subdir": {
+                        "normal_file.txt": "Normal file content",
+                        "special.privatekey": "private key content",
+                        "data.mysensitive": "sensitive data"
+                    }
+                },
+                "outside_project": {
+                    "sensitive_file.txt": "This file is outside the project"
+                }
+            }),
+        )
+        .await;
+
+        cx.update(|cx| {
+            use gpui::UpdateGlobal;
+            use project::WorktreeSettings;
+            use settings::SettingsStore;
+            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(),
+                    ]);
+                    settings.private_files = Some(vec![
+                        "**/.mysecrets".to_string(),
+                        "**/*.privatekey".to_string(),
+                        "**/*.mysensitive".to_string(),
+                    ]);
+                });
+            });
+        });
+
+        let project = Project::test(fs.clone(), [path!("/project_root").as_ref()], cx).await;
+        let action_log = cx.new(|_| ActionLog::new(project.clone()));
+        let tool = Arc::new(ReadFileTool::new(project, action_log));
+        let event_stream = TestToolCallEventStream::new();
+
+        // Reading a file outside the project worktree should fail
+        let result = cx
+            .update(|cx| {
+                let input = ReadFileToolInput {
+                    path: "/outside_project/sensitive_file.txt".to_string(),
+                    start_line: None,
+                    end_line: None,
+                };
+                tool.clone().run(input, event_stream.stream(), cx)
+            })
+            .await;
+        assert!(
+            result.is_err(),
+            "read_file_tool should error when attempting to read an absolute path outside a worktree"
+        );
+
+        // Reading a file within the project should succeed
+        let result = cx
+            .update(|cx| {
+                let input = ReadFileToolInput {
+                    path: "project_root/allowed_file.txt".to_string(),
+                    start_line: None,
+                    end_line: None,
+                };
+                tool.clone().run(input, event_stream.stream(), cx)
+            })
+            .await;
+        assert!(
+            result.is_ok(),
+            "read_file_tool should be able to read files inside worktrees"
+        );
+
+        // Reading files that match file_scan_exclusions should fail
+        let result = cx
+            .update(|cx| {
+                let input = ReadFileToolInput {
+                    path: "project_root/.secretdir/config".to_string(),
+                    start_line: None,
+                    end_line: None,
+                };
+                tool.clone().run(input, event_stream.stream(), cx)
+            })
+            .await;
+        assert!(
+            result.is_err(),
+            "read_file_tool should error when attempting to read files in .secretdir (file_scan_exclusions)"
+        );
+
+        let result = cx
+            .update(|cx| {
+                let input = ReadFileToolInput {
+                    path: "project_root/.mymetadata".to_string(),
+                    start_line: None,
+                    end_line: None,
+                };
+                tool.clone().run(input, event_stream.stream(), cx)
+            })
+            .await;
+        assert!(
+            result.is_err(),
+            "read_file_tool should error when attempting to read .mymetadata files (file_scan_exclusions)"
+        );
+
+        // Reading private files should fail
+        let result = cx
+            .update(|cx| {
+                let input = ReadFileToolInput {
+                    path: "project_root/.mysecrets".to_string(),
+                    start_line: None,
+                    end_line: None,
+                };
+                tool.clone().run(input, event_stream.stream(), cx)
+            })
+            .await;
+        assert!(
+            result.is_err(),
+            "read_file_tool should error when attempting to read .mysecrets (private_files)"
+        );
+
+        let result = cx
+            .update(|cx| {
+                let input = ReadFileToolInput {
+                    path: "project_root/subdir/special.privatekey".to_string(),
+                    start_line: None,
+                    end_line: None,
+                };
+                tool.clone().run(input, event_stream.stream(), cx)
+            })
+            .await;
+        assert!(
+            result.is_err(),
+            "read_file_tool should error when attempting to read .privatekey files (private_files)"
+        );
+
+        let result = cx
+            .update(|cx| {
+                let input = ReadFileToolInput {
+                    path: "project_root/subdir/data.mysensitive".to_string(),
+                    start_line: None,
+                    end_line: None,
+                };
+                tool.clone().run(input, event_stream.stream(), cx)
+            })
+            .await;
+        assert!(
+            result.is_err(),
+            "read_file_tool should error when attempting to read .mysensitive files (private_files)"
+        );
+
+        // Reading a normal file should still work, even with private_files configured
+        let result = cx
+            .update(|cx| {
+                let input = ReadFileToolInput {
+                    path: "project_root/subdir/normal_file.txt".to_string(),
+                    start_line: None,
+                    end_line: None,
+                };
+                tool.clone().run(input, event_stream.stream(), cx)
+            })
+            .await;
+        assert!(result.is_ok(), "Should be able to read normal files");
+        assert_eq!(result.unwrap(), "Normal file content");
+
+        // Path traversal attempts with .. should fail
+        let result = cx
+            .update(|cx| {
+                let input = ReadFileToolInput {
+                    path: "project_root/../outside_project/sensitive_file.txt".to_string(),
+                    start_line: None,
+                    end_line: None,
+                };
+                tool.run(input, event_stream.stream(), cx)
+            })
+            .await;
+        assert!(
+            result.is_err(),
+            "read_file_tool should error when attempting to read a relative path that resolves to outside a worktree"
+        );
+    }
+
+    #[gpui::test]
+    async fn test_read_file_with_multiple_worktree_settings(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor());
+
+        // Create first worktree with its own private_files setting
+        fs.insert_tree(
+            path!("/worktree1"),
+            json!({
+                "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));"
+                },
+                ".zed": {
+                    "settings.json": r#"{
+                        "file_scan_exclusions": ["**/fixture.*"],
+                        "private_files": ["**/secret.rs", "**/config.toml"]
+                    }"#
+                }
+            }),
+        )
+        .await;
+
+        // Create second worktree with different private_files setting
+        fs.insert_tree(
+            path!("/worktree2"),
+            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"
+                },
+                ".zed": {
+                    "settings.json": r#"{
+                        "file_scan_exclusions": ["**/internal.*"],
+                        "private_files": ["**/private.js", "**/data.json"]
+                    }"#
+                }
+            }),
+        )
+        .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;
+
+        let action_log = cx.new(|_| ActionLog::new(project.clone()));
+        let tool = Arc::new(ReadFileTool::new(project.clone(), action_log.clone()));
+        let event_stream = TestToolCallEventStream::new();
+
+        // Test reading allowed files in worktree1
+        let result = cx
+            .update(|cx| {
+                let input = ReadFileToolInput {
+                    path: "worktree1/src/main.rs".to_string(),
+                    start_line: None,
+                    end_line: None,
+                };
+                tool.clone().run(input, event_stream.stream(), cx)
+            })
+            .await
+            .unwrap();
+
+        assert_eq!(result, "fn main() { println!(\"Hello from worktree1\"); }");
+
+        // Test reading private file in worktree1 should fail
+        let result = cx
+            .update(|cx| {
+                let input = ReadFileToolInput {
+                    path: "worktree1/src/secret.rs".to_string(),
+                    start_line: None,
+                    end_line: None,
+                };
+                tool.clone().run(input, event_stream.stream(), cx)
+            })
+            .await;
+
+        assert!(result.is_err());
+        assert!(
+            result
+                .unwrap_err()
+                .to_string()
+                .contains("worktree `private_files` setting"),
+            "Error should mention worktree private_files setting"
+        );
+
+        // Test reading excluded file in worktree1 should fail
+        let result = cx
+            .update(|cx| {
+                let input = ReadFileToolInput {
+                    path: "worktree1/tests/fixture.sql".to_string(),
+                    start_line: None,
+                    end_line: None,
+                };
+                tool.clone().run(input, event_stream.stream(), cx)
+            })
+            .await;
+
+        assert!(result.is_err());
+        assert!(
+            result
+                .unwrap_err()
+                .to_string()
+                .contains("worktree `file_scan_exclusions` setting"),
+            "Error should mention worktree file_scan_exclusions setting"
+        );
+
+        // Test reading allowed files in worktree2
+        let result = cx
+            .update(|cx| {
+                let input = ReadFileToolInput {
+                    path: "worktree2/lib/public.js".to_string(),
+                    start_line: None,
+                    end_line: None,
+                };
+                tool.clone().run(input, event_stream.stream(), cx)
+            })
+            .await
+            .unwrap();
+
+        assert_eq!(
+            result,
+            "export function greet() { return 'Hello from worktree2'; }"
+        );
+
+        // Test reading private file in worktree2 should fail
+        let result = cx
+            .update(|cx| {
+                let input = ReadFileToolInput {
+                    path: "worktree2/lib/private.js".to_string(),
+                    start_line: None,
+                    end_line: None,
+                };
+                tool.clone().run(input, event_stream.stream(), cx)
+            })
+            .await;
+
+        assert!(result.is_err());
+        assert!(
+            result
+                .unwrap_err()
+                .to_string()
+                .contains("worktree `private_files` setting"),
+            "Error should mention worktree private_files setting"
+        );
+
+        // Test reading excluded file in worktree2 should fail
+        let result = cx
+            .update(|cx| {
+                let input = ReadFileToolInput {
+                    path: "worktree2/docs/internal.md".to_string(),
+                    start_line: None,
+                    end_line: None,
+                };
+                tool.clone().run(input, event_stream.stream(), cx)
+            })
+            .await;
+
+        assert!(result.is_err());
+        assert!(
+            result
+                .unwrap_err()
+                .to_string()
+                .contains("worktree `file_scan_exclusions` setting"),
+            "Error should mention worktree file_scan_exclusions setting"
+        );
+
+        // Test that files allowed in one worktree but not in another are handled correctly
+        // (e.g., config.toml is private in worktree1 but doesn't exist in worktree2)
+        let result = cx
+            .update(|cx| {
+                let input = ReadFileToolInput {
+                    path: "worktree1/src/config.toml".to_string(),
+                    start_line: None,
+                    end_line: None,
+                };
+                tool.clone().run(input, event_stream.stream(), cx)
+            })
+            .await;
+
+        assert!(result.is_err());
+        assert!(
+            result
+                .unwrap_err()
+                .to_string()
+                .contains("worktree `private_files` setting"),
+            "Config.toml should be blocked by worktree1's private_files setting"
+        );
+    }
+}