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    asker: Option<&'a dyn Asker>,
62    env: Option<&'a EnvPlan>,
63}
64
65/// Where a chat's commands run, as its workspace and chat settings decide (the engine
66/// resolves them; see `byob_types::config`). The default leaves everything to the answers
67/// the user gave for the folder (asked once).
68#[derive(Debug, Clone, Default, PartialEq, Eq)]
69pub struct EnvPlan {
70    /// In the workspace's container (`Some(true)`), on this machine (`Some(false)`), or as
71    /// the user answered for the folder (`None`). Chosen in the settings, so never asked.
72    pub container: Option<bool>,
73    /// Load the folder's project environment (flake.nix, …) without asking (`Some(true)`),
74    /// never (`Some(false)`), or as answered (`None`).
75    pub project: Option<bool>,
76    /// The container's image; `None`: the folder's devcontainer.json, else the default.
77    pub image: Option<String>,
78}
79
80impl<'a> ToolContext<'a> {
81    pub fn new(workspace: &'a Path, cancel: &'a AtomicBool) -> Self {
82        Self { workspace, cancel, journal: None, chat: None, asker: None, env: None }
83    }
84
85    /// Run commands where `plan` says (see [`EnvPlan`]).
86    #[must_use]
87    pub fn with_env(mut self, plan: &'a EnvPlan) -> Self {
88        self.env = Some(plan);
89        self
90    }
91
92    /// Where this chat's commands run (the default when the engine didn't say).
93    pub fn env(&self) -> EnvPlan {
94        self.env.cloned().unwrap_or_default()
95    }
96
97    /// Let the tool ask the user questions while it runs (see [`ToolContext::ask`]).
98    #[must_use]
99    pub fn with_asker(mut self, asker: &'a dyn Asker) -> Self {
100        self.asker = Some(asker);
101        self
102    }
103
104    /// Ask the user `q` (an approval card), blocking until answered. Without anyone to ask
105    /// (no asker: tests, direct calls) the answer is [`Answer::AutoNo`].
106    pub fn ask(&self, q: &Question) -> Answer {
107        if self.cancelled() {
108            return Answer::AutoNo;
109        }
110        self.asker.map_or(Answer::AutoNo, |a| a.ask(q))
111    }
112
113    /// The chat this call runs in (artifacts belong to a chat).
114    #[must_use]
115    pub fn with_chat(mut self, chat: &'a str) -> Self {
116        self.chat = Some(chat);
117        self
118    }
119
120    pub fn chat(&self) -> Option<&'a str> {
121        self.chat
122    }
123
124    /// Record undo information for this call's file changes in `journal`.
125    #[must_use]
126    pub fn with_journal(mut self, journal: &'a Journal) -> Self {
127        self.journal = Some(journal);
128        self
129    }
130
131    /// The turn's undo journal, if it keeps one. Tools that change files record an
132    /// [`UndoOp`] per change, and back up a file with [`Journal::backup`] before
133    /// overwriting it.
134    pub fn journal(&self) -> Option<&'a Journal> {
135        self.journal
136    }
137
138    /// Shorthand: record `op` if there is a journal.
139    pub fn record(&self, op: UndoOp) {
140        if let Some(j) = self.journal {
141            j.record(op);
142        }
143    }
144
145    pub fn cancelled(&self) -> bool {
146        self.cancel.load(Ordering::Relaxed)
147    }
148
149    /// The turn's cancel flag, for APIs that poll it themselves (e.g. MCP calls).
150    pub fn cancel_flag(&self) -> &AtomicBool {
151        self.cancel
152    }
153}
154
155/// A file-system effect of a call, reported for the UI and the undo journal.
156#[derive(Debug, Clone, PartialEq, Eq)]
157pub enum FileChange {
158    Created(PathBuf),
159    Modified(PathBuf),
160    Deleted(PathBuf),
161    Moved { from: PathBuf, to: PathBuf },
162}
163
164/// A question a tool asks the user while it runs, e.g. "Load this workspace's Nix dev
165/// shell?". Shown as an approval card with only Allow and Deny: the tool remembers the
166/// answer itself, so "always allow" grants don't apply.
167#[derive(Debug, Clone, PartialEq, Eq)]
168pub struct Question {
169    /// Phrased like a pending title: "Load the workspace's Nix dev shell (flake.nix)".
170    pub title: String,
171    /// What saying yes means, for the card (a few short lines).
172    pub text: String,
173    /// Shown prominently, e.g. "installs system-wide".
174    pub warning: Option<String>,
175    /// Decides who answers: the permission policy for this level may answer for the user
176    /// (e.g. `--auto-approve`), unless [`Question::always_ask`].
177    pub access: Access,
178    /// Only a person may say yes: not the policy, not `--auto-approve`.
179    pub always_ask: bool,
180}
181
182/// The answer to a [`Question`].
183#[derive(Debug, Clone, Copy, PartialEq, Eq)]
184pub enum Answer {
185    /// A person said yes.
186    Yes,
187    /// A person said no.
188    No,
189    /// The policy allowed it without asking (`--auto-approve`).
190    AutoYes,
191    /// Nobody was asked: the policy refused, the turn stopped, or there's no one to ask.
192    AutoNo,
193}
194
195impl Answer {
196    pub fn allowed(self) -> bool {
197        matches!(self, Self::Yes | Self::AutoYes)
198    }
199
200    /// A person answered (worth remembering).
201    pub fn by_user(self) -> bool {
202        matches!(self, Self::Yes | Self::No)
203    }
204}
205
206/// Puts a [`Question`] to the user; the agent provides one per call.
207pub trait Asker: Send + Sync {
208    fn ask(&self, q: &Question) -> Answer;
209}
210
211/// Workspace environment settings the engine shows to clients and lets them change
212/// (provided by the code-running tools, `byob-exec`). Calls may block for a while
213/// (stopping a container): the engine makes them off its own thread.
214pub trait EnvControl: Send + Sync {
215    /// The settings and state of workspace `ws`, whose settings say `plan`.
216    fn describe(&self, ws: &Path, plan: &EnvPlan) -> byob_protocol::WorkspaceEnv;
217    /// Change them; returns what happened, for the user.
218    fn apply(&self, ws: &Path, action: &byob_protocol::EnvAction) -> Result<String, String>;
219    /// What this machine offers (Nix, a container runtime). Cheap.
220    fn support(&self) -> byob_protocol::EnvSupport;
221    /// Whether the container runtime answers; the error says what to do. May take seconds.
222    fn check_containers(&self) -> Result<String, String>;
223    /// Forget everything byob keeps for `ws` outside the folder: its answers, installed
224    /// packages and container. The folder itself is never touched. May take seconds.
225    fn forget(&self, ws: &Path) -> Result<String, String>;
226}
227
228/// Result of a call. `text` goes back to the model; keep it bounded.
229#[derive(Debug, Clone, Default, PartialEq)]
230pub struct ToolOutput {
231    pub text: String,
232    /// The call failed; `text` explains why (the model sees it and can recover).
233    pub failed: bool,
234    pub changes: Vec<FileChange>,
235}
236
237impl ToolOutput {
238    pub fn ok(text: impl Into<String>) -> Self {
239        Self { text: text.into(), ..Default::default() }
240    }
241
242    pub fn err(text: impl Into<String>) -> Self {
243        Self { text: text.into(), failed: true, ..Default::default() }
244    }
245}
246
247pub trait Tool: Send + Sync {
248    fn spec(&self) -> ToolSpec;
249
250    /// Short human title for the finished call, e.g. "Wrote notes.md". `args` is untrusted.
251    fn title(&self, args: &Value) -> String;
252
253    /// Title for the call before and while it runs, and on approval cards, e.g.
254    /// "Write notes.md". Defaults to "Run <tool name>".
255    fn pending_title(&self, _args: &Value) -> String {
256        format!("Run {}", self.spec().name)
257    }
258
259    /// A readable account of what the call would do, for approval cards: e.g. a diff for a
260    /// file write. Reads only, no side effects; bounded. Defaults to the key arguments.
261    fn preview(&self, args: &Value, _workspace: &Path) -> Option<Preview> {
262        Some(preview::args(args))
263    }
264
265    /// What this call would do; drives the permission gate. Must not have side effects.
266    fn access(&self, args: &Value) -> Access;
267
268    /// `Some(warning)` if this call must be approved by a person every time: no policy,
269    /// "always allow" grant or `--auto-approve` lets it run (e.g. installing system-wide).
270    /// The warning is shown on the approval card.
271    fn always_ask(&self, _args: &Value) -> Option<String> {
272        None
273    }
274
275    /// Do it. `args` is untrusted model output: validate everything, never panic.
276    fn run(&self, args: &Value, ctx: &ToolContext<'_>) -> ToolOutput;
277}
278
279/// Parse tool arguments into a typed struct, with a model-readable error.
280pub fn parse_args<T: DeserializeOwned>(args: &Value) -> Result<T, String> {
281    serde_json::from_value(args.clone()).map_err(|e| format!("invalid arguments: {e}"))
282}
283
284/// Tools available to a turn, by name.
285#[derive(Default, Clone)]
286pub struct Registry {
287    tools: BTreeMap<String, Arc<dyn Tool>>,
288}
289
290impl Registry {
291    /// Add a tool. A later tool with the same name replaces the earlier one.
292    pub fn register(&mut self, tool: Arc<dyn Tool>) {
293        self.tools.insert(tool.spec().name, tool);
294    }
295
296    pub fn get(&self, name: &str) -> Option<&Arc<dyn Tool>> {
297        self.tools.get(name)
298    }
299
300    pub fn specs(&self) -> Vec<ToolSpec> {
301        self.tools.values().map(|t| t.spec()).collect()
302    }
303
304    pub fn len(&self) -> usize {
305        self.tools.len()
306    }
307
308    pub fn is_empty(&self) -> bool {
309        self.tools.is_empty()
310    }
311}
312
313/// Add the built-in file tools (`list_files`, `read_file`, `write_file`, `move_file`,
314/// `move_files`, `delete_file`), `make_chart` and the artifact tools. Deleted files go to [`DEFAULT_TRASH_DIR`].
315pub fn register_builtins(reg: &mut Registry) {
316    reg.register(Arc::new(ListFiles));
317    reg.register(Arc::new(ReadFile));
318    reg.register(Arc::new(WriteFile));
319    reg.register(Arc::new(MoveFile));
320    reg.register(Arc::new(MoveFiles));
321    reg.register(Arc::new(DeleteFile::default()));
322    reg.register(Arc::new(MakeChart));
323    reg.register(Arc::new(CreateArtifact));
324    reg.register(Arc::new(UpdateArtifact));
325    reg.register(Arc::new(ReadArtifact));
326}
327
328/// A registry with just the built-in file tools.
329pub fn builtin() -> Registry {
330    let mut reg = Registry::default();
331    register_builtins(&mut reg);
332    reg
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338
339    struct Echo;
340
341    impl Tool for Echo {
342        fn spec(&self) -> ToolSpec {
343            ToolSpec {
344                name: "echo".into(),
345                description: "Echo text".into(),
346                parameters: serde_json::json!({"type":"object","properties":{"text":{"type":"string"}},"required":["text"]}),
347            }
348        }
349        fn title(&self, _args: &Value) -> String {
350            "Echo".into()
351        }
352        fn access(&self, _args: &Value) -> Access {
353            Access::Read
354        }
355        fn run(&self, args: &Value, _ctx: &ToolContext<'_>) -> ToolOutput {
356            #[derive(serde::Deserialize)]
357            struct Args {
358                text: String,
359            }
360            match parse_args::<Args>(args) {
361                Ok(a) => ToolOutput::ok(a.text),
362                Err(e) => ToolOutput::err(e),
363            }
364        }
365    }
366
367    #[test]
368    fn registry_dispatch_and_bad_args() {
369        let mut reg = Registry::default();
370        reg.register(Arc::new(Echo));
371        let cancel = AtomicBool::new(false);
372        let ctx = ToolContext::new(Path::new("/tmp"), &cancel);
373        let tool = reg.get("echo").unwrap();
374        assert_eq!(tool.run(&serde_json::json!({"text":"hi"}), &ctx), ToolOutput::ok("hi"));
375        assert!(tool.run(&serde_json::json!({"nope":1}), &ctx).failed);
376        assert_eq!(reg.specs().len(), 1);
377    }
378
379    #[test]
380    fn builtins_are_valid() {
381        let reg = builtin();
382        assert_eq!(reg.len(), 10);
383        for spec in reg.specs() {
384            assert!(spec.name.len() <= 64 && spec.name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'));
385            assert_eq!(spec.parameters["type"], "object");
386            let tool = reg.get(&spec.name).unwrap();
387            let expected = if matches!(spec.name.as_str(), "list_files" | "read_file" | "make_chart" | "create_artifact" | "update_artifact" | "read_artifact")
388            {
389                Access::Read
390            } else {
391                Access::Write
392            };
393            assert_eq!(tool.access(&serde_json::json!({})), expected, "{}", spec.name);
394        }
395    }
396
397    #[test]
398    fn default_pending_title_and_preview() {
399        assert_eq!(Echo.pending_title(&serde_json::json!({})), "Run echo");
400        let p = Echo.preview(&serde_json::json!({"text": "hi"}), Path::new("/tmp"));
401        assert_eq!(p, Some(Preview::Args { fields: vec![("text".into(), "hi".into())], code: None, more: 0 }));
402    }
403
404    #[test]
405    fn access_is_ordered_by_danger() {
406        assert!(Access::Read < Access::Write && Access::Write < Access::Network && Access::Network < Access::Execute);
407    }
408}