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 artifact_types;
12pub mod chart;
13mod fs;
14pub mod images;
15pub mod preview;
16pub mod sandbox;
17pub mod undo;
18
19pub use artifact::{CreateArtifact, ReadArtifact, UpdateArtifact};
20pub use artifact_types::{ArtifactHooks, ArtifactType, ArtifactTypes};
21pub use chart::MakeChart;
22pub use fs::{DEFAULT_TRASH_DIR, DeleteFile, ListFiles, MoveFile, MoveFiles, ReadFile, WriteFile};
23pub use preview::Preview;
24pub use undo::{Journal, UndoOp};
25
26use std::collections::BTreeMap;
27use std::path::{Path, PathBuf};
28use std::sync::Arc;
29use std::sync::atomic::{AtomicBool, Ordering};
30
31use serde::de::DeserializeOwned;
32use serde_json::Value;
33
34/// What the model sees: name, description, JSON Schema for the arguments.
35#[derive(Debug, Clone, PartialEq)]
36pub struct ToolSpec {
37    /// `[a-zA-Z0-9_-]{1,64}` — the constraint OpenAI-compatible servers enforce.
38    pub name: String,
39    pub description: String,
40    /// JSON Schema object describing the arguments.
41    pub parameters: Value,
42}
43
44/// What a call does, for the permission gate. Ordered from least to most dangerous.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
46pub enum Access {
47    /// Reads inside the workspace only.
48    Read,
49    /// Creates, modifies, moves or deletes files.
50    Write,
51    /// Talks to the network.
52    Network,
53    /// Runs code or external programs.
54    Execute,
55}
56
57/// Per-call environment provided by the agent.
58pub struct ToolContext<'a> {
59    /// Root folder the tool may touch. File tools must refuse paths outside it.
60    pub workspace: &'a Path,
61    cancel: &'a AtomicBool,
62    journal: Option<&'a Journal>,
63    chat: Option<&'a str>,
64    asker: Option<&'a dyn Asker>,
65    env: Option<&'a EnvPlan>,
66    host: Option<&'a (dyn std::any::Any + Sync)>,
67    views: Option<&'a std::sync::Mutex<Vec<byob_protocol::View>>>,
68}
69
70/// Where a chat's commands run, as its workspace and chat settings decide (the engine
71/// resolves them; see `byob_types::config`). The default leaves everything to the answers
72/// the user gave for the folder (asked once).
73#[derive(Debug, Clone, Default, PartialEq, Eq)]
74pub struct EnvPlan {
75    /// In the workspace's container (`Some(true)`), on this machine (`Some(false)`), or as
76    /// the user answered for the folder (`None`). Chosen in the settings, so never asked.
77    pub container: Option<bool>,
78    /// Load the folder's project environment (flake.nix, …) without asking (`Some(true)`),
79    /// never (`Some(false)`), or as answered (`None`).
80    pub project: Option<bool>,
81    /// The container's image; `None`: the folder's devcontainer.json, else the default.
82    pub image: Option<String>,
83    /// The environment providers the chat's plugins switch on (`None`: every one byob has;
84    /// see `byob_exec::env::EnvProvider`).
85    pub providers: Option<EnvProviders>,
86}
87
88/// Which workspace environment providers a chat uses.
89#[derive(Debug, Clone, Default, PartialEq, Eq)]
90pub struct EnvProviders {
91    /// Registered providers left out, by id (byob's own: `nix`, `pixi`, `mise`, `container`).
92    pub off: Vec<String>,
93    /// Command providers plugins declare (`environments` in a manifest).
94    pub external: Vec<CommandEnv>,
95}
96
97/// A plugin's environment provider: when the workspace has one of `detect`, run `program`
98/// with `args` in it (sandboxed, with network) and read the environment it prints as JSON
99/// (`{"PATH": "...", "NAME": "value"}`, or pixi's `{"environment_variables": {...}}`).
100#[derive(Debug, Clone, Default, PartialEq, Eq)]
101pub struct CommandEnv {
102    /// Unique among providers (names the cache file): `<plugin>-<id>`.
103    pub id: String,
104    /// For messages: "conda environment".
105    pub label: String,
106    pub detect: Vec<String>,
107    /// Files whose contents key the cache (besides `detect`).
108    pub inputs: Vec<String>,
109    pub program: PathBuf,
110    pub args: Vec<String>,
111    /// The plugin's folder (readable by the run).
112    pub dir: PathBuf,
113}
114
115impl<'a> ToolContext<'a> {
116    pub fn new(workspace: &'a Path, cancel: &'a AtomicBool) -> Self {
117        Self { workspace, cancel, journal: None, chat: None, asker: None, env: None, host: None, views: None }
118    }
119
120    /// Where [`ToolContext::attach_view`] puts views (the engine's turn: on the call's step).
121    #[must_use]
122    pub fn with_views(mut self, views: &'a std::sync::Mutex<Vec<byob_protocol::View>>) -> Self {
123        self.views = Some(views);
124        self
125    }
126
127    /// Show `view` on this call's step (e.g. an MCP App). `false` if nobody takes views.
128    pub fn attach_view(&self, view: byob_protocol::View) -> bool {
129        let Some(v) = self.views else { return false };
130        v.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(view);
131        true
132    }
133
134    /// What the program running the tool offers beyond this context (the engine's turn
135    /// offers a handle to start child turns; see `byob_agent`'s `delegate`). Tools that need
136    /// it look it up by type with [`ToolContext::host`].
137    #[must_use]
138    pub fn with_host(mut self, host: &'a (dyn std::any::Any + Sync)) -> Self {
139        self.host = Some(host);
140        self
141    }
142
143    /// The host capability of type `T`, if the caller offered one.
144    pub fn host<T: 'static>(&self) -> Option<&'a T> {
145        self.host.and_then(|h| (h as &dyn std::any::Any).downcast_ref::<T>())
146    }
147
148    /// Run commands where `plan` says (see [`EnvPlan`]).
149    #[must_use]
150    pub fn with_env(mut self, plan: &'a EnvPlan) -> Self {
151        self.env = Some(plan);
152        self
153    }
154
155    /// Where this chat's commands run (the default when the engine didn't say).
156    pub fn env(&self) -> EnvPlan {
157        self.env.cloned().unwrap_or_default()
158    }
159
160    /// Let the tool ask the user questions while it runs (see [`ToolContext::ask`]).
161    #[must_use]
162    pub fn with_asker(mut self, asker: &'a dyn Asker) -> Self {
163        self.asker = Some(asker);
164        self
165    }
166
167    /// Ask the user `q` (an approval card), blocking until answered. Without anyone to ask
168    /// (no asker: tests, direct calls) the answer is [`Answer::AutoNo`].
169    pub fn ask(&self, q: &Question) -> Answer {
170        if self.cancelled() {
171            return Answer::AutoNo;
172        }
173        self.asker.map_or(Answer::AutoNo, |a| a.ask(q))
174    }
175
176    /// The chat this call runs in (artifacts belong to a chat).
177    #[must_use]
178    pub fn with_chat(mut self, chat: &'a str) -> Self {
179        self.chat = Some(chat);
180        self
181    }
182
183    pub fn chat(&self) -> Option<&'a str> {
184        self.chat
185    }
186
187    /// Record undo information for this call's file changes in `journal`.
188    #[must_use]
189    pub fn with_journal(mut self, journal: &'a Journal) -> Self {
190        self.journal = Some(journal);
191        self
192    }
193
194    /// The turn's undo journal, if it keeps one. Tools that change files record an
195    /// [`UndoOp`] per change, and back up a file with [`Journal::backup`] before
196    /// overwriting it.
197    pub fn journal(&self) -> Option<&'a Journal> {
198        self.journal
199    }
200
201    /// Shorthand: record `op` if there is a journal.
202    pub fn record(&self, op: UndoOp) {
203        if let Some(j) = self.journal {
204            j.record(op);
205        }
206    }
207
208    pub fn cancelled(&self) -> bool {
209        self.cancel.load(Ordering::Relaxed)
210    }
211
212    /// The turn's cancel flag, for APIs that poll it themselves (e.g. MCP calls).
213    pub fn cancel_flag(&self) -> &AtomicBool {
214        self.cancel
215    }
216}
217
218/// A file-system effect of a call, reported for the UI and the undo journal.
219#[derive(Debug, Clone, PartialEq, Eq)]
220pub enum FileChange {
221    Created(PathBuf),
222    Modified(PathBuf),
223    Deleted(PathBuf),
224    Moved { from: PathBuf, to: PathBuf },
225}
226
227/// A question a tool asks the user while it runs, e.g. "Load this workspace's Nix dev
228/// shell?". Shown as an approval card with only Allow and Deny: the tool remembers the
229/// answer itself, so "always allow" grants don't apply.
230#[derive(Debug, Clone, PartialEq, Eq)]
231pub struct Question {
232    /// Phrased like a pending title: "Load the workspace's Nix dev shell (flake.nix)".
233    pub title: String,
234    /// What saying yes means, for the card (a few short lines).
235    pub text: String,
236    /// Shown prominently, e.g. "installs system-wide".
237    pub warning: Option<String>,
238    /// Decides who answers: the permission policy for this level may answer for the user
239    /// (e.g. `--auto-approve`), unless [`Question::always_ask`].
240    pub access: Access,
241    /// Only a person may say yes: not the policy, not `--auto-approve`.
242    pub always_ask: bool,
243}
244
245/// The answer to a [`Question`].
246#[derive(Debug, Clone, Copy, PartialEq, Eq)]
247pub enum Answer {
248    /// A person said yes.
249    Yes,
250    /// A person said no.
251    No,
252    /// The policy allowed it without asking (`--auto-approve`).
253    AutoYes,
254    /// Nobody was asked: the policy refused, the turn stopped, or there's no one to ask.
255    AutoNo,
256}
257
258impl Answer {
259    pub fn allowed(self) -> bool {
260        matches!(self, Self::Yes | Self::AutoYes)
261    }
262
263    /// A person answered (worth remembering).
264    pub fn by_user(self) -> bool {
265        matches!(self, Self::Yes | Self::No)
266    }
267}
268
269/// Puts a [`Question`] to the user; the agent provides one per call.
270pub trait Asker: Send + Sync {
271    fn ask(&self, q: &Question) -> Answer;
272}
273
274/// Workspace environment settings the engine shows to clients and lets them change
275/// (provided by the code-running tools, `byob-exec`). Calls may block for a while
276/// (stopping a container): the engine makes them off its own thread.
277pub trait EnvControl: Send + Sync {
278    /// The settings and state of workspace `ws`, whose settings say `plan`.
279    fn describe(&self, ws: &Path, plan: &EnvPlan) -> byob_protocol::WorkspaceEnv;
280    /// Change them; returns what happened, for the user.
281    fn apply(&self, ws: &Path, action: &byob_protocol::EnvAction) -> Result<String, String>;
282    /// What this machine offers (Nix, a container runtime). Cheap.
283    fn support(&self) -> byob_protocol::EnvSupport;
284    /// Whether the container runtime answers; the error says what to do. May take seconds.
285    fn check_containers(&self) -> Result<String, String>;
286    /// Forget everything byob keeps for `ws` outside the folder: its answers, installed
287    /// packages and container. The folder itself is never touched. May take seconds.
288    fn forget(&self, ws: &Path) -> Result<String, String>;
289}
290
291/// Result of a call. `text` goes back to the model; keep it bounded.
292#[derive(Debug, Clone, Default, PartialEq)]
293pub struct ToolOutput {
294    pub text: String,
295    /// The call failed; `text` explains why (the model sees it and can recover).
296    pub failed: bool,
297    pub changes: Vec<FileChange>,
298}
299
300impl ToolOutput {
301    pub fn ok(text: impl Into<String>) -> Self {
302        Self { text: text.into(), ..Default::default() }
303    }
304
305    pub fn err(text: impl Into<String>) -> Self {
306        Self { text: text.into(), failed: true, ..Default::default() }
307    }
308}
309
310pub trait Tool: Send + Sync {
311    fn spec(&self) -> ToolSpec;
312
313    /// Short human title for the finished call, e.g. "Wrote notes.md". `args` is untrusted.
314    fn title(&self, args: &Value) -> String;
315
316    /// Title for the call before and while it runs, and on approval cards, e.g.
317    /// "Write notes.md". Defaults to "Run <tool name>".
318    fn pending_title(&self, _args: &Value) -> String {
319        format!("Run {}", self.spec().name)
320    }
321
322    /// A readable account of what the call would do, for approval cards: e.g. a diff for a
323    /// file write. Reads only, no side effects; bounded. Defaults to the key arguments.
324    fn preview(&self, args: &Value, _workspace: &Path) -> Option<Preview> {
325        Some(preview::args(args))
326    }
327
328    /// What this call would do; drives the permission gate. Must not have side effects.
329    fn access(&self, args: &Value) -> Access;
330
331    /// `Some(warning)` if this call must be approved by a person every time: no policy,
332    /// "always allow" grant or `--auto-approve` lets it run (e.g. installing system-wide).
333    /// The warning is shown on the approval card.
334    fn always_ask(&self, _args: &Value) -> Option<String> {
335        None
336    }
337
338    /// Do it. `args` is untrusted model output: validate everything, never panic.
339    fn run(&self, args: &Value, ctx: &ToolContext<'_>) -> ToolOutput;
340
341    /// Calls of this tool start before the other calls of the same round and run side by
342    /// side, each on a thread of its own (results still go back in call order). For tools
343    /// that mostly wait, like `delegate`.
344    fn concurrent(&self) -> bool {
345        false
346    }
347}
348
349/// Parse tool arguments into a typed struct, with a model-readable error.
350pub fn parse_args<T: DeserializeOwned>(args: &Value) -> Result<T, String> {
351    serde_json::from_value(args.clone()).map_err(|e| format!("invalid arguments: {e}"))
352}
353
354/// Tools available to a turn, by name.
355#[derive(Default, Clone)]
356pub struct Registry {
357    tools: BTreeMap<String, Arc<dyn Tool>>,
358}
359
360impl Registry {
361    /// Add a tool. A later tool with the same name replaces the earlier one.
362    pub fn register(&mut self, tool: Arc<dyn Tool>) {
363        self.tools.insert(tool.spec().name, tool);
364    }
365
366    pub fn get(&self, name: &str) -> Option<&Arc<dyn Tool>> {
367        self.tools.get(name)
368    }
369
370    /// Take a tool out (a native tool whose plugin is off).
371    pub fn remove(&mut self, name: &str) -> Option<Arc<dyn Tool>> {
372        self.tools.remove(name)
373    }
374
375    pub fn specs(&self) -> Vec<ToolSpec> {
376        self.tools.values().map(|t| t.spec()).collect()
377    }
378
379    pub fn len(&self) -> usize {
380        self.tools.len()
381    }
382
383    pub fn is_empty(&self) -> bool {
384        self.tools.is_empty()
385    }
386}
387
388/// Add the built-in file tools (`list_files`, `read_file`, `write_file`, `move_file`,
389/// `move_files`, `delete_file`), `make_chart` and the artifact tools (for byob's own
390/// artifact types). Deleted files go to [`DEFAULT_TRASH_DIR`].
391pub fn register_builtins(reg: &mut Registry) {
392    register_builtins_with(reg, &ArtifactTypes::builtin());
393}
394
395/// [`register_builtins`], with the artifact tools using `types` (the engine's registry).
396pub fn register_builtins_with(reg: &mut Registry, types: &ArtifactTypes) {
397    reg.register(Arc::new(ListFiles));
398    reg.register(Arc::new(ReadFile));
399    reg.register(Arc::new(WriteFile));
400    reg.register(Arc::new(MoveFile));
401    reg.register(Arc::new(MoveFiles));
402    reg.register(Arc::new(DeleteFile::default()));
403    reg.register(Arc::new(MakeChart));
404    reg.register(Arc::new(CreateArtifact { types: types.clone() }));
405    reg.register(Arc::new(UpdateArtifact { types: types.clone() }));
406    reg.register(Arc::new(ReadArtifact { types: types.clone() }));
407}
408
409/// A registry with just the built-in file tools.
410pub fn builtin() -> Registry {
411    let mut reg = Registry::default();
412    register_builtins(&mut reg);
413    reg
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419
420    struct Echo;
421
422    impl Tool for Echo {
423        fn spec(&self) -> ToolSpec {
424            ToolSpec {
425                name: "echo".into(),
426                description: "Echo text".into(),
427                parameters: serde_json::json!({"type":"object","properties":{"text":{"type":"string"}},"required":["text"]}),
428            }
429        }
430        fn title(&self, _args: &Value) -> String {
431            "Echo".into()
432        }
433        fn access(&self, _args: &Value) -> Access {
434            Access::Read
435        }
436        fn run(&self, args: &Value, _ctx: &ToolContext<'_>) -> ToolOutput {
437            #[derive(serde::Deserialize)]
438            struct Args {
439                text: String,
440            }
441            match parse_args::<Args>(args) {
442                Ok(a) => ToolOutput::ok(a.text),
443                Err(e) => ToolOutput::err(e),
444            }
445        }
446    }
447
448    #[test]
449    fn registry_dispatch_and_bad_args() {
450        let mut reg = Registry::default();
451        reg.register(Arc::new(Echo));
452        let cancel = AtomicBool::new(false);
453        let ctx = ToolContext::new(Path::new("/tmp"), &cancel);
454        let tool = reg.get("echo").unwrap();
455        assert_eq!(tool.run(&serde_json::json!({"text":"hi"}), &ctx), ToolOutput::ok("hi"));
456        assert!(tool.run(&serde_json::json!({"nope":1}), &ctx).failed);
457        assert_eq!(reg.specs().len(), 1);
458    }
459
460    #[test]
461    fn builtins_are_valid() {
462        let reg = builtin();
463        assert_eq!(reg.len(), 10);
464        for spec in reg.specs() {
465            assert!(spec.name.len() <= 64 && spec.name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'));
466            assert_eq!(spec.parameters["type"], "object");
467            let tool = reg.get(&spec.name).unwrap();
468            let expected = if matches!(spec.name.as_str(), "list_files" | "read_file" | "make_chart" | "create_artifact" | "update_artifact" | "read_artifact")
469            {
470                Access::Read
471            } else {
472                Access::Write
473            };
474            assert_eq!(tool.access(&serde_json::json!({})), expected, "{}", spec.name);
475        }
476    }
477
478    #[test]
479    fn default_pending_title_and_preview() {
480        assert_eq!(Echo.pending_title(&serde_json::json!({})), "Run echo");
481        let p = Echo.preview(&serde_json::json!({"text": "hi"}), Path::new("/tmp"));
482        assert_eq!(p, Some(Preview::Args { fields: vec![("text".into(), "hi".into())], code: None, more: 0 }));
483    }
484
485    #[test]
486    fn access_is_ordered_by_danger() {
487        assert!(Access::Read < Access::Write && Access::Write < Access::Network && Access::Network < Access::Execute);
488    }
489}