Skip to main content

fluers_runtime/
tool.rs

1//! Built-in tools, wired to a real [`SessionEnv`].
2//!
3//! Each tool operates purely against the env, so the *same* tools work over a
4//! local directory, a virtual fs, or a remote container. Mirrors Flue's tools
5//! in `packages/runtime/src/agent.ts`.
6
7use 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/// Build the MVP toolset over `env`: `read`, `write`, `edit`, `bash`, `glob`, `grep`.
23///
24/// Each tool captures the env (and the resource [`Limits`]) so it can execute
25/// sandboxed operations.
26#[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/// Like [`mvp_tools`] but with explicit resource limits.
32#[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
44// ---------------------------------------------------------------------------
45// read
46// ---------------------------------------------------------------------------
47
48/// `read` — read a file from the sandbox, bounded by line/byte limits.
49pub struct ReadTool {
50    env: Arc<dyn SessionEnv>,
51    limits: Limits,
52    def: ToolDefinition,
53}
54
55impl ReadTool {
56    /// Construct a `read` tool bound to `env` with the given `limits`.
57    #[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
120// ---------------------------------------------------------------------------
121// write
122// ---------------------------------------------------------------------------
123
124/// `write` — write a file, creating parent directories.
125pub struct WriteTool {
126    env: Arc<dyn SessionEnv>,
127    #[allow(dead_code)]
128    limits: Limits,
129    def: ToolDefinition,
130}
131
132impl WriteTool {
133    /// Construct a `write` tool bound to `env` with the given `limits`.
134    #[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
177// ---------------------------------------------------------------------------
178// edit
179// ---------------------------------------------------------------------------
180
181/// `edit` — replace a **unique** snippet in a file.
182///
183/// `old_text` must occur exactly once in the file; the tool errors if it is
184/// absent (no-op risk) or ambiguous (>1 match). Reads the file in full via
185/// [`SessionEnv::read_file_full`] so an oversized file is **rejected** rather
186/// than silently truncated (which would lose data on write-back).
187pub struct EditTool {
188    env: Arc<dyn SessionEnv>,
189    limits: Limits,
190    def: ToolDefinition,
191}
192
193impl EditTool {
194    /// Construct an `edit` tool bound to `env` with the given `limits`.
195    #[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
275/// `bash` — run a shell command in the sandbox.
276pub struct BashTool {
277    env: Arc<dyn SessionEnv>,
278    limits: Limits,
279    def: ToolDefinition,
280}
281
282impl BashTool {
283    /// Construct a `bash` tool bound to `env` with the given `limits`.
284    #[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
341// ---------------------------------------------------------------------------
342// glob
343// ---------------------------------------------------------------------------
344
345/// `glob` — list files matching a pattern.
346pub struct GlobTool {
347    env: Arc<dyn SessionEnv>,
348    def: ToolDefinition,
349}
350
351impl GlobTool {
352    /// Construct a `glob` tool bound to `env`.
353    #[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
396// ---------------------------------------------------------------------------
397// grep
398// ---------------------------------------------------------------------------
399
400/// `grep` — search file contents.
401pub struct GrepTool {
402    env: Arc<dyn SessionEnv>,
403    def: ToolDefinition,
404}
405
406impl GrepTool {
407    /// Construct a `grep` tool bound to `env`.
408    #[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
456// ---------------------------------------------------------------------------
457// schema helper
458// ---------------------------------------------------------------------------
459
460/// Build a JSON-Schema-ish object with typed properties + a `required` list.
461fn 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    //! `edit` tool semantics: unique-match replace + data-loss safety.
483    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        // `..` is rejected at the env containment seam (resolve), before any edit.
571        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        // The data-loss-safety guarantee: an oversized file is REJECTED by
596        // read_file_full (FileTooLarge), never silently truncated + written
597        // back. The original content must survive untouched.
598        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        // Content is intact — no truncation, no partial write-back.
617        let after = env
618            .read_file(Path::new("big.txt"), 1000, 4096)
619            .await
620            .unwrap();
621        assert_eq!(after, original);
622    }
623}