#![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;
#[derive(Debug, Clone, PartialEq)]
pub struct ToolSpec {
pub name: String,
pub description: String,
pub parameters: Value,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Access {
Read,
Write,
Network,
Execute,
}
pub struct ToolContext<'a> {
pub workspace: &'a Path,
cancel: &'a AtomicBool,
journal: Option<&'a Journal>,
chat: Option<&'a str>,
asker: Option<&'a dyn Asker>,
env: Option<&'a EnvPlan>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct EnvPlan {
pub container: Option<bool>,
pub project: Option<bool>,
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 }
}
#[must_use]
pub fn with_env(mut self, plan: &'a EnvPlan) -> Self {
self.env = Some(plan);
self
}
pub fn env(&self) -> EnvPlan {
self.env.cloned().unwrap_or_default()
}
#[must_use]
pub fn with_asker(mut self, asker: &'a dyn Asker) -> Self {
self.asker = Some(asker);
self
}
pub fn ask(&self, q: &Question) -> Answer {
if self.cancelled() {
return Answer::AutoNo;
}
self.asker.map_or(Answer::AutoNo, |a| a.ask(q))
}
#[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
}
#[must_use]
pub fn with_journal(mut self, journal: &'a Journal) -> Self {
self.journal = Some(journal);
self
}
pub fn journal(&self) -> Option<&'a Journal> {
self.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)
}
pub fn cancel_flag(&self) -> &AtomicBool {
self.cancel
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FileChange {
Created(PathBuf),
Modified(PathBuf),
Deleted(PathBuf),
Moved { from: PathBuf, to: PathBuf },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Question {
pub title: String,
pub text: String,
pub warning: Option<String>,
pub access: Access,
pub always_ask: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Answer {
Yes,
No,
AutoYes,
AutoNo,
}
impl Answer {
pub fn allowed(self) -> bool {
matches!(self, Self::Yes | Self::AutoYes)
}
pub fn by_user(self) -> bool {
matches!(self, Self::Yes | Self::No)
}
}
pub trait Asker: Send + Sync {
fn ask(&self, q: &Question) -> Answer;
}
pub trait EnvControl: Send + Sync {
fn describe(&self, ws: &Path, plan: &EnvPlan) -> byob_protocol::WorkspaceEnv;
fn apply(&self, ws: &Path, action: &byob_protocol::EnvAction) -> Result<String, String>;
fn support(&self) -> byob_protocol::EnvSupport;
fn check_containers(&self) -> Result<String, String>;
fn forget(&self, ws: &Path) -> Result<String, String>;
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ToolOutput {
pub text: String,
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;
fn title(&self, args: &Value) -> String;
fn pending_title(&self, _args: &Value) -> String {
format!("Run {}", self.spec().name)
}
fn preview(&self, args: &Value, _workspace: &Path) -> Option<Preview> {
Some(preview::args(args))
}
fn access(&self, args: &Value) -> Access;
fn always_ask(&self, _args: &Value) -> Option<String> {
None
}
fn run(&self, args: &Value, ctx: &ToolContext<'_>) -> ToolOutput;
}
pub fn parse_args<T: DeserializeOwned>(args: &Value) -> Result<T, String> {
serde_json::from_value(args.clone()).map_err(|e| format!("invalid arguments: {e}"))
}
#[derive(Default, Clone)]
pub struct Registry {
tools: BTreeMap<String, Arc<dyn Tool>>,
}
impl Registry {
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()
}
}
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));
}
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);
}
}