1use std::path::PathBuf;
8use std::sync::Arc;
9
10use async_trait::async_trait;
11use serde_json::{json, Value};
12
13use fluers_core::error::CoreError;
14use fluers_core::tool::{
15 validate_input, InvokeContext, JsonValue, ParameterSchema, Tool, ToolDefinition, ToolResult,
16};
17use fluers_core::Result;
18
19use crate::env::{Limits, SessionEnv};
20use crate::error::RuntimeError;
21
22#[must_use]
27pub fn mvp_tools(env: Arc<dyn SessionEnv>) -> Vec<Arc<dyn Tool>> {
28 mvp_tools_with_limits(env, Limits::default())
29}
30
31#[must_use]
33pub fn mvp_tools_with_limits(env: Arc<dyn SessionEnv>, limits: Limits) -> Vec<Arc<dyn Tool>> {
34 vec![
35 Arc::new(ReadTool::new(env.clone(), limits)),
36 Arc::new(WriteTool::new(env.clone(), limits)),
37 Arc::new(EditTool::new(env.clone(), limits)),
38 Arc::new(BashTool::new(env.clone(), limits)),
39 Arc::new(GlobTool::new(env.clone())),
40 Arc::new(GrepTool::new(env)),
41 ]
42}
43
44pub struct ReadTool {
50 env: Arc<dyn SessionEnv>,
51 limits: Limits,
52 def: ToolDefinition,
53}
54
55impl ReadTool {
56 #[must_use]
58 pub fn new(env: Arc<dyn SessionEnv>, limits: Limits) -> Self {
59 let def = ToolDefinition {
60 name: "read".into(),
61 label: "Read File".into(),
62 description: "Read a file from the sandbox. Path must be relative.".into(),
63 parameters: ParameterSchema {
64 fields: json_schema(&[
65 ("path", "string", true),
66 ("max_lines", "number", false),
67 ("max_bytes", "number", false),
68 ]),
69 },
70 };
71 Self { env, limits, def }
72 }
73}
74
75#[async_trait]
76impl Tool for ReadTool {
77 fn definition(&self) -> ToolDefinition {
78 self.def.clone()
79 }
80
81 async fn execute(&self, ctx: InvokeContext, input: JsonValue) -> Result<ToolResult> {
82 validate_input(&self.def, &input)?;
83 if ctx.cancel.is_cancelled() {
84 return Err(CoreError::Cancelled("read cancelled".into()));
85 }
86 let path = input
87 .get("path")
88 .and_then(Value::as_str)
89 .ok_or_else(|| CoreError::ToolInputValidation("read: `path` required".into()))?;
90 let max_lines = input
91 .get("max_lines")
92 .and_then(Value::as_u64)
93 .map(|n| n as usize)
94 .unwrap_or(self.limits.max_read_lines);
95 let max_bytes = input
96 .get("max_bytes")
97 .and_then(Value::as_u64)
98 .map(|n| n as usize)
99 .unwrap_or(self.limits.max_read_bytes);
100 match self
101 .env
102 .read_file(&PathBuf::from(path), max_lines, max_bytes)
103 .await
104 {
105 Ok(content) => Ok(ToolResult {
106 content: vec![json!({ "type": "text", "text": content })],
107 details: Some(json!({ "path": path, "bytes": content.len() })),
108 }),
109 Err(RuntimeError::Io(e)) => Ok(ToolResult {
110 content: vec![
111 json!({ "type": "text", "text": format!("Error reading `{path}`: {e}") }),
112 ],
113 details: None,
114 }),
115 Err(other) => Err(CoreError::ToolOutput(other.to_string())),
116 }
117 }
118}
119
120pub struct WriteTool {
126 env: Arc<dyn SessionEnv>,
127 #[allow(dead_code)]
128 limits: Limits,
129 def: ToolDefinition,
130}
131
132impl WriteTool {
133 #[must_use]
135 pub fn new(env: Arc<dyn SessionEnv>, limits: Limits) -> Self {
136 let def = ToolDefinition {
137 name: "write".into(),
138 label: "Write File".into(),
139 description: "Write content to a file. Creates the file and parent directories.".into(),
140 parameters: ParameterSchema {
141 fields: json_schema(&[("path", "string", true), ("content", "string", true)]),
142 },
143 };
144 Self { env, limits, def }
145 }
146}
147
148#[async_trait]
149impl Tool for WriteTool {
150 fn definition(&self) -> ToolDefinition {
151 self.def.clone()
152 }
153
154 async fn execute(&self, _ctx: InvokeContext, input: JsonValue) -> Result<ToolResult> {
155 validate_input(&self.def, &input)?;
156 let path = input
157 .get("path")
158 .and_then(Value::as_str)
159 .ok_or_else(|| CoreError::ToolInputValidation("write: `path` required".into()))?;
160 let content = input
161 .get("content")
162 .and_then(Value::as_str)
163 .ok_or_else(|| CoreError::ToolInputValidation("write: `content` required".into()))?;
164 match self.env.write_file(&PathBuf::from(path), content).await {
165 Ok(()) => Ok(ToolResult {
166 content: vec![json!({
167 "type": "text",
168 "text": format!("Wrote {} bytes to `{}`", content.len(), path)
169 })],
170 details: Some(json!({ "path": path, "bytes": content.len() })),
171 }),
172 Err(e) => Err(CoreError::ToolOutput(e.to_string())),
173 }
174 }
175}
176
177pub struct EditTool {
188 env: Arc<dyn SessionEnv>,
189 limits: Limits,
190 def: ToolDefinition,
191}
192
193impl EditTool {
194 #[must_use]
196 pub fn new(env: Arc<dyn SessionEnv>, limits: Limits) -> Self {
197 let def = ToolDefinition {
198 name: "edit".into(),
199 label: "Edit File".into(),
200 description:
201 "Replace a unique snippet in a file. `old_text` must match exactly one place."
202 .into(),
203 parameters: ParameterSchema {
204 fields: json_schema(&[
205 ("path", "string", true),
206 ("old_text", "string", true),
207 ("new_text", "string", true),
208 ]),
209 },
210 };
211 Self { env, limits, def }
212 }
213}
214
215#[async_trait]
216impl Tool for EditTool {
217 fn definition(&self) -> ToolDefinition {
218 self.def.clone()
219 }
220
221 async fn execute(&self, _ctx: InvokeContext, input: JsonValue) -> Result<ToolResult> {
222 validate_input(&self.def, &input)?;
223 let path = input
224 .get("path")
225 .and_then(Value::as_str)
226 .ok_or_else(|| CoreError::ToolInputValidation("edit: `path` required".into()))?;
227 let old_text = input
228 .get("old_text")
229 .and_then(Value::as_str)
230 .ok_or_else(|| CoreError::ToolInputValidation("edit: `old_text` required".into()))?;
231 let new_text = input
232 .get("new_text")
233 .and_then(Value::as_str)
234 .ok_or_else(|| CoreError::ToolInputValidation("edit: `new_text` required".into()))?;
235 if old_text.is_empty() {
236 return Err(CoreError::ToolInputValidation(
237 "edit: `old_text` must be non-empty".into(),
238 ));
239 }
240 let content = self
241 .env
242 .read_file_full(&PathBuf::from(path), self.limits.max_edit_bytes)
243 .await
244 .map_err(|e| CoreError::ToolOutput(e.to_string()))?;
245 let occurrences = content.matches(old_text).count();
246 if occurrences == 0 {
247 return Err(CoreError::ToolInputValidation(format!(
248 "edit: `old_text` not found in `{path}`"
249 )));
250 }
251 if occurrences > 1 {
252 return Err(CoreError::ToolInputValidation(format!(
253 "edit: `old_text` matches {occurrences} places in `{path}`; it must be unique"
254 )));
255 }
256 let updated = content.replacen(old_text, new_text, 1);
257 self.env
258 .write_file(&PathBuf::from(path), &updated)
259 .await
260 .map_err(|e| CoreError::ToolOutput(e.to_string()))?;
261 Ok(ToolResult {
262 content: vec![json!({
263 "type": "text",
264 "text": format!("Edited `{}` ({} -> {} bytes)", path, content.len(), updated.len())
265 })],
266 details: Some(json!({
267 "path": path,
268 "old_bytes": content.len(),
269 "new_bytes": updated.len()
270 })),
271 })
272 }
273}
274
275pub struct BashTool {
277 env: Arc<dyn SessionEnv>,
278 limits: Limits,
279 def: ToolDefinition,
280}
281
282impl BashTool {
283 #[must_use]
285 pub fn new(env: Arc<dyn SessionEnv>, limits: Limits) -> Self {
286 let def = ToolDefinition {
287 name: "bash".into(),
288 label: "Run Shell".into(),
289 description: "Run a shell command in the sandbox. Returns stdout, stderr, exit code."
290 .into(),
291 parameters: ParameterSchema {
292 fields: json_schema(&[
293 ("command", "string", true),
294 ("timeout_ms", "number", false),
295 ]),
296 },
297 };
298 Self { env, limits, def }
299 }
300}
301
302#[async_trait]
303impl Tool for BashTool {
304 fn definition(&self) -> ToolDefinition {
305 self.def.clone()
306 }
307
308 async fn execute(&self, ctx: InvokeContext, input: JsonValue) -> Result<ToolResult> {
309 validate_input(&self.def, &input)?;
310 let command = input
311 .get("command")
312 .and_then(Value::as_str)
313 .ok_or_else(|| CoreError::ToolInputValidation("bash: `command` required".into()))?;
314 let timeout_ms = input
315 .get("timeout_ms")
316 .and_then(Value::as_u64)
317 .or(Some(30_000));
318 match self
319 .env
320 .exec(command, &PathBuf::from("."), timeout_ms, &ctx.cancel)
321 .await
322 {
323 Ok(res) => {
324 let text = format!(
325 "[exit {}]\n--- stdout ---\n{}\n--- stderr ---\n{}",
326 res.exit_code, res.stdout, res.stderr
327 );
328 Ok(ToolResult {
329 content: vec![json!({ "type": "text", "text": text })],
330 details: Some(json!({
331 "exit_code": res.exit_code,
332 "max_grep": self.limits.max_grep_matches,
333 })),
334 })
335 }
336 Err(e) => Err(CoreError::ToolOutput(e.to_string())),
337 }
338 }
339}
340
341pub struct GlobTool {
347 env: Arc<dyn SessionEnv>,
348 def: ToolDefinition,
349}
350
351impl GlobTool {
352 #[must_use]
354 pub fn new(env: Arc<dyn SessionEnv>) -> Self {
355 let def = ToolDefinition {
356 name: "glob".into(),
357 label: "Glob".into(),
358 description: "List files (relative paths) matching a glob pattern.".into(),
359 parameters: ParameterSchema {
360 fields: json_schema(&[("pattern", "string", true)]),
361 },
362 };
363 Self { env, def }
364 }
365}
366
367#[async_trait]
368impl Tool for GlobTool {
369 fn definition(&self) -> ToolDefinition {
370 self.def.clone()
371 }
372
373 async fn execute(&self, _ctx: InvokeContext, input: JsonValue) -> Result<ToolResult> {
374 validate_input(&self.def, &input)?;
375 let pattern = input
376 .get("pattern")
377 .and_then(Value::as_str)
378 .ok_or_else(|| CoreError::ToolInputValidation("glob: `pattern` required".into()))?;
379 match self.env.glob(pattern, 1000).await {
380 Ok(paths) => {
381 let text = if paths.is_empty() {
382 "(no matches)".to_string()
383 } else {
384 paths.join("\n")
385 };
386 Ok(ToolResult {
387 content: vec![json!({ "type": "text", "text": text })],
388 details: Some(json!({ "count": paths.len() })),
389 })
390 }
391 Err(e) => Err(CoreError::ToolOutput(e.to_string())),
392 }
393 }
394}
395
396pub struct GrepTool {
402 env: Arc<dyn SessionEnv>,
403 def: ToolDefinition,
404}
405
406impl GrepTool {
407 #[must_use]
409 pub fn new(env: Arc<dyn SessionEnv>) -> Self {
410 let def = ToolDefinition {
411 name: "grep".into(),
412 label: "Grep".into(),
413 description: "Search file contents for a pattern (regex).".into(),
414 parameters: ParameterSchema {
415 fields: json_schema(&[("pattern", "string", true), ("paths", "array", false)]),
416 },
417 };
418 Self { env, def }
419 }
420}
421
422#[async_trait]
423impl Tool for GrepTool {
424 fn definition(&self) -> ToolDefinition {
425 self.def.clone()
426 }
427
428 async fn execute(&self, _ctx: InvokeContext, input: JsonValue) -> Result<ToolResult> {
429 validate_input(&self.def, &input)?;
430 let pattern = input
431 .get("pattern")
432 .and_then(Value::as_str)
433 .ok_or_else(|| CoreError::ToolInputValidation("grep: `pattern` required".into()))?;
434 let paths: Vec<&str> = input
435 .get("paths")
436 .and_then(Value::as_array)
437 .map(|arr| arr.iter().filter_map(Value::as_str).collect())
438 .unwrap_or_default();
439 match self.env.grep(pattern, &paths, 100).await {
440 Ok(matches) => {
441 let text = if matches.is_empty() {
442 "(no matches)".to_string()
443 } else {
444 matches.join("\n")
445 };
446 Ok(ToolResult {
447 content: vec![json!({ "type": "text", "text": text })],
448 details: Some(json!({ "count": matches.len() })),
449 })
450 }
451 Err(e) => Err(CoreError::ToolOutput(e.to_string())),
452 }
453 }
454}
455
456fn json_schema(props: &[(&str, &str, bool)]) -> std::collections::BTreeMap<String, Value> {
462 let mut fields: std::collections::BTreeMap<String, Value> = std::collections::BTreeMap::new();
463 fields.insert("type".into(), json!("object"));
464 let mut properties = serde_json::Map::new();
465 let mut required = Vec::new();
466 for (name, ty, req) in props {
467 properties.insert(
468 (*name).to_string(),
469 json!({ "type": ty, "description": format!("`{name}` parameter") }),
470 );
471 if *req {
472 required.push(json!(name));
473 }
474 }
475 fields.insert("properties".into(), Value::Object(properties));
476 fields.insert("required".into(), Value::Array(required));
477 fields
478}
479
480#[cfg(test)]
481mod edit_tests {
482 use super::*;
484 use crate::LocalSessionEnv;
485 use std::path::Path;
486 use tokio_util::sync::CancellationToken;
487
488 fn ctx() -> InvokeContext {
489 InvokeContext {
490 tool_call_id: "t1".into(),
491 cancel: CancellationToken::new(),
492 }
493 }
494
495 #[tokio::test]
496 async fn edit_replaces_unique_match() {
497 let dir = tempfile::tempdir().unwrap();
498 let env: Arc<dyn SessionEnv> = Arc::new(
499 LocalSessionEnv::new(dir.path(), Limits::default())
500 .await
501 .unwrap(),
502 );
503 env.write_file(Path::new("a.txt"), "hello world")
504 .await
505 .unwrap();
506 let tool = EditTool::new(env.clone(), Limits::default());
507 let input = json!({"path":"a.txt","old_text":"world","new_text":"moon"});
508 tool.execute(ctx(), input).await.unwrap();
509 let after = env.read_file(Path::new("a.txt"), 100, 1024).await.unwrap();
510 assert_eq!(after, "hello moon");
511 }
512
513 #[tokio::test]
514 async fn edit_errors_when_old_text_not_found() {
515 let dir = tempfile::tempdir().unwrap();
516 let env: Arc<dyn SessionEnv> = Arc::new(
517 LocalSessionEnv::new(dir.path(), Limits::default())
518 .await
519 .unwrap(),
520 );
521 env.write_file(Path::new("a.txt"), "hello").await.unwrap();
522 let tool = EditTool::new(env, Limits::default());
523 let input = json!({"path":"a.txt","old_text":"xyz","new_text":"abc"});
524 let res = tool.execute(ctx(), input).await;
525 assert!(matches!(res, Err(CoreError::ToolInputValidation(_))));
526 }
527
528 #[tokio::test]
529 async fn edit_errors_when_old_text_not_unique() {
530 let dir = tempfile::tempdir().unwrap();
531 let env: Arc<dyn SessionEnv> = Arc::new(
532 LocalSessionEnv::new(dir.path(), Limits::default())
533 .await
534 .unwrap(),
535 );
536 env.write_file(Path::new("a.txt"), "ha ha ha")
537 .await
538 .unwrap();
539 let tool = EditTool::new(env, Limits::default());
540 let input = json!({"path":"a.txt","old_text":"ha","new_text":"ho"});
541 let res = tool.execute(ctx(), input).await;
542 assert!(matches!(res, Err(CoreError::ToolInputValidation(_))));
543 }
544
545 #[tokio::test]
546 async fn edit_errors_when_old_text_empty() {
547 let dir = tempfile::tempdir().unwrap();
548 let env: Arc<dyn SessionEnv> = Arc::new(
549 LocalSessionEnv::new(dir.path(), Limits::default())
550 .await
551 .unwrap(),
552 );
553 env.write_file(Path::new("a.txt"), "hello").await.unwrap();
554 let tool = EditTool::new(env, Limits::default());
555 let input = json!({"path":"a.txt","old_text":"","new_text":"x"});
556 let res = tool.execute(ctx(), input).await;
557 assert!(matches!(res, Err(CoreError::ToolInputValidation(_))));
558 }
559
560 #[tokio::test]
561 async fn edit_rejects_path_escape() {
562 let dir = tempfile::tempdir().unwrap();
563 let env: Arc<dyn SessionEnv> = Arc::new(
564 LocalSessionEnv::new(dir.path(), Limits::default())
565 .await
566 .unwrap(),
567 );
568 env.write_file(Path::new("a.txt"), "hello").await.unwrap();
569 let tool = EditTool::new(env, Limits::default());
570 let input = json!({"path":"../escape.txt","old_text":"x","new_text":"y"});
572 let res = tool.execute(ctx(), input).await;
573 assert!(res.is_err(), "path escape must be rejected");
574 }
575
576 #[tokio::test]
577 async fn edit_round_trips_multiline_block() {
578 let dir = tempfile::tempdir().unwrap();
579 let env: Arc<dyn SessionEnv> = Arc::new(
580 LocalSessionEnv::new(dir.path(), Limits::default())
581 .await
582 .unwrap(),
583 );
584 let body = "line one\nTODO: fix me\nline three\n";
585 env.write_file(Path::new("m.txt"), body).await.unwrap();
586 let tool = EditTool::new(env.clone(), Limits::default());
587 let input = json!({"path":"m.txt","old_text":"TODO: fix me\nline three","new_text":"DONE\nline three"});
588 tool.execute(ctx(), input).await.unwrap();
589 let after = env.read_file(Path::new("m.txt"), 100, 1024).await.unwrap();
590 assert_eq!(after, "line one\nDONE\nline three\n");
591 }
592
593 #[tokio::test]
594 async fn edit_errors_when_file_too_large_and_does_not_destroy() {
595 let dir = tempfile::tempdir().unwrap();
599 let env: Arc<dyn SessionEnv> = Arc::new(
600 LocalSessionEnv::new(dir.path(), Limits::default())
601 .await
602 .unwrap(),
603 );
604 let original = "a".repeat(100);
605 env.write_file(Path::new("big.txt"), &original)
606 .await
607 .unwrap();
608 let small_cap = Limits {
609 max_edit_bytes: 50,
610 ..Limits::default()
611 };
612 let tool = EditTool::new(env.clone(), small_cap);
613 let input = json!({"path":"big.txt","old_text":"a","new_text":"b"});
614 let res = tool.execute(ctx(), input).await;
615 assert!(matches!(res, Err(CoreError::ToolOutput(_))));
616 let after = env
618 .read_file(Path::new("big.txt"), 1000, 4096)
619 .await
620 .unwrap();
621 assert_eq!(after, original);
622 }
623}