Skip to main content

recursive/tools/
mod.rs

1//! Tool abstraction: any side effect the model can request.
2//!
3//! Tools are orthogonal to the agent and to each other. To add a capability
4//! you implement `Tool` and register it; no other file changes.
5
6use async_trait::async_trait;
7use serde_json::Value;
8use std::collections::BTreeMap;
9use std::sync::Arc;
10
11use crate::error::{Error, Result};
12use crate::llm::ToolSpec;
13
14pub mod apply_patch;
15pub mod count_lines;
16pub mod fs;
17pub mod shell;
18
19pub use apply_patch::ApplyPatch;
20pub use count_lines::CountLines;
21pub use fs::{ListDir, ReadFile, WriteFile};
22pub use shell::RunShell;
23
24#[async_trait]
25pub trait Tool: Send + Sync {
26    fn spec(&self) -> ToolSpec;
27    async fn execute(&self, arguments: Value) -> Result<String>;
28}
29
30#[derive(Default, Clone)]
31pub struct ToolRegistry {
32    tools: BTreeMap<String, Arc<dyn Tool>>,
33}
34
35impl ToolRegistry {
36    pub fn new() -> Self {
37        Self::default()
38    }
39
40    pub fn register(mut self, tool: Arc<dyn Tool>) -> Self {
41        let name = tool.spec().name;
42        self.tools.insert(name, tool);
43        self
44    }
45
46    pub fn specs(&self) -> Vec<ToolSpec> {
47        self.tools.values().map(|t| t.spec()).collect()
48    }
49
50    pub fn names(&self) -> Vec<String> {
51        self.tools.keys().cloned().collect()
52    }
53
54    pub fn get(&self, name: &str) -> Option<Arc<dyn Tool>> {
55        self.tools.get(name).cloned()
56    }
57
58    pub async fn invoke(&self, name: &str, arguments: Value) -> Result<String> {
59        let tool = self
60            .get(name)
61            .ok_or_else(|| Error::UnknownTool(name.into()))?;
62        tool.execute(arguments).await.map_err(|e| match e {
63            Error::Tool { .. } | Error::BadToolArgs { .. } | Error::UnknownTool(_) => e,
64            other => Error::Tool {
65                name: name.into(),
66                message: other.to_string(),
67            },
68        })
69    }
70}
71
72/// Resolve a possibly-relative path against the workspace root.
73///
74/// Both the root and the candidate are normalised to an absolute, dot-free
75/// form before comparison so that `--workspace .` works exactly the same as
76/// `--workspace /abs/path`.
77pub(crate) fn resolve_within(root: &std::path::Path, path: &str) -> Result<std::path::PathBuf> {
78    let candidate = std::path::Path::new(path);
79    let joined = if candidate.is_absolute() {
80        candidate.to_path_buf()
81    } else {
82        root.join(candidate)
83    };
84    let abs_root = absolutise(root);
85    let abs_joined = absolutise(&joined);
86    if !abs_joined.starts_with(&abs_root) {
87        return Err(Error::BadToolArgs {
88            name: "<fs>".into(),
89            message: format!(
90                "path `{}` escapes workspace root `{}`",
91                path,
92                abs_root.display()
93            ),
94        });
95    }
96    Ok(abs_joined)
97}
98
99/// Turn a path into an absolute, normalised form. Does not touch the disk,
100/// so it works for files that don't yet exist (needed by `write_file`).
101fn absolutise(p: &std::path::Path) -> std::path::PathBuf {
102    let abs = if p.is_absolute() {
103        p.to_path_buf()
104    } else {
105        std::env::current_dir()
106            .unwrap_or_else(|_| std::path::PathBuf::from("."))
107            .join(p)
108    };
109    normalise(&abs)
110}
111
112fn normalise(p: &std::path::Path) -> std::path::PathBuf {
113    let mut out = std::path::PathBuf::new();
114    for c in p.components() {
115        use std::path::Component::*;
116        match c {
117            ParentDir => {
118                out.pop();
119            }
120            CurDir => {}
121            other => out.push(other.as_os_str()),
122        }
123    }
124    out
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use async_trait::async_trait;
131
132    struct Echo;
133
134    #[async_trait]
135    impl Tool for Echo {
136        fn spec(&self) -> ToolSpec {
137            ToolSpec {
138                name: "echo".into(),
139                description: "echo".into(),
140                parameters: serde_json::json!({"type":"object","properties":{"msg":{"type":"string"}}}),
141            }
142        }
143        async fn execute(&self, args: Value) -> Result<String> {
144            Ok(args["msg"].as_str().unwrap_or("").into())
145        }
146    }
147
148    #[tokio::test]
149    async fn registry_dispatches_and_errors_on_unknown() {
150        let reg = ToolRegistry::new().register(Arc::new(Echo));
151        let out = reg
152            .invoke("echo", serde_json::json!({"msg":"hi"}))
153            .await
154            .unwrap();
155        assert_eq!(out, "hi");
156        let err = reg.invoke("nope", serde_json::json!({})).await.unwrap_err();
157        assert!(matches!(err, Error::UnknownTool(_)));
158    }
159
160    #[test]
161    fn resolve_within_rejects_escape() {
162        let root = std::path::Path::new("/work");
163        assert!(resolve_within(root, "../etc/passwd").is_err());
164        assert!(resolve_within(root, "/elsewhere").is_err());
165        assert!(resolve_within(root, "src/lib.rs").is_ok());
166    }
167
168    #[test]
169    fn resolve_within_handles_relative_root() {
170        // Regression: `--workspace .` (relative) used to fail the prefix check.
171        let cwd = std::env::current_dir().unwrap();
172        let resolved = resolve_within(std::path::Path::new("."), "src/lib.rs").unwrap();
173        assert!(resolved.starts_with(&cwd));
174        assert!(resolved.ends_with("src/lib.rs"));
175    }
176}