1use std::sync::Arc;
2
3use crate::schema::json_schema_for;
4use anyhow::{Result, anyhow};
5use assistant_tool::{ActionLog, Tool, ToolResult, outline};
6use gpui::{AnyWindowHandle, App, Entity, Task};
7use itertools::Itertools;
8use language_model::{LanguageModelRequestMessage, LanguageModelToolSchemaFormat};
9use project::Project;
10use schemars::JsonSchema;
11use serde::{Deserialize, Serialize};
12use std::{fmt::Write, path::Path};
13use ui::IconName;
14use util::markdown::MarkdownInlineCode;
15
16/// If the model requests to read a file whose size exceeds this, then
17/// If the model requests to list the entries in a directory with more
18/// entries than this, then the tool will return a subset of the entries
19/// and suggest trying again.
20const MAX_DIR_ENTRIES: usize = 1024;
21
22#[derive(Debug, Serialize, Deserialize, JsonSchema)]
23pub struct ContentsToolInput {
24 /// The relative path of the file or directory to access.
25 ///
26 /// This path should never be absolute, and the first component
27 /// of the path should always be a root directory in a project.
28 ///
29 /// <example>
30 /// If the project has the following root directories:
31 ///
32 /// - directory1
33 /// - directory2
34 ///
35 /// If you want to access `file.txt` in `directory1`, you should use the path `directory1/file.txt`.
36 /// If you want to list contents in the directory `directory2/subfolder`, you should use the path `directory2/subfolder`.
37 /// </example>
38 pub path: String,
39
40 /// Optional position (1-based index) to start reading on, if you want to read a subset of the contents.
41 /// When reading a file, this refers to a line number in the file (e.g. 1 is the first line).
42 /// When reading a directory, this refers to the number of the directory entry (e.g. 1 is the first entry).
43 ///
44 /// Defaults to 1.
45 pub start: Option<u32>,
46
47 /// Optional position (1-based index) to end reading on, if you want to read a subset of the contents.
48 /// When reading a file, this refers to a line number in the file (e.g. 1 is the first line).
49 /// When reading a directory, this refers to the number of the directory entry (e.g. 1 is the first entry).
50 ///
51 /// Defaults to reading until the end of the file or directory.
52 pub end: Option<u32>,
53}
54
55pub struct ContentsTool;
56
57impl Tool for ContentsTool {
58 fn name(&self) -> String {
59 "contents".into()
60 }
61
62 fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
63 false
64 }
65
66 fn description(&self) -> String {
67 include_str!("./contents_tool/description.md").into()
68 }
69
70 fn icon(&self) -> IconName {
71 IconName::FileSearch
72 }
73
74 fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result<serde_json::Value> {
75 json_schema_for::<ContentsToolInput>(format)
76 }
77
78 fn ui_text(&self, input: &serde_json::Value) -> String {
79 match serde_json::from_value::<ContentsToolInput>(input.clone()) {
80 Ok(input) => {
81 let path = MarkdownInlineCode(&input.path);
82
83 match (input.start, input.end) {
84 (Some(start), None) => format!("Read {path} (from line {start})"),
85 (Some(start), Some(end)) => {
86 format!("Read {path} (lines {start}-{end})")
87 }
88 _ => format!("Read {path}"),
89 }
90 }
91 Err(_) => "Read file or directory".to_string(),
92 }
93 }
94
95 fn run(
96 self: Arc<Self>,
97 input: serde_json::Value,
98 _messages: &[LanguageModelRequestMessage],
99 project: Entity<Project>,
100 action_log: Entity<ActionLog>,
101 _window: Option<AnyWindowHandle>,
102 cx: &mut App,
103 ) -> ToolResult {
104 let input = match serde_json::from_value::<ContentsToolInput>(input) {
105 Ok(input) => input,
106 Err(err) => return Task::ready(Err(anyhow!(err))).into(),
107 };
108
109 // Sometimes models will return these even though we tell it to give a path and not a glob.
110 // When this happens, just list the root worktree directories.
111 if matches!(input.path.as_str(), "." | "" | "./" | "*") {
112 let output = project
113 .read(cx)
114 .worktrees(cx)
115 .filter_map(|worktree| {
116 worktree.read(cx).root_entry().and_then(|entry| {
117 if entry.is_dir() {
118 entry.path.to_str()
119 } else {
120 None
121 }
122 })
123 })
124 .collect::<Vec<_>>()
125 .join("\n");
126
127 return Task::ready(Ok(output)).into();
128 }
129
130 let Some(project_path) = project.read(cx).find_project_path(&input.path, cx) else {
131 return Task::ready(Err(anyhow!("Path {} not found in project", &input.path))).into();
132 };
133
134 let Some(worktree) = project
135 .read(cx)
136 .worktree_for_id(project_path.worktree_id, cx)
137 else {
138 return Task::ready(Err(anyhow!("Worktree not found"))).into();
139 };
140 let worktree = worktree.read(cx);
141
142 let Some(entry) = worktree.entry_for_path(&project_path.path) else {
143 return Task::ready(Err(anyhow!("Path not found: {}", input.path))).into();
144 };
145
146 // If it's a directory, list its contents
147 if entry.is_dir() {
148 let mut output = String::new();
149 let start_index = input
150 .start
151 .map(|line| (line as usize).saturating_sub(1))
152 .unwrap_or(0);
153 let end_index = input
154 .end
155 .map(|line| (line as usize).saturating_sub(1))
156 .unwrap_or(MAX_DIR_ENTRIES);
157 let mut skipped = 0;
158
159 for (index, entry) in worktree.child_entries(&project_path.path).enumerate() {
160 if index >= start_index && index <= end_index {
161 writeln!(
162 output,
163 "{}",
164 Path::new(worktree.root_name()).join(&entry.path).display(),
165 )
166 .unwrap();
167 } else {
168 skipped += 1;
169 }
170 }
171
172 if output.is_empty() {
173 output.push_str(&input.path);
174 output.push_str(" is empty.");
175 }
176
177 if skipped > 0 {
178 write!(
179 output,
180 "\n\nNote: Skipped {skipped} entries. Adjust start and end to see other entries.",
181 ).ok();
182 }
183
184 Task::ready(Ok(output)).into()
185 } else {
186 // It's a file, so read its contents
187 let file_path = input.path.clone();
188 cx.spawn(async move |cx| {
189 let buffer = cx
190 .update(|cx| {
191 project.update(cx, |project, cx| project.open_buffer(project_path, cx))
192 })?
193 .await?;
194
195 if input.start.is_some() || input.end.is_some() {
196 let result = buffer.read_with(cx, |buffer, _cx| {
197 let text = buffer.text();
198 let start = input.start.unwrap_or(1);
199 let lines = text.split('\n').skip(start as usize - 1);
200 if let Some(end) = input.end {
201 let count = end.saturating_sub(start).max(1); // Ensure at least 1 line
202 Itertools::intersperse(lines.take(count as usize), "\n").collect()
203 } else {
204 Itertools::intersperse(lines, "\n").collect()
205 }
206 })?;
207
208 action_log.update(cx, |log, cx| {
209 log.track_buffer(buffer, cx);
210 })?;
211
212 Ok(result)
213 } else {
214 // No line ranges specified, so check file size to see if it's too big.
215 let file_size = buffer.read_with(cx, |buffer, _cx| buffer.text().len())?;
216
217 if file_size <= outline::AUTO_OUTLINE_SIZE {
218 let result = buffer.read_with(cx, |buffer, _cx| buffer.text())?;
219
220 action_log.update(cx, |log, cx| {
221 log.track_buffer(buffer, cx);
222 })?;
223
224 Ok(result)
225 } else {
226 // File is too big, so return its outline and a suggestion to
227 // read again with a line number range specified.
228 let outline = outline::file_outline(project, file_path, action_log, None, cx).await?;
229
230 Ok(format!("This file was too big to read all at once. Here is an outline of its symbols:\n\n{outline}\n\nUsing the line numbers in this outline, you can call this tool again while specifying the start and end fields to see the implementations of symbols in the outline."))
231 }
232 }
233 }).into()
234 }
235 }
236}