byob-tools 0.2.1

Agent tools: filesystem, web fetch, charts, python (L2)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
//! The tool interface. Everything the model can call — built-in file tools, plugin
//! skills, Python tools, MCP server tools — implements [`Tool`] and is collected in a
//! [`Registry`]. The agent loop (`byob-agent`) owns calling them, the permission gate, and
//! approvals; tools only describe themselves and do the work.
//!
//! Tools are synchronous and run on the turn's worker thread. Long-running tools should
//! poll [`ToolContext::cancelled`].
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]

pub mod artifact;
pub mod chart;
mod fs;
pub mod preview;
pub mod sandbox;
pub mod undo;

pub use artifact::{CreateArtifact, ReadArtifact, UpdateArtifact};
pub use chart::MakeChart;
pub use fs::{DEFAULT_TRASH_DIR, DeleteFile, ListFiles, MoveFile, MoveFiles, ReadFile, WriteFile};
pub use preview::Preview;
pub use undo::{Journal, UndoOp};

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};

use serde::de::DeserializeOwned;
use serde_json::Value;

/// What the model sees: name, description, JSON Schema for the arguments.
#[derive(Debug, Clone, PartialEq)]
pub struct ToolSpec {
    /// `[a-zA-Z0-9_-]{1,64}` — the constraint OpenAI-compatible servers enforce.
    pub name: String,
    pub description: String,
    /// JSON Schema object describing the arguments.
    pub parameters: Value,
}

/// What a call does, for the permission gate. Ordered from least to most dangerous.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Access {
    /// Reads inside the workspace only.
    Read,
    /// Creates, modifies, moves or deletes files.
    Write,
    /// Talks to the network.
    Network,
    /// Runs code or external programs.
    Execute,
}

/// Per-call environment provided by the agent.
pub struct ToolContext<'a> {
    /// Root folder the tool may touch. File tools must refuse paths outside it.
    pub workspace: &'a Path,
    cancel: &'a AtomicBool,
    journal: Option<&'a Journal>,
    chat: Option<&'a str>,
    asker: Option<&'a dyn Asker>,
    env: Option<&'a EnvPlan>,
}

/// Where a chat's commands run, as its workspace and chat settings decide (the engine
/// resolves them; see `byob_types::config`). The default leaves everything to the answers
/// the user gave for the folder (asked once).
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct EnvPlan {
    /// In the workspace's container (`Some(true)`), on this machine (`Some(false)`), or as
    /// the user answered for the folder (`None`). Chosen in the settings, so never asked.
    pub container: Option<bool>,
    /// Load the folder's project environment (flake.nix, …) without asking (`Some(true)`),
    /// never (`Some(false)`), or as answered (`None`).
    pub project: Option<bool>,
    /// The container's image; `None`: the folder's devcontainer.json, else the default.
    pub image: Option<String>,
}

impl<'a> ToolContext<'a> {
    pub fn new(workspace: &'a Path, cancel: &'a AtomicBool) -> Self {
        Self { workspace, cancel, journal: None, chat: None, asker: None, env: None }
    }

    /// Run commands where `plan` says (see [`EnvPlan`]).
    #[must_use]
    pub fn with_env(mut self, plan: &'a EnvPlan) -> Self {
        self.env = Some(plan);
        self
    }

    /// Where this chat's commands run (the default when the engine didn't say).
    pub fn env(&self) -> EnvPlan {
        self.env.cloned().unwrap_or_default()
    }

    /// Let the tool ask the user questions while it runs (see [`ToolContext::ask`]).
    #[must_use]
    pub fn with_asker(mut self, asker: &'a dyn Asker) -> Self {
        self.asker = Some(asker);
        self
    }

    /// Ask the user `q` (an approval card), blocking until answered. Without anyone to ask
    /// (no asker: tests, direct calls) the answer is [`Answer::AutoNo`].
    pub fn ask(&self, q: &Question) -> Answer {
        if self.cancelled() {
            return Answer::AutoNo;
        }
        self.asker.map_or(Answer::AutoNo, |a| a.ask(q))
    }

    /// The chat this call runs in (artifacts belong to a chat).
    #[must_use]
    pub fn with_chat(mut self, chat: &'a str) -> Self {
        self.chat = Some(chat);
        self
    }

    pub fn chat(&self) -> Option<&'a str> {
        self.chat
    }

    /// Record undo information for this call's file changes in `journal`.
    #[must_use]
    pub fn with_journal(mut self, journal: &'a Journal) -> Self {
        self.journal = Some(journal);
        self
    }

    /// The turn's undo journal, if it keeps one. Tools that change files record an
    /// [`UndoOp`] per change, and back up a file with [`Journal::backup`] before
    /// overwriting it.
    pub fn journal(&self) -> Option<&'a Journal> {
        self.journal
    }

    /// Shorthand: record `op` if there is a journal.
    pub fn record(&self, op: UndoOp) {
        if let Some(j) = self.journal {
            j.record(op);
        }
    }

    pub fn cancelled(&self) -> bool {
        self.cancel.load(Ordering::Relaxed)
    }

    /// The turn's cancel flag, for APIs that poll it themselves (e.g. MCP calls).
    pub fn cancel_flag(&self) -> &AtomicBool {
        self.cancel
    }
}

