1use std::path::{Path, PathBuf};
5use std::time::Duration;
6
7use async_trait::async_trait;
8use serde::de::DeserializeOwned;
9use serde::Deserialize;
10use serde_json::{json, Value};
11
12use crate::error::{Error, Result};
13use crate::tools::{Tool, ToolContext};
14
15const MAX_READ_BYTES: usize = 400_000;
16const DEFAULT_BASH_TIMEOUT_MS: u64 = 120_000;
17
18fn parse_args<T: DeserializeOwned>(tool: &str, args: Value) -> Result<T> {
19 serde_json::from_value(args).map_err(|e| Error::InvalidArguments {
20 tool: tool.to_string(),
21 message: e.to_string(),
22 })
23}
24
25fn rel(ctx: &ToolContext, p: &Path) -> String {
26 p.strip_prefix(&ctx.cwd)
27 .unwrap_or(p)
28 .to_string_lossy()
29 .into_owned()
30}
31
32pub struct ReadFileTool;
36
37#[derive(Deserialize)]
38struct ReadArgs {
39 path: String,
40 #[serde(default)]
41 offset: Option<usize>,
42 #[serde(default)]
43 limit: Option<usize>,
44}
45
46#[async_trait]
47impl Tool for ReadFileTool {
48 fn name(&self) -> &str {
49 "read_file"
50 }
51 fn description(&self) -> &str {
52 "Read the contents of a UTF-8 text file. Optionally pass `offset` (1-based start line) and `limit` (number of lines) to read a slice of a large file."
53 }
54 fn parameters(&self) -> Value {
55 json!({
56 "type": "object",
57 "properties": {
58 "path": {"type": "string", "description": "File path, absolute or relative to the working directory."},
59 "offset": {"type": "integer", "description": "1-based line to start at."},
60 "limit": {"type": "integer", "description": "Maximum number of lines to return."}
61 },
62 "required": ["path"],
63 "additionalProperties": false
64 })
65 }
66 async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
67 let a: ReadArgs = parse_args(self.name(), args)?;
68 let path = ctx.resolve(&a.path);
69 let bytes = tokio::fs::read(&path)
70 .await
71 .map_err(|e| Error::tool(self.name(), format!("{}: {e}", path.display())))?;
72 if bytes.len() > MAX_READ_BYTES && a.offset.is_none() && a.limit.is_none() {
73 return Err(Error::tool(
74 self.name(),
75 format!(
76 "file is {} bytes (> {MAX_READ_BYTES}); pass offset/limit to read a slice",
77 bytes.len()
78 ),
79 ));
80 }
81 let text = String::from_utf8_lossy(&bytes);
82 if a.offset.is_none() && a.limit.is_none() {
83 return Ok(text.into_owned());
84 }
85 let start = a.offset.unwrap_or(1).saturating_sub(1);
86 let limit = a.limit.unwrap_or(usize::MAX);
87 let sliced: Vec<&str> = text.lines().skip(start).take(limit).collect();
88 Ok(sliced.join("\n"))
89 }
90}
91
92pub struct WriteFileTool;
96
97#[derive(Deserialize)]
98struct WriteArgs {
99 path: String,
100 content: String,
101}
102
103#[async_trait]
104impl Tool for WriteFileTool {
105 fn name(&self) -> &str {
106 "write_file"
107 }
108 fn description(&self) -> &str {
109 "Create or overwrite a file with the given contents. Parent directories are created as needed."
110 }
111 fn parameters(&self) -> Value {
112 json!({
113 "type": "object",
114 "properties": {
115 "path": {"type": "string", "description": "File path to write."},
116 "content": {"type": "string", "description": "Full file contents."}
117 },
118 "required": ["path", "content"],
119 "additionalProperties": false
120 })
121 }
122 async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
123 let a: WriteArgs = parse_args(self.name(), args)?;
124 let path = ctx.resolve(&a.path);
125 ctx.check_write(&path)?;
126 if let Some(parent) = path.parent() {
127 tokio::fs::create_dir_all(parent).await.ok();
128 }
129 tokio::fs::write(&path, a.content.as_bytes())
130 .await
131 .map_err(|e| Error::tool(self.name(), format!("{}: {e}", path.display())))?;
132 Ok(format!(
133 "Wrote {} bytes to {}",
134 a.content.len(),
135 rel(ctx, &path)
136 ))
137 }
138}
139
140pub struct EditFileTool;
144
145#[derive(Deserialize)]
146struct EditArgs {
147 path: String,
148 old_string: String,
149 new_string: String,
150 #[serde(default)]
151 replace_all: bool,
152}
153
154#[async_trait]
155impl Tool for EditFileTool {
156 fn name(&self) -> &str {
157 "edit_file"
158 }
159 fn description(&self) -> &str {
160 "Replace an exact substring in a file. By default `old_string` must occur exactly once; set `replace_all` to replace every occurrence."
161 }
162 fn parameters(&self) -> Value {
163 json!({
164 "type": "object",
165 "properties": {
166 "path": {"type": "string"},
167 "old_string": {"type": "string", "description": "Exact text to replace."},
168 "new_string": {"type": "string", "description": "Replacement text."},
169 "replace_all": {"type": "boolean", "description": "Replace every occurrence instead of requiring uniqueness."}
170 },
171 "required": ["path", "old_string", "new_string"],
172 "additionalProperties": false
173 })
174 }
175 async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
176 let a: EditArgs = parse_args(self.name(), args)?;
177 if a.old_string.is_empty() {
178 return Err(Error::tool(self.name(), "old_string must not be empty"));
181 }
182 let path = ctx.resolve(&a.path);
183 ctx.check_write(&path)?;
184 let original = tokio::fs::read_to_string(&path)
185 .await
186 .map_err(|e| Error::tool(self.name(), format!("{}: {e}", path.display())))?;
187 let count = original.matches(&a.old_string).count();
188 if count == 0 {
189 return Err(Error::tool(self.name(), "old_string not found in file"));
190 }
191 if count > 1 && !a.replace_all {
192 return Err(Error::tool(
193 self.name(),
194 format!("old_string occurs {count} times; pass replace_all or add more context"),
195 ));
196 }
197 let updated = if a.replace_all {
198 original.replace(&a.old_string, &a.new_string)
199 } else {
200 original.replacen(&a.old_string, &a.new_string, 1)
201 };
202 tokio::fs::write(&path, updated.as_bytes())
203 .await
204 .map_err(|e| Error::tool(self.name(), format!("{}: {e}", path.display())))?;
205 Ok(format!(
206 "Replaced {} occurrence(s) in {}",
207 if a.replace_all { count } else { 1 },
208 rel(ctx, &path)
209 ))
210 }
211}
212
213pub struct ListDirTool;
217
218#[derive(Deserialize)]
219struct ListArgs {
220 #[serde(default)]
221 path: Option<String>,
222}
223
224#[async_trait]
225impl Tool for ListDirTool {
226 fn name(&self) -> &str {
227 "list_dir"
228 }
229 fn description(&self) -> &str {
230 "List the entries of a directory (defaults to the working directory). Directories are suffixed with `/`."
231 }
232 fn parameters(&self) -> Value {
233 json!({
234 "type": "object",
235 "properties": {
236 "path": {"type": "string", "description": "Directory to list. Defaults to the working directory."}
237 },
238 "additionalProperties": false
239 })
240 }
241 async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
242 let a: ListArgs = parse_args(self.name(), args)?;
243 let dir = match a.path {
244 Some(p) => ctx.resolve(&p),
245 None => ctx.cwd.clone(),
246 };
247 let mut rd = tokio::fs::read_dir(&dir)
248 .await
249 .map_err(|e| Error::tool(self.name(), format!("{}: {e}", dir.display())))?;
250 let mut entries = Vec::new();
251 while let Some(e) = rd
252 .next_entry()
253 .await
254 .map_err(|e| Error::tool(self.name(), e.to_string()))?
255 {
256 let name = e.file_name().to_string_lossy().into_owned();
257 let is_dir = e.file_type().await.map(|t| t.is_dir()).unwrap_or(false);
258 entries.push(if is_dir { format!("{name}/") } else { name });
259 }
260 entries.sort();
261 if entries.is_empty() {
262 Ok("(empty directory)".to_string())
263 } else {
264 Ok(entries.join("\n"))
265 }
266 }
267}
268
269pub struct GlobTool;
273
274#[derive(Deserialize)]
275struct GlobArgs {
276 pattern: String,
277}
278
279#[async_trait]
280impl Tool for GlobTool {
281 fn name(&self) -> &str {
282 "glob"
283 }
284 fn description(&self) -> &str {
285 "Find files matching a glob pattern (e.g. `src/**/*.rs`), relative to the working directory."
286 }
287 fn parameters(&self) -> Value {
288 json!({
289 "type": "object",
290 "properties": {
291 "pattern": {"type": "string", "description": "Glob pattern, e.g. **/*.rs"}
292 },
293 "required": ["pattern"],
294 "additionalProperties": false
295 })
296 }
297 async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
298 let a: GlobArgs = parse_args(self.name(), args)?;
299 let cwd = ctx.cwd.clone();
300 let full = if PathBuf::from(&a.pattern).is_absolute() {
301 a.pattern.clone()
302 } else {
303 cwd.join(&a.pattern).to_string_lossy().into_owned()
304 };
305 let cwd2 = cwd.clone();
306 let matches = tokio::task::spawn_blocking(move || {
307 let mut out = Vec::new();
308 if let Ok(paths) = glob::glob(&full) {
309 for p in paths.flatten() {
310 let display = p
311 .strip_prefix(&cwd2)
312 .unwrap_or(&p)
313 .to_string_lossy()
314 .into_owned();
315 out.push(display);
316 }
317 }
318 out
319 })
320 .await
321 .map_err(|e| Error::tool("glob", e.to_string()))?;
322 if matches.is_empty() {
323 Ok("(no matches)".to_string())
324 } else {
325 Ok(matches.join("\n"))
326 }
327 }
328}
329
330pub struct SearchTool;
334
335#[derive(Deserialize)]
336struct SearchArgs {
337 pattern: String,
338 #[serde(default)]
339 path: Option<String>,
340 #[serde(default)]
341 max_results: Option<usize>,
342}
343
344#[async_trait]
345impl Tool for SearchTool {
346 fn name(&self) -> &str {
347 "search"
348 }
349 fn description(&self) -> &str {
350 "Search file contents with a regular expression, respecting .gitignore. Returns `path:line: text` matches."
351 }
352 fn parameters(&self) -> Value {
353 json!({
354 "type": "object",
355 "properties": {
356 "pattern": {"type": "string", "description": "Regular expression to search for."},
357 "path": {"type": "string", "description": "Directory or file to search. Defaults to the working directory."},
358 "max_results": {"type": "integer", "description": "Cap on the number of matches (default 200)."}
359 },
360 "required": ["pattern"],
361 "additionalProperties": false
362 })
363 }
364 async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
365 let a: SearchArgs = parse_args(self.name(), args)?;
366 let re = regex::Regex::new(&a.pattern)
367 .map_err(|e| Error::tool(self.name(), format!("invalid regex: {e}")))?;
368 let root = match a.path {
369 Some(p) => ctx.resolve(&p),
370 None => ctx.cwd.clone(),
371 };
372 let cwd = ctx.cwd.clone();
373 let cap = a.max_results.unwrap_or(200);
374 let results = tokio::task::spawn_blocking(move || {
375 let mut out: Vec<String> = Vec::new();
376 let walker = ignore::WalkBuilder::new(&root).build();
377 'outer: for entry in walker.flatten() {
378 if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
379 continue;
380 }
381 let path = entry.path();
382 let Ok(content) = std::fs::read_to_string(path) else {
383 continue; };
385 for (i, line) in content.lines().enumerate() {
386 if re.is_match(line) {
387 let rel = path.strip_prefix(&cwd).unwrap_or(path);
388 out.push(format!("{}:{}: {}", rel.display(), i + 1, line.trim_end()));
389 if out.len() >= cap {
390 break 'outer;
391 }
392 }
393 }
394 }
395 out
396 })
397 .await
398 .map_err(|e| Error::tool("search", e.to_string()))?;
399 if results.is_empty() {
400 Ok("(no matches)".to_string())
401 } else {
402 Ok(results.join("\n"))
403 }
404 }
405}
406
407pub struct BashTool {
411 default_timeout_ms: u64,
412}
413
414impl Default for BashTool {
415 fn default() -> Self {
416 BashTool {
417 default_timeout_ms: DEFAULT_BASH_TIMEOUT_MS,
418 }
419 }
420}
421
422#[derive(Deserialize)]
423struct BashArgs {
424 command: String,
425 #[serde(default)]
426 timeout_ms: Option<u64>,
427}
428
429#[async_trait]
430impl Tool for BashTool {
431 fn name(&self) -> &str {
432 "bash"
433 }
434 fn description(&self) -> &str {
435 "Execute a shell command via `sh -c` in the working directory and return its combined stdout/stderr and exit code."
436 }
437 fn parameters(&self) -> Value {
438 json!({
439 "type": "object",
440 "properties": {
441 "command": {"type": "string", "description": "Shell command to run."},
442 "timeout_ms": {"type": "integer", "description": "Timeout in milliseconds (default 120000)."}
443 },
444 "required": ["command"],
445 "additionalProperties": false
446 })
447 }
448 async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
449 let a: BashArgs = parse_args(self.name(), args)?;
450 let timeout = Duration::from_millis(a.timeout_ms.unwrap_or(self.default_timeout_ms));
451
452 let mut cmd = build_sandboxed_sh(&a.command, ctx);
453 cmd.current_dir(&ctx.cwd).stdin(std::process::Stdio::null());
454
455 let fut = cmd.output();
456 let output = match tokio::time::timeout(timeout, fut).await {
457 Ok(Ok(o)) => o,
458 Ok(Err(e)) => return Err(Error::tool(self.name(), e.to_string())),
459 Err(_) => {
460 return Err(Error::tool(
461 self.name(),
462 format!("command timed out after {timeout:?}"),
463 ))
464 }
465 };
466
467 let mut buf = String::new();
468 let stdout = String::from_utf8_lossy(&output.stdout);
469 let stderr = String::from_utf8_lossy(&output.stderr);
470 if !stdout.is_empty() {
471 buf.push_str(&stdout);
472 }
473 if !stderr.is_empty() {
474 if !buf.is_empty() && !buf.ends_with('\n') {
475 buf.push('\n');
476 }
477 buf.push_str(&stderr);
478 }
479 let code = output.status.code().unwrap_or(-1);
480 if buf.is_empty() {
481 buf.push_str("(no output)");
482 }
483 Ok(format!("exit code: {code}\n{buf}"))
484 }
485}
486
487fn build_sandboxed_sh(command: &str, ctx: &ToolContext) -> tokio::process::Command {
497 #[cfg(target_os = "macos")]
498 {
499 use crate::tools::SandboxPolicy;
500 if ctx.sandbox != SandboxPolicy::DangerFullAccess {
501 if let Some(profile) = seatbelt_profile(ctx) {
502 let mut cmd = tokio::process::Command::new("sandbox-exec");
503 cmd.arg("-p").arg(profile).arg("sh").arg("-c").arg(command);
504 return cmd;
505 }
506 }
507 }
508 let _ = ctx;
509 let mut cmd = tokio::process::Command::new("sh");
510 cmd.arg("-c").arg(command);
511 cmd
512}
513
514fn build_sandboxed_interactive_sh(ctx: &ToolContext) -> tokio::process::Command {
519 #[cfg(target_os = "macos")]
520 {
521 use crate::tools::SandboxPolicy;
522 if ctx.sandbox != SandboxPolicy::DangerFullAccess {
523 if let Some(profile) = seatbelt_profile(ctx) {
524 let mut cmd = tokio::process::Command::new("sandbox-exec");
525 cmd.arg("-p").arg(profile).arg("sh");
526 return cmd;
527 }
528 }
529 }
530 let _ = ctx;
531 tokio::process::Command::new("sh")
532}
533
534#[cfg(target_os = "macos")]
537fn seatbelt_profile(ctx: &ToolContext) -> Option<String> {
538 use crate::tools::SandboxPolicy;
539 match ctx.sandbox {
540 SandboxPolicy::DangerFullAccess => None,
541 SandboxPolicy::ReadOnly => Some("(version 1)(allow default)(deny file-write*)".to_string()),
542 SandboxPolicy::WorkspaceWrite => {
543 let real = std::fs::canonicalize(&ctx.cwd).unwrap_or_else(|_| ctx.cwd.clone());
546 let dir = real.to_string_lossy().replace('"', "");
547 Some(format!(
548 "(version 1)(allow default)(deny file-write*)\
549(allow file-write* (subpath \"{dir}\"))\
550(allow file-write* (literal \"/dev/null\") (literal \"/dev/dtracehelper\") (literal \"/dev/tty\"))"
551 ))
552 }
553 }
554}
555
556pub struct ApplyPatchTool;
563
564#[derive(Deserialize)]
565struct ApplyPatchArgs {
566 patch: String,
568}
569
570enum PatchOp {
572 Add {
573 path: String,
574 body: String,
575 },
576 Delete {
577 path: String,
578 },
579 Update {
580 path: String,
581 move_to: Option<String>,
582 hunks: Vec<Hunk>,
583 },
584}
585
586#[derive(Default)]
590struct Hunk {
591 old: Vec<String>,
592 new: Vec<String>,
593 anchor: Option<String>,
594}
595
596fn parse_patch(patch: &str) -> Result<Vec<PatchOp>> {
597 let err = |m: &str| Error::tool("apply_patch", m.to_string());
598 let lines: Vec<&str> = patch.lines().collect();
599 let mut i = 0;
600 while i < lines.len() && lines[i].trim() != "*** Begin Patch" {
602 i += 1;
603 }
604 if i == lines.len() {
605 return Err(err("missing '*** Begin Patch'"));
606 }
607 i += 1;
608
609 let mut ops = Vec::new();
610 while i < lines.len() {
611 let line = lines[i];
612 let t = line.trim_end();
613 if t == "*** End Patch" {
614 return Ok(ops);
615 } else if let Some(p) = t.strip_prefix("*** Add File: ") {
616 i += 1;
617 let mut body = Vec::new();
618 while i < lines.len() && lines[i].starts_with('+') {
619 body.push(&lines[i][1..]);
620 i += 1;
621 }
622 ops.push(PatchOp::Add {
623 path: p.to_string(),
624 body: body.join("\n"),
625 });
626 } else if let Some(p) = t.strip_prefix("*** Delete File: ") {
627 ops.push(PatchOp::Delete {
628 path: p.to_string(),
629 });
630 i += 1;
631 } else if let Some(p) = t.strip_prefix("*** Update File: ") {
632 i += 1;
633 let mut move_to = None;
634 if i < lines.len() {
635 if let Some(m) = lines[i].trim_end().strip_prefix("*** Move to: ") {
636 move_to = Some(m.to_string());
637 i += 1;
638 }
639 }
640 let mut hunks = Vec::new();
641 let mut cur = Hunk::default();
642 let mut started = false;
643 while i < lines.len() {
644 let l = lines[i];
645 let lt = l.trim_end();
646 if lt.starts_with("*** ") {
647 break; }
649 if let Some(anchor) = lt.strip_prefix("@@") {
650 if started && (!cur.old.is_empty() || !cur.new.is_empty()) {
651 hunks.push(std::mem::take(&mut cur));
652 }
653 let anchor = anchor.trim();
655 cur.anchor = (!anchor.is_empty()).then(|| anchor.to_string());
656 started = true;
657 i += 1;
658 continue;
659 }
660 started = true;
661 if let Some(rest) = l.strip_prefix('+') {
662 cur.new.push(rest.to_string());
663 } else if let Some(rest) = l.strip_prefix('-') {
664 cur.old.push(rest.to_string());
665 } else {
666 let ctx = l.strip_prefix(' ').unwrap_or(l).to_string();
668 cur.old.push(ctx.clone());
669 cur.new.push(ctx);
670 }
671 i += 1;
672 }
673 if !cur.old.is_empty() || !cur.new.is_empty() {
674 hunks.push(cur);
675 }
676 ops.push(PatchOp::Update {
677 path: p.to_string(),
678 move_to,
679 hunks,
680 });
681 } else {
682 i += 1;
684 }
685 }
686 Err(err("missing '*** End Patch'"))
687}
688
689fn apply_update(original: &str, hunks: &[Hunk], tool: &str) -> Result<String> {
690 let mut text = original.to_string();
691 for h in hunks {
692 let from = match &h.anchor {
696 Some(a) => {
697 let Some(pos) = text.find(a.as_str()) else {
698 return Err(Error::tool(tool, format!("@@ anchor not found: {a}")));
699 };
700 text[pos..]
702 .find('\n')
703 .map(|nl| pos + nl + 1)
704 .unwrap_or(text.len())
705 }
706 None => 0,
707 };
708
709 let new_block = h.new.join("\n");
710
711 if h.old.is_empty() {
712 if h.anchor.is_some() {
715 let needs_lead_nl = from > 0 && text.as_bytes()[from - 1] != b'\n';
716 let payload = if needs_lead_nl {
717 format!("\n{new_block}\n")
718 } else {
719 format!("{new_block}\n")
720 };
721 text.insert_str(from, &payload);
722 } else {
723 if !text.is_empty() && !text.ends_with('\n') {
724 text.push('\n');
725 }
726 text.push_str(&new_block);
727 }
728 continue;
729 }
730
731 let old_block = h.old.join("\n");
732 match text[from..].find(&old_block) {
733 Some(rel) => {
734 let start = from + rel;
735 text.replace_range(start..start + old_block.len(), &new_block);
736 }
737 None => {
738 return Err(Error::tool(
739 tool,
740 format!("hunk did not match file contents:\n{old_block}"),
741 ))
742 }
743 }
744 }
745 Ok(text)
746}
747
748#[async_trait]
749impl Tool for ApplyPatchTool {
750 fn name(&self) -> &str {
751 "apply_patch"
752 }
753 fn description(&self) -> &str {
754 "Apply a patch in the apply_patch envelope format (*** Begin Patch / *** End Patch) with Add File, Delete File, and Update File operations. Update hunks use leading '+'/'-'/' ' on each line and may include '@@' context headers and an optional '*** Move to:' rename."
755 }
756 fn parameters(&self) -> Value {
757 json!({
758 "type": "object",
759 "properties": {
760 "patch": {"type": "string", "description": "The full *** Begin Patch … *** End Patch text."}
761 },
762 "required": ["patch"],
763 "additionalProperties": false
764 })
765 }
766 async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
767 let a: ApplyPatchArgs = parse_args(self.name(), args)?;
768 let ops = parse_patch(&a.patch)?;
769 let mut summary = Vec::new();
770 for op in ops {
771 match op {
772 PatchOp::Add { path, body } => {
773 let full = ctx.resolve(&path);
774 ctx.check_write(&full)?;
775 if let Some(parent) = full.parent() {
776 tokio::fs::create_dir_all(parent).await.ok();
777 }
778 tokio::fs::write(&full, body.as_bytes())
779 .await
780 .map_err(|e| {
781 Error::tool(self.name(), format!("{}: {e}", full.display()))
782 })?;
783 summary.push(format!("A {}", rel(ctx, &full)));
784 }
785 PatchOp::Delete { path } => {
786 let full = ctx.resolve(&path);
787 ctx.check_write(&full)?;
788 tokio::fs::remove_file(&full).await.map_err(|e| {
789 Error::tool(self.name(), format!("{}: {e}", full.display()))
790 })?;
791 summary.push(format!("D {}", rel(ctx, &full)));
792 }
793 PatchOp::Update {
794 path,
795 move_to,
796 hunks,
797 } => {
798 let full = ctx.resolve(&path);
799 let dest_for_check = move_to
800 .as_ref()
801 .map(|m| ctx.resolve(m))
802 .unwrap_or_else(|| full.clone());
803 ctx.check_write(&dest_for_check)?;
804 let original = tokio::fs::read_to_string(&full).await.map_err(|e| {
805 Error::tool(self.name(), format!("{}: {e}", full.display()))
806 })?;
807 let updated = apply_update(&original, &hunks, self.name())?;
808 let dest = match &move_to {
809 Some(m) => ctx.resolve(m),
810 None => full.clone(),
811 };
812 if let Some(parent) = dest.parent() {
813 tokio::fs::create_dir_all(parent).await.ok();
814 }
815 tokio::fs::write(&dest, updated.as_bytes())
816 .await
817 .map_err(|e| {
818 Error::tool(self.name(), format!("{}: {e}", dest.display()))
819 })?;
820 if move_to.is_some() && dest != full {
821 tokio::fs::remove_file(&full).await.ok();
822 summary.push(format!("M {} -> {}", rel(ctx, &full), rel(ctx, &dest)));
823 } else {
824 summary.push(format!("U {}", rel(ctx, &full)));
825 }
826 }
827 }
828 }
829 if summary.is_empty() {
830 Ok("(empty patch)".to_string())
831 } else {
832 Ok(format!("Applied patch:\n{}", summary.join("\n")))
833 }
834 }
835}
836
837use tokio::io::{AsyncReadExt, AsyncWriteExt};
840use tokio::sync::Mutex as AsyncMutex;
841
842pub struct PersistentShellTool {
847 state: AsyncMutex<Option<ShellState>>,
848 default_timeout_ms: u64,
849}
850
851impl Default for PersistentShellTool {
852 fn default() -> Self {
853 PersistentShellTool {
854 state: AsyncMutex::new(None),
855 default_timeout_ms: DEFAULT_BASH_TIMEOUT_MS,
856 }
857 }
858}
859
860struct ShellState {
861 #[allow(dead_code)]
864 child: tokio::process::Child,
865 stdin: tokio::process::ChildStdin,
866 stdout: tokio::io::BufReader<tokio::process::ChildStdout>,
867}
868
869const SHELL_SENTINEL: &str = "__SC_SHELL_DONE__";
870
871#[derive(Deserialize)]
872struct ShellArgs {
873 #[serde(default)]
874 command: Option<String>,
875 #[serde(default)]
876 write_stdin: Option<String>,
877 #[serde(default)]
878 timeout_ms: Option<u64>,
879}
880
881impl PersistentShellTool {
882 async fn ensure_started(
883 &self,
884 state: &mut Option<ShellState>,
885 ctx: &ToolContext,
886 ) -> Result<()> {
887 if state.is_some() {
888 return Ok(());
889 }
890 let mut child = build_sandboxed_interactive_sh(ctx)
891 .current_dir(&ctx.cwd)
892 .stdin(std::process::Stdio::piped())
893 .stdout(std::process::Stdio::piped())
894 .stderr(std::process::Stdio::piped())
895 .kill_on_drop(true)
898 .spawn()
899 .map_err(|e| Error::tool("shell", format!("spawn sh: {e}")))?;
900 let stdin = child
901 .stdin
902 .take()
903 .ok_or_else(|| Error::tool("shell", "no stdin"))?;
904 let stdout = tokio::io::BufReader::new(
905 child
906 .stdout
907 .take()
908 .ok_or_else(|| Error::tool("shell", "no stdout"))?,
909 );
910 *state = Some(ShellState {
911 child,
912 stdin,
913 stdout,
914 });
915 Ok(())
916 }
917}
918
919#[async_trait]
920impl Tool for PersistentShellTool {
921 fn name(&self) -> &str {
922 "shell"
923 }
924 fn description(&self) -> &str {
925 "Run a command in a PERSISTENT shell whose working directory, environment, and shell functions survive across calls (unlike one-shot bash). Pass `command` to run to completion (returns exit code), or `write_stdin` to feed raw input to the shell (for interactive programs)."
926 }
927 fn parameters(&self) -> Value {
928 json!({
929 "type": "object",
930 "properties": {
931 "command": {"type": "string", "description": "Command to run in the persistent shell."},
932 "write_stdin": {"type": "string", "description": "Raw text to write to the shell's stdin instead of running a command."},
933 "timeout_ms": {"type": "integer", "description": "Timeout in milliseconds (default 120000)."}
934 },
935 "additionalProperties": false
936 })
937 }
938 async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
939 let a: ShellArgs = parse_args(self.name(), args)?;
940 let timeout = Duration::from_millis(a.timeout_ms.unwrap_or(self.default_timeout_ms));
941 let mut guard = self.state.lock().await;
942 self.ensure_started(&mut guard, ctx).await?;
943 let st = guard.as_mut().expect("started");
944
945 if let Some(input) = a.write_stdin {
946 st.stdin
947 .write_all(input.as_bytes())
948 .await
949 .map_err(|e| Error::tool(self.name(), format!("write_stdin: {e}")))?;
950 st.stdin.flush().await.ok();
951 let out = read_available(&mut st.stdout, Duration::from_millis(800)).await;
953 return Ok(if out.is_empty() {
954 "(no output)".into()
955 } else {
956 out
957 });
958 }
959
960 let command = a
961 .command
962 .ok_or_else(|| Error::tool(self.name(), "provide `command` or `write_stdin`"))?;
963 let wrapped =
966 format!("{{ {command}\n}} 2>&1\nprintf '%s %d\\n' '{SHELL_SENTINEL}' \"$?\"\n");
967 st.stdin
968 .write_all(wrapped.as_bytes())
969 .await
970 .map_err(|e| Error::tool(self.name(), format!("write: {e}")))?;
971 st.stdin.flush().await.ok();
972
973 let mut acc = String::new();
976 let mut code = -1;
977 let read_fut = async {
978 let mut chunk = [0u8; 4096];
979 loop {
980 let n = st.stdout.read(&mut chunk).await.unwrap_or(0);
981 if n == 0 {
982 break; }
984 acc.push_str(&String::from_utf8_lossy(&chunk[..n]));
985 if let Some(pos) = acc.find(SHELL_SENTINEL) {
986 let after = &acc[pos + SHELL_SENTINEL.len()..];
987 if let Some(nl) = after.find('\n') {
988 code = after[..nl].trim().parse().unwrap_or(-1);
989 acc.truncate(pos);
990 break;
991 }
992 }
993 }
994 };
995 if tokio::time::timeout(timeout, read_fut).await.is_err() {
996 return Err(Error::tool(
997 self.name(),
998 format!("command timed out after {timeout:?}"),
999 ));
1000 }
1001 let output = if acc.trim().is_empty() {
1002 "(no output)".to_string()
1003 } else {
1004 acc.trim_end().to_string()
1005 };
1006 Ok(format!("exit code: {code}\n{output}"))
1007 }
1008}
1009
1010async fn read_available<R: AsyncReadExt + Unpin>(reader: &mut R, window: Duration) -> String {
1013 let mut buf = Vec::new();
1014 let mut chunk = [0u8; 4096];
1015 loop {
1016 match tokio::time::timeout(window, reader.read(&mut chunk)).await {
1017 Ok(Ok(0)) => break, Ok(Ok(n)) => buf.extend_from_slice(&chunk[..n]),
1019 Ok(Err(_)) => break,
1020 Err(_) => break, }
1022 }
1023 String::from_utf8_lossy(&buf).into_owned()
1024}
1025
1026pub struct UpdatePlanTool {
1032 plan: std::sync::Mutex<Vec<PlanStep>>,
1033}
1034
1035impl Default for UpdatePlanTool {
1036 fn default() -> Self {
1037 UpdatePlanTool {
1038 plan: std::sync::Mutex::new(Vec::new()),
1039 }
1040 }
1041}
1042
1043#[derive(Deserialize, Clone)]
1044struct PlanStep {
1045 step: String,
1046 #[serde(default = "default_status")]
1047 status: String,
1048}
1049fn default_status() -> String {
1050 "pending".to_string()
1051}
1052
1053#[derive(Deserialize)]
1054struct PlanArgs {
1055 plan: Vec<PlanStep>,
1056}
1057
1058impl UpdatePlanTool {
1059 pub fn current(&self) -> Vec<(String, String)> {
1061 self.plan
1062 .lock()
1063 .unwrap()
1064 .iter()
1065 .map(|s| (s.step.clone(), s.status.clone()))
1066 .collect()
1067 }
1068}
1069
1070#[async_trait]
1071impl Tool for UpdatePlanTool {
1072 fn name(&self) -> &str {
1073 "update_plan"
1074 }
1075 fn description(&self) -> &str {
1076 "Record or update the task plan: a checklist of steps with statuses (pending/in_progress/completed). Replaces the current plan. Use it to track multi-step work."
1077 }
1078 fn parameters(&self) -> Value {
1079 json!({
1080 "type": "object",
1081 "properties": {
1082 "plan": {
1083 "type": "array",
1084 "items": {
1085 "type": "object",
1086 "properties": {
1087 "step": {"type": "string"},
1088 "status": {"type": "string", "enum": ["pending", "in_progress", "completed"]}
1089 },
1090 "required": ["step"]
1091 }
1092 }
1093 },
1094 "required": ["plan"],
1095 "additionalProperties": false
1096 })
1097 }
1098 async fn execute(&self, args: Value, _ctx: &ToolContext) -> Result<String> {
1099 let a: PlanArgs = parse_args(self.name(), args)?;
1100 *self.plan.lock().unwrap() = a.plan.clone();
1101 let rendered = a
1102 .plan
1103 .iter()
1104 .map(|s| {
1105 let mark = match s.status.as_str() {
1106 "completed" => "[x]",
1107 "in_progress" => "[~]",
1108 _ => "[ ]",
1109 };
1110 format!("{mark} {}", s.step)
1111 })
1112 .collect::<Vec<_>>()
1113 .join("\n");
1114 Ok(if rendered.is_empty() {
1115 "(empty plan)".into()
1116 } else {
1117 format!("Plan updated:\n{rendered}")
1118 })
1119 }
1120}