1#![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#[derive(Debug, Clone, PartialEq)]
33pub struct ToolSpec {
34 pub name: String,
36 pub description: String,
37 pub parameters: Value,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
43pub enum Access {
44 Read,
46 Write,
48 Network,
50 Execute,
52}
53
54pub struct ToolContext<'a> {
56 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
69pub struct EnvPlan {
70 pub container: Option<bool>,
73 pub project: Option<bool>,
76 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 #[must_use]
87 pub fn with_env(mut self, plan: &'a EnvPlan) -> Self {
88 self.env = Some(plan);
89 self
90 }
91
92 pub fn env(&self) -> EnvPlan {
94 self.env.cloned().unwrap_or_default()
95 }
96
97 #[must_use]
99 pub fn with_asker(mut self, asker: &'a dyn Asker) -> Self {
100 self.asker = Some(asker);
101 self
102 }
103
104 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 #[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 #[must_use]
126 pub fn with_journal(mut self, journal: &'a Journal) -> Self {
127 self.journal = Some(journal);
128 self
129 }
130
131 pub fn journal(&self) -> Option<&'a Journal> {
135 self.journal
136 }
137
138 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 pub fn cancel_flag(&self) -> &AtomicBool {
151 self.cancel
152 }
153}
154
155#[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#[derive(Debug, Clone, PartialEq, Eq)]
168pub struct Question {
169 pub title: String,
171 pub text: String,
173 pub warning: Option<String>,
175 pub access: Access,
178 pub always_ask: bool,
180}
181
182#[derive(Debug, Clone, Copy, PartialEq, Eq)]
184pub enum Answer {
185 Yes,
187 No,
189 AutoYes,
191 AutoNo,
193}
194
195impl Answer {
196 pub fn allowed(self) -> bool {
197 matches!(self, Self::Yes | Self::AutoYes)
198 }
199
200 pub fn by_user(self) -> bool {
202 matches!(self, Self::Yes | Self::No)
203 }
204}
205
206pub trait Asker: Send + Sync {
208 fn ask(&self, q: &Question) -> Answer;
209}
210
211pub trait EnvControl: Send + Sync {
215 fn describe(&self, ws: &Path, plan: &EnvPlan) -> byob_protocol::WorkspaceEnv;
217 fn apply(&self, ws: &Path, action: &byob_protocol::EnvAction) -> Result<String, String>;
219 fn support(&self) -> byob_protocol::EnvSupport;
221 fn check_containers(&self) -> Result<String, String>;
223 fn forget(&self, ws: &Path) -> Result<String, String>;
226}
227
228#[derive(Debug, Clone, Default, PartialEq)]
230pub struct ToolOutput {
231 pub text: String,
232 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 fn title(&self, args: &Value) -> String;
252
253 fn pending_title(&self, _args: &Value) -> String {
256 format!("Run {}", self.spec().name)
257 }
258
259 fn preview(&self, args: &Value, _workspace: &Path) -> Option<Preview> {
262 Some(preview::args(args))
263 }
264
265 fn access(&self, args: &Value) -> Access;
267
268 fn always_ask(&self, _args: &Value) -> Option<String> {
272 None
273 }
274
275 fn run(&self, args: &Value, ctx: &ToolContext<'_>) -> ToolOutput;
277}
278
279pub 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#[derive(Default, Clone)]
286pub struct Registry {
287 tools: BTreeMap<String, Arc<dyn Tool>>,
288}
289
290impl Registry {
291 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
313pub 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
328pub 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}