/// A file-system effect of a call, reported for the UI and the undo journal.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FileChange {
    Created(PathBuf),
    Modified(PathBuf),
    Deleted(PathBuf),
    Moved { from: PathBuf, to: PathBuf },
}

/// A question a tool asks the user while it runs, e.g. "Load this workspace's Nix dev
/// shell?". Shown as an approval card with only Allow and Deny: the tool remembers the
/// answer itself, so "always allow" grants don't apply.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Question {
    /// Phrased like a pending title: "Load the workspace's Nix dev shell (flake.nix)".
    pub title: String,
    /// What saying yes means, for the card (a few short lines).
    pub text: String,
    /// Shown prominently, e.g. "installs system-wide".
    pub warning: Option<String>,
    /// Decides who answers: the permission policy for this level may answer for the user
    /// (e.g. `--auto-approve`), unless [`Question::always_ask`].
    pub access: Access,
    /// Only a person may say yes: not the policy, not `--auto-approve`.
    pub always_ask: bool,
}

/// The answer to a [`Question`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Answer {
    /// A person said yes.
    Yes,
    /// A person said no.
    No,
    /// The policy allowed it without asking (`--auto-approve`).
    AutoYes,
    /// Nobody was asked: the policy refused, the turn stopped, or there's no one to ask.
    AutoNo,
}

impl Answer {
    pub fn allowed(self) -> bool {
        matches!(self, Self::Yes | Self::AutoYes)
    }

    /// A person answered (worth remembering).
    pub fn by_user(self) -> bool {
        matches!(self, Self::Yes | Self::No)
    }
}

/// Puts a [`Question`] to the user; the agent provides one per call.
pub trait Asker: Send + Sync {
    fn ask(&self, q: &Question) -> Answer;
}

/// Workspace environment settings the engine shows to clients and lets them change
/// (provided by the code-running tools, `byob-exec`). Calls may block for a while
/// (stopping a container): the engine makes them off its own thread.
pub trait EnvControl: Send + Sync {
    /// The settings and state of workspace `ws`, whose settings say `plan`.
    fn describe(&self, ws: &Path, plan: &EnvPlan) -> byob_protocol::WorkspaceEnv;
    /// Change them; returns what happened, for the user.
    fn apply(&self, ws: &Path, action: &byob_protocol::EnvAction) -> Result<String, String>;
    /// What this machine offers (Nix, a container runtime). Cheap.
    fn support(&self) -> byob_protocol::EnvSupport;
    /// Whether the container runtime answers; the error says what to do. May take seconds.
    fn check_containers(&self) -> Result<String, String>;
    /// Forget everything byob keeps for `ws` outside the folder: its answers, installed
    /// packages and container. The folder itself is never touched. May take seconds.
    fn forget(&self, ws: &Path) -> Result<String, String>;
}

/// Result of a call. `text` goes back to the model; keep it bounded.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ToolOutput {
    pub text: String,
    /// The call failed; `text` explains why (the model sees it and can recover).
    pub failed: bool,
    pub changes: Vec<FileChange>,
}

impl ToolOutput {
    pub fn ok(text: impl Into<String>) -> Self {
        Self { text: text.into(), ..Default::default() }
    }

    pub fn err(text: impl Into<String>) -> Self {
        Self { text: text.into(), failed: true, ..Default::default() }
    }
}

pub trait Tool: Send + Sync {
    fn spec(&self) -> ToolSpec;

    /// Short human title for the finished call, e.g. "Wrote notes.md". `args` is untrusted.
    fn title(&self, args: &Value) -> String;

    /// Title for the call before and while it runs, and on approval cards, e.g.
    /// "Write notes.md". Defaults to "Run <tool name>".
    fn pending_title(&self, _args: &Value) -> String {
        format!("Run {}", self.spec().name)
    }

    /// A readable account of what the call would do, for approval cards: e.g. a diff for a
    /// file write. Reads only, no side effects; bounded. Defaults to the key arguments.
    fn preview(&self, args: &Value, _workspace: &Path) -> Option<Preview> {
        Some(preview::args(args))
    }

    /// What this call would do; drives the permission gate. Must not have side effects.
    fn access(&self, args: &Value) -> Access;

    /// `Some(warning)` if this call must be approved by a person every time: no policy,
    /// "always allow" grant or `--auto-approve` lets it run (e.g. installing system-wide).
    /// The warning is shown on the approval card.
    fn always_ask(&self, _args: &Value) -> Option<String> {
        None
    }

