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 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#[derive(Debug, Clone, PartialEq)]
39pub struct ToolSpec {
40 pub name: String,
42 pub description: String,
43 pub parameters: Value,
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
49pub enum Access {
50 Read,
52 Write,
54 Network,
56 Execute,
58}
59
60pub struct ToolContext<'a> {
62 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
78pub struct EnvPlan {
79 pub container: Option<bool>,
82 pub project: Option<bool>,
85 pub image: Option<String>,
87 pub providers: Option<EnvProviders>,
90}
91
92#[derive(Debug, Clone, Default, PartialEq, Eq)]
94pub struct EnvProviders {
95 pub off: Vec<String>,
97 pub external: Vec<CommandEnv>,
99}
100
101#[derive(Debug, Clone, Default, PartialEq, Eq)]
105pub struct CommandEnv {
106 pub id: String,
108 pub label: String,
110 pub detect: Vec<String>,
111 pub inputs: Vec<String>,
113 pub program: PathBuf,
114 pub args: Vec<String>,
115 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 #[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 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 #[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 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 #[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 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 #[must_use]
172 pub fn with_env(mut self, plan: &'a EnvPlan) -> Self {
173 self.env = Some(plan);
174 self
175 }
176
177 pub fn env(&self) -> EnvPlan {
179 self.env.cloned().unwrap_or_default()
180 }
181
182 #[must_use]
184 pub fn with_asker(mut self, asker: &'a dyn Asker) -> Self {
185 self.asker = Some(asker);
186 self
187 }
188
189 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 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 #[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 #[must_use]
220 pub fn with_journal(mut self, journal: &'a Journal) -> Self {
221 self.journal = Some(journal);
222 self
223 }
224
225 pub fn journal(&self) -> Option<&'a Journal> {
229 self.journal
230 }
231
232 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 pub fn cancel_flag(&self) -> &AtomicBool {
245 self.cancel
246 }
247}
248
249#[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#[derive(Debug, Clone, PartialEq, Eq)]
262pub struct Question {
263 pub title: String,
265 pub text: String,
267 pub warning: Option<String>,
269 pub access: Access,
272 pub always_ask: bool,
274}
275
276#[derive(Debug, Clone, Copy, PartialEq, Eq)]
278pub enum Answer {
279 Yes,
281 No,
283 AutoYes,
285 AutoNo,
287}
288
289impl Answer {
290 pub fn allowed(self) -> bool {
291 matches!(self, Self::Yes | Self::AutoYes)
292 }
293
294 pub fn by_user(self) -> bool {
296 matches!(self, Self::Yes | Self::No)
297 }
298}
299
300pub trait Asker: Send + Sync {
302 fn ask(&self, q: &Question) -> Answer;
303
304 fn ask_user(&self, q: &UserQuestion) -> UserReply {
307 let _ = q;
308 UserReply::NoOne
309 }
310}
311
312#[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#[derive(Debug, Clone, PartialEq, Eq)]
323pub enum UserReply {
324 Answered(String),
326 Skipped,
328 NoOne,
330}
331
332pub trait EnvControl: Send + Sync {
336 fn describe(&self, ws: &Path, plan: &EnvPlan) -> byob_protocol::WorkspaceEnv;
338 fn apply(&self, ws: &Path, action: &byob_protocol::EnvAction) -> Result<String, String>;
340 fn support(&self) -> byob_protocol::EnvSupport;
342 fn check_containers(&self) -> Result<String, String>;
344 fn forget(&self, ws: &Path) -> Result<String, String>;
347}
348
349#[derive(Debug, Clone, Default, PartialEq)]
351pub struct ToolOutput {
352 pub text: String,
353 pub failed: bool,
355 pub changes: Vec<FileChange>,
356 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 fn title(&self, args: &Value) -> String;
376
377 fn pending_title(&self, _args: &Value) -> String {
380 format!("Run {}", self.spec().name)
381 }
382
383 fn preview(&self, args: &Value, _workspace: &Path) -> Option<Preview> {
386 Some(preview::args(args))
387 }
388
389 fn access(&self, args: &Value) -> Access;
391
392 fn always_ask(&self, _args: &Value) -> Option<String> {
396 None
397 }
398
399 fn run(&self, args: &Value, ctx: &ToolContext<'_>) -> ToolOutput;
401
402 fn concurrent(&self) -> bool {
406 false
407 }
408}
409
410pub 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#[derive(Default, Clone)]
417pub struct Registry {
418 tools: BTreeMap<String, Arc<dyn Tool>>,
419}
420
421impl Registry {
422 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 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
449pub fn register_builtins(reg: &mut Registry) {
453 register_builtins_with(reg, &ArtifactTypes::builtin());
454}
455
456pub 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
475pub 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}