acp: Include only path to @mentioned directory in user message (#37942)

Agus Zubiaga created

Nowadays, people don't expect @-mentioning a directory to include the
contents of all files within it. Doing so makes it very likely to
consume an undesirable amount of tokens.

By default, we'll now only include the path of the directory and let the
model decide how much to read via tools. We'll still include the
contents if no tools are available (e.g. "Minimal" profile is selected).

Release Notes:

- Agent Panel: Do not include the content of @-mentioned directories
when tools are available

Change summary

crates/agent_ui/src/acp/message_editor.rs | 277 +++++++++++++-----------
crates/agent_ui/src/acp/thread_view.rs    |  30 +-
2 files changed, 170 insertions(+), 137 deletions(-)

Detailed changes

crates/agent_ui/src/acp/message_editor.rs 🔗

@@ -364,7 +364,7 @@ impl MessageEditor {
 
         let task = match mention_uri.clone() {
             MentionUri::Fetch { url } => self.confirm_mention_for_fetch(url, cx),
-            MentionUri::Directory { abs_path } => self.confirm_mention_for_directory(abs_path, cx),
+            MentionUri::Directory { .. } => Task::ready(Ok(Mention::UriOnly)),
             MentionUri::Thread { id, .. } => self.confirm_mention_for_thread(id, cx),
             MentionUri::TextThread { path, .. } => self.confirm_mention_for_text_thread(path, cx),
             MentionUri::File { abs_path } => self.confirm_mention_for_file(abs_path, cx),
@@ -468,97 +468,6 @@ impl MessageEditor {
         })
     }
 
-    fn confirm_mention_for_directory(
-        &mut self,
-        abs_path: PathBuf,
-        cx: &mut Context<Self>,
-    ) -> Task<Result<Mention>> {
-        fn collect_files_in_path(worktree: &Worktree, path: &Path) -> Vec<(Arc<Path>, PathBuf)> {
-            let mut files = Vec::new();
-
-            for entry in worktree.child_entries(path) {
-                if entry.is_dir() {
-                    files.extend(collect_files_in_path(worktree, &entry.path));
-                } else if entry.is_file() {
-                    files.push((entry.path.clone(), worktree.full_path(&entry.path)));
-                }
-            }
-
-            files
-        }
-
-        let Some(project_path) = self
-            .project
-            .read(cx)
-            .project_path_for_absolute_path(&abs_path, cx)
-        else {
-            return Task::ready(Err(anyhow!("project path not found")));
-        };
-        let Some(entry) = self.project.read(cx).entry_for_path(&project_path, cx) else {
-            return Task::ready(Err(anyhow!("project entry not found")));
-        };
-        let directory_path = entry.path.clone();
-        let worktree_id = project_path.worktree_id;
-        let Some(worktree) = self.project.read(cx).worktree_for_id(worktree_id, cx) else {
-            return Task::ready(Err(anyhow!("worktree not found")));
-        };
-        let project = self.project.clone();
-        cx.spawn(async move |_, cx| {
-            let file_paths = worktree.read_with(cx, |worktree, _cx| {
-                collect_files_in_path(worktree, &directory_path)
-            })?;
-            let descendants_future = cx.update(|cx| {
-                join_all(file_paths.into_iter().map(|(worktree_path, full_path)| {
-                    let rel_path = worktree_path
-                        .strip_prefix(&directory_path)
-                        .log_err()
-                        .map_or_else(|| worktree_path.clone(), |rel_path| rel_path.into());
-
-                    let open_task = project.update(cx, |project, cx| {
-                        project.buffer_store().update(cx, |buffer_store, cx| {
-                            let project_path = ProjectPath {
-                                worktree_id,
-                                path: worktree_path,
-                            };
-                            buffer_store.open_buffer(project_path, cx)
-                        })
-                    });
-
-                    cx.spawn(async move |cx| {
-                        let buffer = open_task.await.log_err()?;
-                        let buffer_content = outline::get_buffer_content_or_outline(
-                            buffer.clone(),
-                            Some(&full_path),
-                            &cx,
-                        )
-                        .await
-                        .ok()?;
-
-                        Some((rel_path, full_path, buffer_content.text, buffer))
-                    })
-                }))
-            })?;
-
-            let contents = cx
-                .background_spawn(async move {
-                    let (contents, tracked_buffers) = descendants_future
-                        .await
-                        .into_iter()
-                        .flatten()
-                        .map(|(rel_path, full_path, rope, buffer)| {
-                            ((rel_path, full_path, rope), buffer)
-                        })
-                        .unzip();
-                    Mention::Text {
-                        content: render_directory_contents(contents),
-                        tracked_buffers,
-                    }
-                })
-                .await;
-            anyhow::Ok(contents)
-        })
-    }
-
     fn confirm_mention_for_fetch(
         &mut self,
         url: url::Url,
@@ -776,6 +685,7 @@ impl MessageEditor {
 
     pub fn contents(
         &self,
+        full_mention_content: bool,
         cx: &mut Context<Self>,
     ) -> Task<Result<(Vec<acp::ContentBlock>, Vec<Entity<Buffer>>)>> {
         // Check for unsupported slash commands before spawning async task
@@ -787,9 +697,12 @@ impl MessageEditor {
             return Task::ready(Err(err));
         }
 
-        let contents = self
-            .mention_set
-            .contents(&self.prompt_capabilities.borrow(), cx);
+        let contents = self.mention_set.contents(
+            &self.prompt_capabilities.borrow(),
+            full_mention_content,
+            self.project.clone(),
+            cx,
+        );
         let editor = self.editor.clone();
 
         cx.spawn(async move |_, cx| {
@@ -1263,6 +1176,96 @@ impl MessageEditor {
     }
 }
 
+fn full_mention_for_directory(
+    project: &Entity<Project>,
+    abs_path: &Path,
+    cx: &mut App,
+) -> Task<Result<Mention>> {
+    fn collect_files_in_path(worktree: &Worktree, path: &Path) -> Vec<(Arc<Path>, PathBuf)> {
+        let mut files = Vec::new();
+
+        for entry in worktree.child_entries(path) {
+            if entry.is_dir() {
+                files.extend(collect_files_in_path(worktree, &entry.path));
+            } else if entry.is_file() {
+                files.push((entry.path.clone(), worktree.full_path(&entry.path)));
+            }
+        }
+
+        files
+    }
+
+    let Some(project_path) = project
+        .read(cx)
+        .project_path_for_absolute_path(&abs_path, cx)
+    else {
+        return Task::ready(Err(anyhow!("project path not found")));
+    };
+    let Some(entry) = project.read(cx).entry_for_path(&project_path, cx) else {
+        return Task::ready(Err(anyhow!("project entry not found")));
+    };
+    let directory_path = entry.path.clone();
+    let worktree_id = project_path.worktree_id;
+    let Some(worktree) = project.read(cx).worktree_for_id(worktree_id, cx) else {
+        return Task::ready(Err(anyhow!("worktree not found")));
+    };
+    let project = project.clone();
+    cx.spawn(async move |cx| {
+        let file_paths = worktree.read_with(cx, |worktree, _cx| {
+            collect_files_in_path(worktree, &directory_path)
+        })?;
+        let descendants_future = cx.update(|cx| {
+            join_all(file_paths.into_iter().map(|(worktree_path, full_path)| {
+                let rel_path = worktree_path
+                    .strip_prefix(&directory_path)
+                    .log_err()
+                    .map_or_else(|| worktree_path.clone(), |rel_path| rel_path.into());
+
+                let open_task = project.update(cx, |project, cx| {
+                    project.buffer_store().update(cx, |buffer_store, cx| {
+                        let project_path = ProjectPath {
+                            worktree_id,
+                            path: worktree_path,
+                        };
+                        buffer_store.open_buffer(project_path, cx)
+                    })
+                });
+
+                cx.spawn(async move |cx| {
+                    let buffer = open_task.await.log_err()?;
+                    let buffer_content = outline::get_buffer_content_or_outline(
+                        buffer.clone(),
+                        Some(&full_path),
+                        &cx,
+                    )
+                    .await
+                    .ok()?;
+
+                    Some((rel_path, full_path, buffer_content.text, buffer))
+                })
+            }))
+        })?;
+
+        let contents = cx
+            .background_spawn(async move {
+                let (contents, tracked_buffers) = descendants_future
+                    .await
+                    .into_iter()
+                    .flatten()
+                    .map(|(rel_path, full_path, rope, buffer)| {
+                        ((rel_path, full_path, rope), buffer)
+                    })
+                    .unzip();
+                Mention::Text {
+                    content: render_directory_contents(contents),
+                    tracked_buffers,
+                }
+            })
+            .await;
+        anyhow::Ok(contents)
+    })
+}
+
 fn render_directory_contents(entries: Vec<(Arc<Path>, PathBuf, String)>) -> String {
     let mut output = String::new();
     for (_relative_path, full_path, content) in entries {
@@ -1514,6 +1517,8 @@ impl MentionSet {
     fn contents(
         &self,
         prompt_capabilities: &acp::PromptCapabilities,
+        full_mention_content: bool,
+        project: Entity<Project>,
         cx: &mut App,
     ) -> Task<Result<HashMap<CreaseId, (MentionUri, Mention)>>> {
         if !prompt_capabilities.embedded_context {
@@ -1527,13 +1532,19 @@ impl MentionSet {
         }
 
         let mentions = self.mentions.clone();
-        cx.spawn(async move |_cx| {
+        cx.spawn(async move |cx| {
             let mut contents = HashMap::default();
             for (crease_id, (mention_uri, task)) in mentions {
-                contents.insert(
-                    crease_id,
-                    (mention_uri, task.await.map_err(|e| anyhow!("{e}"))?),
-                );
+                let content = if full_mention_content
+                    && let MentionUri::Directory { abs_path } = &mention_uri
+                {
+                    cx.update(|cx| full_mention_for_directory(&project, abs_path, cx))?
+                        .await?
+                } else {
+                    task.await.map_err(|e| anyhow!("{e}"))?
+                };
+
+                contents.insert(crease_id, (mention_uri, content));
             }
             Ok(contents)
         })
@@ -1694,7 +1705,7 @@ mod tests {
         });
 
         let (content, _) = message_editor
-            .update(cx, |message_editor, cx| message_editor.contents(cx))
+            .update(cx, |message_editor, cx| message_editor.contents(false, cx))
             .await
             .unwrap();
 
@@ -1757,7 +1768,7 @@ mod tests {
         });
 
         let contents_result = message_editor
-            .update(cx, |message_editor, cx| message_editor.contents(cx))
+            .update(cx, |message_editor, cx| message_editor.contents(false, cx))
             .await;
 
         // Should fail because available_commands is empty (no commands supported)
@@ -1780,7 +1791,7 @@ mod tests {
         });
 
         let contents_result = message_editor
-            .update(cx, |message_editor, cx| message_editor.contents(cx))
+            .update(cx, |message_editor, cx| message_editor.contents(false, cx))
             .await;
 
         assert!(contents_result.is_err());
@@ -1795,7 +1806,7 @@ mod tests {
         });
 
         let contents_result = message_editor
-            .update(cx, |message_editor, cx| message_editor.contents(cx))
+            .update(cx, |message_editor, cx| message_editor.contents(false, cx))
             .await;
 
         // Should succeed because /help is in available_commands
@@ -1807,7 +1818,7 @@ mod tests {
         });
 
         let (content, _) = message_editor
-            .update(cx, |message_editor, cx| message_editor.contents(cx))
+            .update(cx, |message_editor, cx| message_editor.contents(false, cx))
             .await
             .unwrap();
 
@@ -1825,7 +1836,7 @@ mod tests {
 
         // The @ mention functionality should not be affected
         let (content, _) = message_editor
-            .update(cx, |message_editor, cx| message_editor.contents(cx))
+            .update(cx, |message_editor, cx| message_editor.contents(false, cx))
             .await
             .unwrap();
 
@@ -2271,9 +2282,12 @@ mod tests {
 
         let contents = message_editor
             .update(&mut cx, |message_editor, cx| {
-                message_editor
-                    .mention_set()
-                    .contents(&all_prompt_capabilities, cx)
+                message_editor.mention_set().contents(
+                    &all_prompt_capabilities,
+                    false,
+                    project.clone(),
+                    cx,
+                )
             })
             .await
             .unwrap()
@@ -2290,9 +2304,12 @@ mod tests {
 
         let contents = message_editor
             .update(&mut cx, |message_editor, cx| {
-                message_editor
-                    .mention_set()
-                    .contents(&acp::PromptCapabilities::default(), cx)
+                message_editor.mention_set().contents(
+                    &acp::PromptCapabilities::default(),
+                    false,
+                    project.clone(),
+                    cx,
+                )
             })
             .await
             .unwrap()
@@ -2341,9 +2358,12 @@ mod tests {
 
         let contents = message_editor
             .update(&mut cx, |message_editor, cx| {
-                message_editor
-                    .mention_set()
-                    .contents(&all_prompt_capabilities, cx)
+                message_editor.mention_set().contents(
+                    &all_prompt_capabilities,
+                    false,
+                    project.clone(),
+                    cx,
+                )
             })
             .await
             .unwrap()
@@ -2451,9 +2471,12 @@ mod tests {
 
         let contents = message_editor
             .update(&mut cx, |message_editor, cx| {
-                message_editor
-                    .mention_set()
-                    .contents(&all_prompt_capabilities, cx)
+                message_editor.mention_set().contents(
+                    &all_prompt_capabilities,
+                    false,
+                    project.clone(),
+                    cx,
+                )
             })
             .await
             .unwrap()
@@ -2501,9 +2524,12 @@ mod tests {
         // Getting the message contents fails
         message_editor
             .update(&mut cx, |message_editor, cx| {
-                message_editor
-                    .mention_set()
-                    .contents(&all_prompt_capabilities, cx)
+                message_editor.mention_set().contents(
+                    &all_prompt_capabilities,
+                    false,
+                    project.clone(),
+                    cx,
+                )
             })
             .await
             .expect_err("Should fail to load x.png");
@@ -2548,9 +2574,12 @@ mod tests {
         // Now getting the contents succeeds, because the invalid mention was removed
         let contents = message_editor
             .update(&mut cx, |message_editor, cx| {
-                message_editor
-                    .mention_set()
-                    .contents(&all_prompt_capabilities, cx)
+                message_editor.mention_set().contents(
+                    &all_prompt_capabilities,
+                    false,
+                    project.clone(),
+                    cx,
+                )
             })
             .await
             .unwrap();

crates/agent_ui/src/acp/thread_view.rs 🔗

@@ -1038,10 +1038,7 @@ impl AcpThreadView {
             return;
         }
 
-        let contents = self
-            .message_editor
-            .update(cx, |message_editor, cx| message_editor.contents(cx));
-        self.send_impl(contents, window, cx)
+        self.send_impl(self.message_editor.clone(), window, cx)
     }
 
     fn stop_current_and_send_new_message(&mut self, window: &mut Window, cx: &mut Context<Self>) {
@@ -1051,15 +1048,11 @@ impl AcpThreadView {
 
         let cancelled = thread.update(cx, |thread, cx| thread.cancel(cx));
 
-        let contents = self
-            .message_editor
-            .update(cx, |message_editor, cx| message_editor.contents(cx));
-
         cx.spawn_in(window, async move |this, cx| {
             cancelled.await;
 
             this.update_in(cx, |this, window, cx| {
-                this.send_impl(contents, window, cx);
+                this.send_impl(this.message_editor.clone(), window, cx);
             })
             .ok();
         })
@@ -1068,10 +1061,23 @@ impl AcpThreadView {
 
     fn send_impl(
         &mut self,
-        contents: Task<Result<(Vec<acp::ContentBlock>, Vec<Entity<Buffer>>)>>,
+        message_editor: Entity<MessageEditor>,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
+        let full_mention_content = self.as_native_thread(cx).is_some_and(|thread| {
+            // Include full contents when using minimal profile
+            let thread = thread.read(cx);
+            AgentSettings::get_global(cx)
+                .profiles
+                .get(thread.profile())
+                .is_some_and(|profile| profile.tools.is_empty())
+        });
+
+        let contents = message_editor.update(cx, |message_editor, cx| {
+            message_editor.contents(full_mention_content, cx)
+        });
+
         let agent_telemetry_id = self.agent.telemetry_id();
 
         self.thread_error.take();
@@ -1200,10 +1206,8 @@ impl AcpThreadView {
             thread
                 .update(cx, |thread, cx| thread.rewind(user_message_id, cx))?
                 .await?;
-            let contents =
-                message_editor.update(cx, |message_editor, cx| message_editor.contents(cx))?;
             this.update_in(cx, |this, window, cx| {
-                this.send_impl(contents, window, cx);
+                this.send_impl(message_editor, window, cx);
             })?;
             anyhow::Ok(())
         })