release/1.3.1
rs 456 lines 16.6 KB
Raw
1 //! Workspace tools: `read_file`, `write_file`, `edit_file`, `list_directory` and
2 //! `run_command`, routed through the ACP client (`fs/*`, `terminal/*`) when it advertises
3 //! support and falling back to the local filesystem and `sh -c` otherwise. Every tool takes an
4 //! optional `root` for multi-root workspaces. Writes and commands ask for approval.
5
6 use std::path::Path;
7
8 use agent_client_protocol::ErrorCode;
9 use agent_client_protocol::schema::v1::{
10 CreateTerminalRequest, Diff, KillTerminalRequest, ReadTextFileRequest, ReleaseTerminalRequest,
11 Terminal, TerminalOutputRequest, ToolCallContent, ToolCallLocation, ToolCallUpdateFields,
12 ToolKind, WaitForTerminalExitRequest, WriteTextFileRequest,
13 };
14 use anyhow::{Context, Result, anyhow, bail};
15 use async_trait::async_trait;
16 use serde::Deserialize;
17 use serde_json::{Value, json};
18
19 use crate::tools::{
20 DescribeCtx, MAX_OUTPUT_BYTES, ToolCtx, ToolOutcome, Toolset, absolutize, base_for,
21 function_def, resolve_in, truncate,
22 };
23
24 /// The workspace tools as a [`Toolset`].
25 #[derive(Debug, Default, Clone, Copy)]
26 pub struct WorkspaceTools;
27
28 const NAMES: &[&str] = &[
29 "read_file",
30 "write_file",
31 "edit_file",
32 "list_directory",
33 "run_command",
34 ];
35
36 fn tool_definitions() -> Vec<Value> {
37 let f = |name: &str, desc: &str, params: Value| function_def(name, desc, params);
38 let root_prop = || {
39 json!({
40 "type": "string",
41 "description": "Workspace root to act in: absolute path of a session root or a directory beneath one. Defaults to the primary working directory."
42 })
43 };
44 vec![
45 f(
46 "read_file",
47 "Read a text file. Paths may be relative to the working directory.",
48 json!({
49 "type": "object",
50 "properties": {
51 "path": { "type": "string" },
52 "line": { "type": "integer", "description": "1-based line to start from" },
53 "limit": { "type": "integer", "description": "Max lines to read" },
54 "root": root_prop()
55 },
56 "required": ["path"]
57 }),
58 ),
59 f(
60 "write_file",
61 "Create or overwrite a file with the given content.",
62 json!({
63 "type": "object",
64 "properties": { "path": { "type": "string" }, "content": { "type": "string" }, "root": root_prop() },
65 "required": ["path", "content"]
66 }),
67 ),
68 f(
69 "edit_file",
70 "Replace an exact, unique occurrence of old_string with new_string in a file.",
71 json!({
72 "type": "object",
73 "properties": {
74 "path": { "type": "string" },
75 "old_string": { "type": "string" },
76 "new_string": { "type": "string" },
77 "root": root_prop()
78 },
79 "required": ["path", "old_string", "new_string"]
80 }),
81 ),
82 f(
83 "list_directory",
84 "List entries of a directory (directories end with '/').",
85 json!({
86 "type": "object",
87 "properties": { "path": { "type": "string", "description": "Defaults to the working directory" }, "root": root_prop() }
88 }),
89 ),
90 f(
91 "run_command",
92 "Run a shell command (sh -c) in the working directory and return its output.",
93 json!({
94 "type": "object",
95 "properties": { "command": { "type": "string" }, "root": root_prop() },
96 "required": ["command"]
97 }),
98 ),
99 ]
100 }
101
102 #[async_trait]
103 impl Toolset for WorkspaceTools {
104 fn definitions(&self) -> Vec<Value> {
105 tool_definitions()
106 }
107
108 fn handles(&self, name: &str) -> bool {
109 NAMES.contains(&name)
110 }
111
112 fn describe(
113 &self,
114 name: &str,
115 args: &Value,
116 ctx: &DescribeCtx<'_>,
117 ) -> (String, ToolKind, Vec<ToolCallLocation>) {
118 let str_arg = |k: &str| args.get(k).and_then(Value::as_str);
119 let base =
120 base_for(ctx.cwd, ctx.roots, str_arg("root")).unwrap_or_else(|_| ctx.cwd.to_path_buf());
121 // ACP v1: ToolCallLocation.path must be an absolute path.
122 let path = str_arg("path").map(|p| absolutize(&resolve_in(&base, p)));
123 let loc = path
124 .clone()
125 .map(ToolCallLocation::new)
126 .into_iter()
127 .collect();
128 let shown = path
129 .as_deref()
130 .map_or_else(|| base.display().to_string(), |p| p.display().to_string());
131 match name {
132 "read_file" => (format!("Read {shown}"), ToolKind::Read, loc),
133 "write_file" => (format!("Write {shown}"), ToolKind::Edit, loc),
134 "edit_file" => (format!("Edit {shown}"), ToolKind::Edit, loc),
135 "list_directory" => (format!("List {shown}"), ToolKind::Search, loc),
136 "run_command" => {
137 let cmd = str_arg("command").unwrap_or("");
138 let where_ = if base == ctx.cwd {
139 String::new()
140 } else {
141 format!(" in {}", base.display())
142 };
143 (format!("`{cmd}`{where_}"), ToolKind::Execute, vec![])
144 }
145 _ => (name.to_string(), ToolKind::Other, vec![]),
146 }
147 }
148
149 async fn execute(
150 &self,
151 ctx: &ToolCtx,
152 tool_call_id: &str,
153 name: &str,
154 args: Value,
155 ) -> Result<ToolOutcome> {
156 match name {
157 "read_file" => ctx.read_file(args).await,
158 "write_file" => ctx.write_file(tool_call_id, args).await,
159 "edit_file" => ctx.edit_file(tool_call_id, args).await,
160 "list_directory" => ctx.list_directory(args).await,
161 "run_command" => ctx.run_command(tool_call_id, args).await,
162 other => Err(anyhow!("unknown tool `{other}`")),
163 }
164 }
165 }
166
167 #[derive(Deserialize)]
168 struct ReadArgs {
169 path: String,
170 line: Option<u32>,
171 limit: Option<u32>,
172 root: Option<String>,
173 }
174 #[derive(Deserialize)]
175 struct WriteArgs {
176 path: String,
177 content: String,
178 root: Option<String>,
179 }
180 #[derive(Deserialize)]
181 struct EditArgs {
182 path: String,
183 old_string: String,
184 new_string: String,
185 root: Option<String>,
186 }
187 #[derive(Deserialize)]
188 struct ListArgs {
189 path: Option<String>,
190 root: Option<String>,
191 }
192 #[derive(Deserialize)]
193 struct CommandArgs {
194 command: String,
195 root: Option<String>,
196 }
197
198 impl ToolCtx {
199 pub(crate) async fn read_file(&self, args: Value) -> Result<ToolOutcome> {
200 let a: ReadArgs = serde_json::from_value(args)?;
201 let path = self.resolve_with(a.root.as_deref(), &a.path)?;
202 let text = if self.caps.fs.read_text_file {
203 // ACP v1 fs/read_text_file requires an absolute path.
204 let mut req = ReadTextFileRequest::new(self.session_id.clone(), absolutize(&path));
205 if let Some(l) = a.line {
206 req = req.line(l);
207 }
208 if let Some(l) = a.limit {
209 req = req.limit(l);
210 }
211 self.connection
212 .send_request(req)
213 .block_task()
214 .await?
215 .content
216 } else {
217 let full = tokio::fs::read_to_string(&path)
218 .await
219 .with_context(|| format!("reading {}", path.display()))?;
220 let skip = a.line.map_or(0, |l| l.saturating_sub(1) as usize);
221 let take = a.limit.map_or(usize::MAX, |l| l as usize);
222 full.lines()
223 .skip(skip)
224 .take(take)
225 .collect::<Vec<_>>()
226 .join("\n")
227 };
228 // Show the user a short summary; give the model the full text.
229 let mut out = ToolOutcome::ok(format!("{} lines", text.lines().count()));
230 out.text = truncate(text);
231 Ok(out)
232 }
233
234 /// Read the current file contents for diffs/edits; `Ok(None)` if it doesn't exist.
235 /// A client-fs read failure is an error, distinct from a missing file (ACP
236 /// `ResourceNotFound`), so callers don't mistake a failed read for a new file.
237 pub(crate) async fn read_existing(&self, path: &Path) -> Result<Option<String>> {
238 if self.caps.fs.read_text_file {
239 let req = ReadTextFileRequest::new(self.session_id.clone(), absolutize(path));
240 match self.connection.send_request(req).block_task().await {
241 Ok(r) => Ok(Some(r.content)),
242 Err(e) if e.code == ErrorCode::ResourceNotFound => Ok(None),
243 Err(e) => Err(anyhow!("client read of {} failed: {e}", path.display())),
244 }
245 } else {
246 match tokio::fs::read_to_string(path).await {
247 Ok(content) => Ok(Some(content)),
248 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
249 Err(e) => Err(anyhow!("reading {}: {e}", path.display())),
250 }
251 }
252 }
253
254 pub(crate) async fn write_text(&self, path: &Path, content: &str) -> Result<()> {
255 if self.caps.fs.write_text_file {
256 let req = WriteTextFileRequest::new(self.session_id.clone(), absolutize(path), content);
257 self.connection.send_request(req).block_task().await?;
258 } else {
259 if let Some(parent) = path.parent() {
260 tokio::fs::create_dir_all(parent).await?;
261 }
262 tokio::fs::write(path, content).await?;
263 }
264 Ok(())
265 }
266
267 pub(crate) async fn write_file(&self, id: &str, args: Value) -> Result<ToolOutcome> {
268 let a: WriteArgs = serde_json::from_value(args)?;
269 let path = self.resolve_with(a.root.as_deref(), &a.path)?;
270 let old = self.read_existing(&path).await?;
271 // ACP v1: Diff.path must be an absolute file path.
272 let diff = Diff::new(absolutize(&path), a.content.clone()).old_text(old);
273 self.apply_edit(id, "write_file", &path, &a.content, diff)
274 .await
275 }
276
277 pub(crate) async fn edit_file(&self, id: &str, args: Value) -> Result<ToolOutcome> {
278 let a: EditArgs = serde_json::from_value(args)?;
279 let path = self.resolve_with(a.root.as_deref(), &a.path)?;
280 let old = self
281 .read_existing(&path)
282 .await?
283 .ok_or_else(|| anyhow!("{} does not exist", path.display()))?;
284 match old.matches(&a.old_string).count() {
285 0 => bail!("old_string not found in {}", path.display()),
286 1 => {}
287 n => bail!(
288 "old_string occurs {n} times in {}; make it unique",
289 path.display()
290 ),
291 }
292 let new = old.replacen(&a.old_string, &a.new_string, 1);
293 let diff = Diff::new(absolutize(&path), new.clone()).old_text(old);
294 self.apply_edit(id, "edit_file", &path, &new, diff).await
295 }
296
297 pub(crate) async fn apply_edit(
298 &self,
299 id: &str,
300 tool: &str,
301 path: &Path,
302 content: &str,
303 diff: Diff,
304 ) -> Result<ToolOutcome> {
305 let diff = ToolCallContent::from(diff);
306 if !self.permit(id, tool, vec![diff.clone()]).await? {
307 return Ok(ToolOutcome::err("User rejected this change."));
308 }
309 self.write_text(path, content).await?;
310 Ok(ToolOutcome {
311 text: format!("Wrote {}", path.display()),
312 content: vec![diff],
313 failed: false,
314 locations: Vec::new(),
315 })
316 }
317
318 pub(crate) async fn list_directory(&self, args: Value) -> Result<ToolOutcome> {
319 let a: ListArgs = serde_json::from_value(args)?;
320 let base = self.base_for(a.root.as_deref())?;
321 let path = a
322 .path
323 .map_or_else(|| base.clone(), |p| resolve_in(&base, &p));
324 let mut entries = Vec::new();
325 let mut rd = tokio::fs::read_dir(&path)
326 .await
327 .with_context(|| format!("listing {}", path.display()))?;
328 while let Some(e) = rd.next_entry().await? {
329 let mut name = e.file_name().to_string_lossy().into_owned();
330 if e.file_type().await.is_ok_and(|t| t.is_dir()) {
331 name.push('/');
332 }
333 entries.push(name);
334 }
335 entries.sort();
336 Ok(ToolOutcome::ok(entries.join("\n")))
337 }
338
339 pub(crate) async fn run_command(&self, id: &str, args: Value) -> Result<ToolOutcome> {
340 let a: CommandArgs = serde_json::from_value(args)?;
341 let base = self.base_for(a.root.as_deref())?;
342 if !self.permit(id, "run_command", vec![]).await? {
343 return Ok(ToolOutcome::err("User rejected running this command."));
344 }
345 if self.caps.terminal {
346 self.run_in_client_terminal(id, &a.command, &base).await
347 } else {
348 self.run_locally(&a.command, &base).await
349 }
350 }
351
352 pub(crate) async fn run_in_client_terminal(
353 &self,
354 id: &str,
355 command: &str,
356 cwd: &Path,
357 ) -> Result<ToolOutcome> {
358 let sid = self.session_id.clone();
359 let req = CreateTerminalRequest::new(sid.clone(), "sh")
360 .args(vec!["-c".into(), command.into()])
361 // ACP v1: the terminal cwd must be an absolute path.
362 .cwd(absolutize(cwd))
363 .output_byte_limit(MAX_OUTPUT_BYTES as u64);
364 let terminal_id = self
365 .connection
366 .send_request(req)
367 .block_task()
368 .await?
369 .terminal_id;
370 // Embed the live terminal in the tool call so the user can watch it.
371 let content = vec![ToolCallContent::Terminal(Terminal::new(
372 terminal_id.clone(),
373 ))];
374 self.update(id, ToolCallUpdateFields::new().content(content.clone()))?;
375
376 let wait = self
377 .connection
378 .send_request(WaitForTerminalExitRequest::new(
379 sid.clone(),
380 terminal_id.clone(),
381 ))
382 .block_task();
383 let exit = tokio::select! {
384 r = wait => Some(r?.exit_status),
385 () = tokio::time::sleep(self.command_timeout) => None,
386 () = self.cancel.cancelled() => None,
387 };
388 if exit.is_none() {
389 let _ = self
390 .connection
391 .send_request(KillTerminalRequest::new(sid.clone(), terminal_id.clone()))
392 .block_task()
393 .await;
394 }
395 let output = self
396 .connection
397 .send_request(TerminalOutputRequest::new(sid.clone(), terminal_id.clone()))
398 .block_task()
399 .await?;
400 let _ = self
401 .connection
402 .send_request(ReleaseTerminalRequest::new(sid, terminal_id))
403 .block_task()
404 .await;
405
406 let status = match &exit {
407 Some(s) => match (s.exit_code, &s.signal) {
408 (Some(c), _) => format!("exit code {c}"),
409 (None, Some(sig)) => format!("killed by {sig}"),
410 _ => "exited".into(),
411 },
412 None if self.cancel.is_cancelled() => "cancelled".into(),
413 None => format!("timed out after {}s", self.command_timeout.as_secs()),
414 };
415 let failed = !matches!(exit.as_ref().and_then(|s| s.exit_code), Some(0));
416 let trunc = if output.truncated {
417 "\n[output truncated]"
418 } else {
419 ""
420 };
421 Ok(ToolOutcome {
422 text: format!("{}{trunc}\n[{status}]", output.output),
423 content,
424 failed,
425 locations: Vec::new(),
426 })
427 }
428
429 pub(crate) async fn run_locally(&self, command: &str, cwd: &Path) -> Result<ToolOutcome> {
430 let child = tokio::process::Command::new("sh")
431 .arg("-c")
432 .arg(command)
433 .current_dir(cwd)
434 .stdin(std::process::Stdio::null())
435 .kill_on_drop(true)
436 .output();
437 let out = tokio::select! {
438 r = tokio::time::timeout(self.command_timeout, child) => match r {
439 Ok(r) => r?,
440 Err(_) => return Ok(ToolOutcome::err(format!("Command timed out after {}s", self.command_timeout.as_secs()))),
441 },
442 () = self.cancel.cancelled() => return Ok(ToolOutcome::err("Command cancelled")),
443 };
444 let mut text = String::from_utf8_lossy(&out.stdout).into_owned();
445 text.push_str(&String::from_utf8_lossy(&out.stderr));
446 let status = out
447 .status
448 .code()
449 .map_or_else(|| "killed by signal".into(), |c| format!("exit code {c}"));
450 let text = format!("{}\n[{status}]", truncate(text));
451 let mut outcome = ToolOutcome::ok(format!("```\n{text}\n```"));
452 outcome.text = text;
453 outcome.failed = !out.status.success();
454 Ok(outcome)
455 }
456 }