Skip to main content

byob_tools/
lib.rs

1//! The tool interface. Everything the model can call — built-in file tools, plugin
2//! skills, Python tools, MCP server tools — implements [`Tool`] and is collected in a
3//! [`Registry`]. The agent loop (`byob-agent`) owns calling them, the permission gate, and
4//! approvals; tools only describe themselves and do the work.
5//!
6//! Tools are synchronous and run on the turn's worker thread. Long-running tools should
7//! poll [`ToolContext::cancelled`].
8#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
9
10pub mod artifact;
11pub mod chart;
12mod fs;
13pub mod preview;
14pub mod sandbox;
15pub mod undo;
16
17pub use artifact::{CreateArtifact, ReadArtifact, UpdateArtifact};
18pub use chart::MakeChart;
19pub use fs::{DEFAULT_TRASH_DIR, DeleteFile, ListFiles, MoveFile, MoveFiles, ReadFile, WriteFile};
20pub use preview::Preview;
21pub use undo::{Journal, UndoOp};
22
23use std::collections::BTreeMap;
24use std::path::{Path, PathBuf};
25use std::sync::Arc;
26use std::sync::atomic::{AtomicBool, Ordering};
27
28use serde::de::DeserializeOwned;
29use serde_json::Value;
30
31/// What the model sees: name, description, JSON Schema for the arguments.
32#[derive(Debug, Clone, PartialEq)]
33pub struct ToolSpec {
34    /// `[a-zA-Z0-9_-]{1,64}` — the constraint OpenAI-compatible servers enforce.
35    pub name: String,
36    pub description: String,
37    /// JSON Schema object describing the arguments.
38    pub parameters: Value,
39}
40
41/// What a call does, for the permission gate. Ordered from least to most dangerous.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
43pub enum Access {
44    /// Reads inside the workspace only.
45    Read,
46    /// Creates, modifies, moves or deletes files.
47    Write,
48    /// Talks to the network.
49    Network,
50    /// Runs code or external programs.
51    Execute,
52}
53
54/// Per-call environment provided by the agent.
55pub struct ToolContext<'a> {
56    /// Root folder the tool may touch. File tools must refuse paths outside it.
57    pub workspace: &'a Path,
58    cancel: &'a AtomicBool,
59    journal: Option<&'a Journal>,
60    chat: Option<&'a str>,
61}
62
63impl<'a> ToolContext<'a> {
64    pub fn new(workspace: &'a Path, cancel: &'a AtomicBool) -> Self {
65        Self { workspace, cancel, journal: None, chat: None }
66    }
67
68    /// The chat this call runs in (artifacts belong to a chat).
69    #[must_use]
70    pub fn with_chat(mut self, chat: &'a str) -> Self {
71        self.chat = Some(chat);
72        self
73    }
74
75    pub fn chat(&self) -> Option<&'a str> {
76        self.chat
77    }
78
79    /// Record undo information for this call's file changes in `journal`.
80    #[must_use]
81    pub fn with_journal(mut self, journal: &'a Journal) -> Self {
82        self.journal = Some(journal);
83        self
84    }
85
86    /// The turn's undo journal, if it keeps one. Tools that change files record an
87    /// [`UndoOp`] per change, and back up a file with [`Journal::backup`] before
88    /// overwriting it.
89    pub fn journal(&self) -> Option<&'a Journal> {
90        self.journal
91    }
92
93    /// Shorthand: record `op` if there is a journal.
94    pub fn record(&self, op: UndoOp) {
95        if let Some(j) = self.journal {
96            j.record(op);
97        }
98    }
99
100    pub fn cancelled(&self) -> bool {
101        self.cancel.load(Ordering::Relaxed)
102    }
103
104    /// The turn's cancel flag, for APIs that poll it themselves (e.g. MCP calls).
105    pub fn cancel_flag(&self) -> &AtomicBool {
106        self.cancel
107    }
108}
109
110/// A file-system effect of a call, reported for the UI and the undo journal.
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub enum FileChange {
113    Created(PathBuf),
114    Modified(PathBuf),
115    Deleted(PathBuf),
116    Moved { from: PathBuf, to: PathBuf },
117}
118
119/// Result of a call. `text` goes back to the model; keep it bounded.
120#[derive(Debug, Clone, Default, PartialEq)]
121pub struct ToolOutput {
122    pub text: String,
123    /// The call failed; `text` explains why (the model sees it and can recover).
124    pub failed: bool,
125    pub changes: Vec<FileChange>,
126}
127
128impl ToolOutput {
129    pub fn ok(text: impl Into<String>) -> Self {
130        Self { text: text.into(), ..Default::default() }
131    }
132
133    pub fn err(text: impl Into<String>) -> Self {
134        Self { text: text.into(), failed: true, ..Default::default() }
135    }
136}
137
138pub trait Tool: Send + Sync {
139    fn spec(&self) -> ToolSpec;
140
141    /// Short human title for the finished call, e.g. "Wrote notes.md". `args` is untrusted.
142    fn title(&self, args: &Value) -> String;
143
144    /// Title for the call before and while it runs, and on approval cards, e.g.
145    /// "Write notes.md". Defaults to "Run <tool name>".
146    fn pending_title(&self, _args: &Value) -> String {
147        format!("Run {}", self.spec().name)
148    }
149
150    /// A readable account of what the call would do, for approval cards: e.g. a diff for a
151    /// file write. Reads only, no side effects; bounded. Defaults to the key arguments.
152    fn preview(&self, args: &Value, _workspace: &Path) -> Option<Preview> {
153        Some(preview::args(args))
154    }
155
156    /// What this call would do; drives the permission gate. Must not have side effects.
157    fn access(&self, args: &Value) -> Access;
158
159    /// Do it. `args` is untrusted model output: validate everything, never panic.
160    fn run(&self, args: &Value, ctx: &ToolContext<'_>) -> ToolOutput;
161}
162
163/// Parse tool arguments into a typed struct, with a model-readable error.
164pub fn parse_args<T: DeserializeOwned>(args: &Value) -> Result<T, String> {
165    serde_json::from_value(args.clone()).map_err(|e| format!("invalid arguments: {e}"))
166}
167
168/// Tools available to a turn, by name.
169#[derive(Default, Clone)]
170pub struct Registry {
171    tools: BTreeMap<String, Arc<dyn Tool>>,
172}
173
174impl Registry {
175    /// Add a tool. A later tool with the same name replaces the earlier one.
176    pub fn register(&mut self, tool: Arc<dyn Tool>) {
177        self.tools.insert(tool.spec().name, tool);
178    }
179
180    pub fn get(&self, name: &str) -> Option<&Arc<dyn Tool>> {
181        self.tools.get(name)
182    }
183
184    pub fn specs(&self) -> Vec<ToolSpec> {
185        self.tools.values().map(|t| t.spec()).collect()
186    }
187
188    pub fn len(&self) -> usize {
189        self.tools.len()
190    }
191
192    pub fn is_empty(&self) -> bool {
193        self.tools.is_empty()
194    }
195}
196
197/// Add the built-in file tools (`list_files`, `read_file`, `write_file`, `move_file`,
198/// `move_files`, `delete_file`), `make_chart` and the artifact tools. Deleted files go to [`DEFAULT_TRASH_DIR`].
199pub fn register_builtins(reg: &mut Registry) {
200    reg.register(Arc::new(ListFiles));
201    reg.register(Arc::new(ReadFile));
202    reg.register(Arc::new(WriteFile));
203    reg.register(Arc::new(MoveFile));
204    reg.register(Arc::new(MoveFiles));
205    reg.register(Arc::new(DeleteFile::default()));
206    reg.register(Arc::new(MakeChart));
207    reg.register(Arc::new(CreateArtifact));
208    reg.register(Arc::new(UpdateArtifact));
209    reg.register(Arc::new(ReadArtifact));
210}
211
212/// A registry with just the built-in file tools.
213pub fn builtin() -> Registry {
214    let mut reg = Registry::default();
215    register_builtins(&mut reg);
216    reg
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    struct Echo;
224
225    impl Tool for Echo {
226        fn spec(&self) -> ToolSpec {
227            ToolSpec {
228                name: "echo".into(),
229                description: "Echo text".into(),
230                parameters: serde_json::json!({"type":"object","properties":{"text":{"type":"string"}},"required":["text"]}),
231            }
232        }
233        fn title(&self, _args: &Value) -> String {
234            "Echo".into()
235        }
236        fn access(&self, _args: &Value) -> Access {
237            Access::Read
238        }
239        fn run(&self, args: &Value, _ctx: &ToolContext<'_>) -> ToolOutput {
240            #[derive(serde::Deserialize)]
241            struct Args {
242                text: String,
243            }
244            match parse_args::<Args>(args) {
245                Ok(a) => ToolOutput::ok(a.text),
246                Err(e) => ToolOutput::err(e),
247            }
248        }
249    }
250
251    #[test]
252    fn registry_dispatch_and_bad_args() {
253        let mut reg = Registry::default();
254        reg.register(Arc::new(Echo));
255        let cancel = AtomicBool::new(false);
256        let ctx = ToolContext::new(Path::new("/tmp"), &cancel);
257        let tool = reg.get("echo").unwrap();
258        assert_eq!(tool.run(&serde_json::json!({"text":"hi"}), &ctx), ToolOutput::ok("hi"));
259        assert!(tool.run(&serde_json::json!({"nope":1}), &ctx).failed);
260        assert_eq!(reg.specs().len(), 1);
261    }
262
263    #[test]
264    fn builtins_are_valid() {
265        let reg = builtin();
266        assert_eq!(reg.len(), 10);
267        for spec in reg.specs() {
268            assert!(spec.name.len() <= 64 && spec.name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'));
269            assert_eq!(spec.parameters["type"], "object");
270            let tool = reg.get(&spec.name).unwrap();
271            let expected = if matches!(spec.name.as_str(), "list_files" | "read_file" | "make_chart" | "create_artifact" | "update_artifact" | "read_artifact")
272            {
273                Access::Read
274            } else {
275                Access::Write
276            };
277            assert_eq!(tool.access(&serde_json::json!({})), expected, "{}", spec.name);
278        }
279    }
280
281    #[test]
282    fn default_pending_title_and_preview() {
283        assert_eq!(Echo.pending_title(&serde_json::json!({})), "Run echo");
284        let p = Echo.preview(&serde_json::json!({"text": "hi"}), Path::new("/tmp"));
285        assert_eq!(p, Some(Preview::Args { fields: vec![("text".into(), "hi".into())], code: None, more: 0 }));
286    }
287
288    #[test]
289    fn access_is_ordered_by_danger() {
290        assert!(Access::Read < Access::Write && Access::Write < Access::Network && Access::Network < Access::Execute);
291    }
292}