    /// Do it. `args` is untrusted model output: validate everything, never panic.
    fn run(&self, args: &Value, ctx: &ToolContext<'_>) -> ToolOutput;
}

/// Parse tool arguments into a typed struct, with a model-readable error.
pub fn parse_args<T: DeserializeOwned>(args: &Value) -> Result<T, String> {
    serde_json::from_value(args.clone()).map_err(|e| format!("invalid arguments: {e}"))
}

/// Tools available to a turn, by name.
#[derive(Default, Clone)]
pub struct Registry {
    tools: BTreeMap<String, Arc<dyn Tool>>,
}

impl Registry {
    /// Add a tool. A later tool with the same name replaces the earlier one.
    pub fn register(&mut self, tool: Arc<dyn Tool>) {
        self.tools.insert(tool.spec().name, tool);
    }

    pub fn get(&self, name: &str) -> Option<&Arc<dyn Tool>> {
        self.tools.get(name)
    }

    pub fn specs(&self) -> Vec<ToolSpec> {
        self.tools.values().map(|t| t.spec()).collect()
    }

    pub fn len(&self) -> usize {
        self.tools.len()
    }

    pub fn is_empty(&self) -> bool {
        self.tools.is_empty()
    }
}

/// Add the built-in file tools (`list_files`, `read_file`, `write_file`, `move_file`,
/// `move_files`, `delete_file`), `make_chart` and the artifact tools. Deleted files go to [`DEFAULT_TRASH_DIR`].
pub fn register_builtins(reg: &mut Registry) {
    reg.register(Arc::new(ListFiles));
    reg.register(Arc::new(ReadFile));
    reg.register(Arc::new(WriteFile));
    reg.register(Arc::new(MoveFile));
    reg.register(Arc::new(MoveFiles));
    reg.register(Arc::new(DeleteFile::default()));
    reg.register(Arc::new(MakeChart));
    reg.register(Arc::new(CreateArtifact));
    reg.register(Arc::new(UpdateArtifact));
    reg.register(Arc::new(ReadArtifact));
}

/// A registry with just the built-in file tools.
pub fn builtin() -> Registry {
    let mut reg = Registry::default();
    register_builtins(&mut reg);
    reg
}

#[cfg(test)]
mod tests {
    use super::*;

    struct Echo;

    impl Tool for Echo {
        fn spec(&self) -> ToolSpec {
            ToolSpec {
                name: "echo".into(),
                description: "Echo text".into(),
                parameters: serde_json::json!({"type":"object","properties":{"text":{"type":"string"}},"required":["text"]}),
            }
        }
        fn title(&self, _args: &Value) -> String {
            "Echo".into()
        }
        fn access(&self, _args: &Value) -> Access {
            Access::Read
        }
        fn run(&self, args: &Value, _ctx: &ToolContext<'_>) -> ToolOutput {
            #[derive(serde::Deserialize)]
            struct Args {
                text: String,
            }
            match parse_args::<Args>(args) {
                Ok(a) => ToolOutput::ok(a.text),
                Err(e) => ToolOutput::err(e),
            }
        }
    }

    #[test]
    fn registry_dispatch_and_bad_args() {
        let mut reg = Registry::default();
        reg.register(Arc::new(Echo));
        let cancel = AtomicBool::new(false);
        let ctx = ToolContext::new(Path::new("/tmp"), &cancel);
        let tool = reg.get("echo").unwrap();
        assert_eq!(tool.run(&serde_json::json!({"text":"hi"}), &ctx), ToolOutput::ok("hi"));
        assert!(tool.run(&serde_json::json!({"nope":1}), &ctx).failed);
        assert_eq!(reg.specs().len(), 1);
    }

    #[test]
    fn builtins_are_valid() {
        let reg = builtin();
        assert_eq!(reg.len(), 10);
        for spec in reg.specs() {
            assert!(spec.name.len() <= 64 && spec.name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'));
            assert_eq!(spec.parameters["type"], "object");
            let tool = reg.get(&spec.name).unwrap();
            let expected = if matches!(spec.name.as_str(), "list_files" | "read_file" | "make_chart" | "create_artifact" | "update_artifact" | "read_artifact")
            {
                Access::Read
            } else {
                Access::Write
            };
            assert_eq!(tool.access(&serde_json::json!({})), expected, "{}", spec.name);
        }
    }

    #[test]
    fn default_pending_title_and_preview() {
        assert_eq!(Echo.pending_title(&serde_json::json!({})), "Run echo");
        let p = Echo.preview(&serde_json::json!({"text": "hi"}), Path::new("/tmp"));
        assert_eq!(p, Some(Preview::Args { fields: vec![("text".into(), "hi".into())], code: None, more: 0 }));
    }

    #[test]
    fn access_is_ordered_by_danger() {
        assert!(Access::Read < Access::Write && Access::Write < Access::Network && Access::Network < Access::Execute);
    }
}