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