diff --git a/crates/agent/src/context.rs b/crates/agent/src/context.rs index 71fa8176a012569373df927eb37145208d6a105d..4510b0d3d3548b3ff807a3e549a9f2dc53951452 100644 --- a/crates/agent/src/context.rs +++ b/crates/agent/src/context.rs @@ -6,7 +6,7 @@ use futures::future; use futures::{FutureExt, future::Shared}; use gpui::{App, AppContext as _, ElementId, Entity, SharedString, Task}; use icons::IconName; -use language::{Buffer, ParseStatus}; +use language::Buffer; use language_model::{LanguageModelImage, LanguageModelRequestMessage, MessageContent}; use project::{Project, ProjectEntryId, ProjectPath, Worktree}; use prompt_store::{PromptStore, UserPromptId}; @@ -191,45 +191,19 @@ impl FileContextHandle { let buffer = self.buffer.clone(); cx.spawn(async move |cx| { - // For large files, use outline instead of full content - if rope.len() > outline::AUTO_OUTLINE_SIZE { - // Wait until the buffer has been fully parsed, so we can read its outline - if let Ok(mut parse_status) = - buffer.read_with(cx, |buffer, _| buffer.parse_status()) - { - while *parse_status.borrow() != ParseStatus::Idle { - parse_status.changed().await.log_err(); - } - - if let Ok(snapshot) = buffer.read_with(cx, |buffer, _| buffer.snapshot()) { - let items = snapshot - .outline(None) - .items - .into_iter() - .map(|item| item.to_point(&snapshot)); - - if let Ok(outline_text) = - outline::render_outline(items, None, 0, usize::MAX).await - { - let context = AgentContext::File(FileContext { - handle: self, - full_path, - text: outline_text.into(), - is_outline: true, - }); - return Some((context, vec![buffer])); - } - } - } - } + let buffer_content = + outline::get_buffer_content_or_outline(buffer.clone(), Some(&full_path), &cx) + .await + .unwrap_or_else(|_| outline::BufferContent { + text: rope.to_string(), + is_outline: false, + }); - // Fallback to full content if we couldn't build an outline - // (or didn't need to because the file was small enough) let context = AgentContext::File(FileContext { handle: self, full_path, - text: rope.to_string().into(), - is_outline: false, + text: buffer_content.text.into(), + is_outline: buffer_content.is_outline, }); Some((context, vec![buffer])) }) diff --git a/crates/agent2/src/tools/read_file_tool.rs b/crates/agent2/src/tools/read_file_tool.rs index 99f145901c664624d66d7487cce579f55cff908a..09f2ff3ca9f5ecb30189c7d1625c1728a8ff3b11 100644 --- a/crates/agent2/src/tools/read_file_tool.rs +++ b/crates/agent2/src/tools/read_file_tool.rs @@ -147,7 +147,7 @@ impl AgentTool for ReadFileTool { event_stream.update_fields(ToolCallUpdateFields { locations: Some(vec![acp::ToolCallLocation { - path: abs_path, + path: abs_path.clone(), line: input.start_line.map(|line| line.saturating_sub(1)), }]), ..Default::default() @@ -225,38 +225,30 @@ impl AgentTool for ReadFileTool { Ok(result.into()) } 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())?; + let buffer_content = + outline::get_buffer_content_or_outline(buffer.clone(), Some(&abs_path), cx) + .await?; - 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.clone(), cx); - })?; + action_log.update(cx, |log, cx| { + log.buffer_read(buffer.clone(), cx); + })?; - Ok(result.into()) - } else { - // File is too big, so return the outline - // and a suggestion to read again with line numbers. - let outline = - outline::file_outline(project.clone(), file_path, action_log, None, cx) - .await?; + if buffer_content.is_outline { 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." + to search the file for specific content.", buffer_content.text } .into()) + } else { + Ok(buffer_content.text.into()) } }; diff --git a/crates/agent_ui/src/acp/message_editor.rs b/crates/agent_ui/src/acp/message_editor.rs index 02ee46e840299b9307253603f3c165bbd525d377..2c14e284d79534168dfbaad3f524a6b05af713d4 100644 --- a/crates/agent_ui/src/acp/message_editor.rs +++ b/crates/agent_ui/src/acp/message_editor.rs @@ -8,6 +8,7 @@ use agent_servers::{AgentServer, AgentServerDelegate}; use agent2::HistoryStore; use anyhow::{Result, anyhow}; use assistant_slash_commands::codeblock_fence_for_path; +use assistant_tool::outline; use collections::{HashMap, HashSet}; use editor::{ Addon, Anchor, AnchorRangeExt, ContextMenuOptions, ContextMenuPlacement, Editor, EditorElement, @@ -456,11 +457,14 @@ impl MessageEditor { .update(cx, |project, cx| project.open_buffer(project_path, cx)); cx.spawn(async move |_, cx| { let buffer = buffer.await?; - let mention = buffer.update(cx, |buffer, cx| Mention::Text { - content: buffer.text(), - tracked_buffers: vec![cx.entity()], - })?; - anyhow::Ok(mention) + let buffer_content = + outline::get_buffer_content_or_outline(buffer.clone(), Some(&abs_path), &cx) + .await?; + + Ok(Mention::Text { + content: buffer_content.text, + tracked_buffers: vec![buffer], + }) }) } @@ -520,18 +524,17 @@ impl MessageEditor { }) }); - // TODO: report load errors instead of just logging - let rope_task = cx.spawn(async move |cx| { + cx.spawn(async move |cx| { let buffer = open_task.await.log_err()?; - let rope = buffer - .read_with(cx, |buffer, _cx| buffer.as_rope().clone()) - .log_err()?; - Some((rope, buffer)) - }); + let buffer_content = outline::get_buffer_content_or_outline( + buffer.clone(), + Some(&full_path), + &cx, + ) + .await + .ok()?; - cx.background_spawn(async move { - let (rope, buffer) = rope_task.await?; - Some((rel_path, full_path, rope.to_string(), buffer)) + Some((rel_path, full_path, buffer_content.text, buffer)) }) })) })?; @@ -1580,6 +1583,7 @@ mod tests { use agent_client_protocol as acp; use agent2::HistoryStore; use assistant_context::ContextStore; + use assistant_tool::outline; use editor::{AnchorRangeExt as _, Editor, EditorMode}; use fs::FakeFs; use futures::StreamExt as _; @@ -2584,4 +2588,109 @@ mod tests { }) .collect::>() } + + #[gpui::test] + async fn test_large_file_mention_uses_outline(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + + // Create a large file that exceeds AUTO_OUTLINE_SIZE + const LINE: &str = "fn example_function() { /* some code */ }\n"; + let large_content = LINE.repeat(2 * (outline::AUTO_OUTLINE_SIZE / LINE.len())); + assert!(large_content.len() > outline::AUTO_OUTLINE_SIZE); + + // Create a small file that doesn't exceed AUTO_OUTLINE_SIZE + let small_content = "fn small_function() { /* small */ }\n"; + assert!(small_content.len() < outline::AUTO_OUTLINE_SIZE); + + fs.insert_tree( + "/project", + json!({ + "large_file.rs": large_content.clone(), + "small_file.rs": small_content, + }), + ) + .await; + + let project = Project::test(fs, [Path::new(path!("/project"))], cx).await; + + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let context_store = cx.new(|cx| ContextStore::fake(project.clone(), cx)); + let history_store = cx.new(|cx| HistoryStore::new(context_store, cx)); + + let message_editor = cx.update(|window, cx| { + cx.new(|cx| { + let editor = MessageEditor::new( + workspace.downgrade(), + project.clone(), + history_store.clone(), + None, + Default::default(), + Default::default(), + "Test Agent".into(), + "Test", + EditorMode::AutoHeight { + min_lines: 1, + max_lines: None, + }, + window, + cx, + ); + // Enable embedded context so files are actually included + editor.prompt_capabilities.set(acp::PromptCapabilities { + embedded_context: true, + ..Default::default() + }); + editor + }) + }); + + // Test large file mention + // Get the absolute path using the project's worktree + let large_file_abs_path = project.read_with(cx, |project, cx| { + let worktree = project.worktrees(cx).next().unwrap(); + let worktree_root = worktree.read(cx).abs_path(); + worktree_root.join("large_file.rs") + }); + let large_file_task = message_editor.update(cx, |editor, cx| { + editor.confirm_mention_for_file(large_file_abs_path, cx) + }); + + let large_file_mention = large_file_task.await.unwrap(); + match large_file_mention { + Mention::Text { content, .. } => { + // Should contain outline header for large files + assert!(content.contains("File outline for")); + assert!(content.contains("file too large to show full content")); + // Should not contain the full repeated content + assert!(!content.contains(&LINE.repeat(100))); + } + _ => panic!("Expected Text mention for large file"), + } + + // Test small file mention + // Get the absolute path using the project's worktree + let small_file_abs_path = project.read_with(cx, |project, cx| { + let worktree = project.worktrees(cx).next().unwrap(); + let worktree_root = worktree.read(cx).abs_path(); + worktree_root.join("small_file.rs") + }); + let small_file_task = message_editor.update(cx, |editor, cx| { + editor.confirm_mention_for_file(small_file_abs_path, cx) + }); + + let small_file_mention = small_file_task.await.unwrap(); + match small_file_mention { + Mention::Text { content, .. } => { + // Should contain the actual content + assert_eq!(content, small_content); + // Should not contain outline header + assert!(!content.contains("File outline for")); + } + _ => panic!("Expected Text mention for small file"), + } + } } diff --git a/crates/assistant_tool/src/outline.rs b/crates/assistant_tool/src/outline.rs index d9bf64cf059a33f1cc4e6d2833e77a0554b82d93..fa5ad561eea94d92568ee36ab8835f8a87337314 100644 --- a/crates/assistant_tool/src/outline.rs +++ b/crates/assistant_tool/src/outline.rs @@ -1,10 +1,11 @@ use action_log::ActionLog; use anyhow::{Context as _, Result}; use gpui::{AsyncApp, Entity}; -use language::{OutlineItem, ParseStatus}; +use language::{Buffer, OutlineItem, ParseStatus}; use project::Project; use regex::Regex; use std::fmt::Write; +use std::path::Path; use text::Point; /// For files over this size, instead of reading them (or including them in context), @@ -128,3 +129,67 @@ fn render_entries( entries_rendered } + +/// Result of getting buffer content, which can be either full content or an outline. +pub struct BufferContent { + /// The actual content (either full text or outline) + pub text: String, + /// Whether this is an outline (true) or full content (false) + pub is_outline: bool, +} + +/// Returns either the full content of a buffer or its outline, depending on size. +/// For files larger than AUTO_OUTLINE_SIZE, returns an outline with a header. +/// For smaller files, returns the full content. +pub async fn get_buffer_content_or_outline( + buffer: Entity, + path: Option<&Path>, + cx: &AsyncApp, +) -> Result { + let file_size = buffer.read_with(cx, |buffer, _| buffer.text().len())?; + + if file_size > AUTO_OUTLINE_SIZE { + // For large files, use outline instead of full content + // Wait until the buffer has been fully parsed, so we can read its outline + let mut parse_status = buffer.read_with(cx, |buffer, _| buffer.parse_status())?; + while *parse_status.borrow() != ParseStatus::Idle { + parse_status.changed().await?; + } + + let outline_items = buffer.read_with(cx, |buffer, _| { + let snapshot = buffer.snapshot(); + snapshot + .outline(None) + .items + .into_iter() + .map(|item| item.to_point(&snapshot)) + .collect::>() + })?; + + let outline_text = render_outline(outline_items, None, 0, usize::MAX).await?; + + let text = if let Some(path) = path { + format!( + "# File outline for {} (file too large to show full content)\n\n{}", + path.display(), + outline_text + ) + } else { + format!( + "# File outline (file too large to show full content)\n\n{}", + outline_text + ) + }; + Ok(BufferContent { + text, + is_outline: true, + }) + } else { + // File is small enough, return full content + let text = buffer.read_with(cx, |buffer, _| buffer.text())?; + Ok(BufferContent { + text, + is_outline: false, + }) + } +} diff --git a/crates/assistant_tools/src/read_file_tool.rs b/crates/assistant_tools/src/read_file_tool.rs index a6e984fca6f2704a6dbe4c16d5e659f0c8bfe141..7222f061c7caba54ee2e3294378c4a7d957914f5 100644 --- a/crates/assistant_tools/src/read_file_tool.rs +++ b/crates/assistant_tools/src/read_file_tool.rs @@ -261,37 +261,31 @@ impl Tool for ReadFileTool { 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())?; + let path_buf = std::path::PathBuf::from(&file_path); + let buffer_content = + outline::get_buffer_content_or_outline(buffer.clone(), Some(&path_buf), cx) + .await?; - 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); - })?; + action_log.update(cx, |log, cx| { + log.buffer_read(buffer, cx); + })?; - Ok(result.into()) - } 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?; + if buffer_content.is_outline { 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." + to search the file for specific content.", buffer_content.text } .into()) + } else { + Ok(buffer_content.text.into()) } } })