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}
62
63impl<'a> ToolContext<'a> {
64 pub fn new(workspace: &'a Path, cancel: &'a AtomicBool) -> Self {
65 Self { workspace, cancel, journal: None, chat: None }
66 }
67
68 #[must_use]
70 pub fn with_chat(mut self, chat: &'a str) -> Self {
71 self.chat = Some(chat);
72 self
73 }
74
75 pub fn chat(&self) -> Option<&'a str> {
76 self.chat
77 }
78
79 #[must_use]
81 pub fn with_journal(mut self, journal: &'a Journal) -> Self {
82 self.journal = Some(journal);
83 self
84 }
85
86 pub fn journal(&self) -> Option<&'a Journal> {
90 self.journal
91 }
92
93 pub fn record(&self, op: UndoOp) {
95 if let Some(j) = self.journal {
96 j.record(op);
97 }
98 }
99
100 pub fn cancelled(&self) -> bool {
101 self.cancel.load(Ordering::Relaxed)
102 }
103
104 pub fn cancel_flag(&self) -> &AtomicBool {
106 self.cancel
107 }
108}
109
110#[derive(Debug, Clone, PartialEq, Eq)]
112pub enum FileChange {
113 Created(PathBuf),
114 Modified(PathBuf),
115 Deleted(PathBuf),
116 Moved { from: PathBuf, to: PathBuf },
117}
118
119#[derive(Debug, Clone, Default, PartialEq)]
121pub struct ToolOutput {
122 pub text: String,
123 pub failed: bool,
125 pub changes: Vec<FileChange>,
126}
127
128impl ToolOutput {
129 pub fn ok(text: impl Into<String>) -> Self {
130 Self { text: text.into(), ..Default::default() }
131 }
132
133 pub fn err(text: impl Into<String>) -> Self {
134 Self { text: text.into(), failed: true, ..Default::default() }
135 }
136}
137
138pub trait Tool: Send + Sync {
139 fn spec(&self) -> ToolSpec;
140
141 fn title(&self, args: &Value) -> String;
143
144 fn pending_title(&self, _args: &Value) -> String {
147 format!("Run {}", self.spec().name)
148 }
149
150 fn preview(&self, args: &Value, _workspace: &Path) -> Option<Preview> {
153 Some(preview::args(args))
154 }
155
156 fn access(&self, args: &Value) -> Access;
158
159 fn run(&self, args: &Value, ctx: &ToolContext<'_>) -> ToolOutput;
161}
162
163pub fn parse_args<T: DeserializeOwned>(args: &Value) -> Result<T, String> {
165 serde_json::from_value(args.clone()).map_err(|e| format!("invalid arguments: {e}"))
166}
167
168#[derive(Default, Clone)]
170pub struct Registry {
171 tools: BTreeMap<String, Arc<dyn Tool>>,
172}
173
174impl Registry {
175 pub fn register(&mut self, tool: Arc<dyn Tool>) {
177 self.tools.insert(tool.spec().name, tool);
178 }
179
180 pub fn get(&self, name: &str) -> Option<&Arc<dyn Tool>> {
181 self.tools.get(name)
182 }
183
184 pub fn specs(&self) -> Vec<ToolSpec> {
185 self.tools.values().map(|t| t.spec()).collect()
186 }
187
188 pub fn len(&self) -> usize {
189 self.tools.len()
190 }
191
192 pub fn is_empty(&self) -> bool {
193 self.tools.is_empty()
194 }
195}
196
197pub fn register_builtins(reg: &mut Registry) {
200 reg.register(Arc::new(ListFiles));
201 reg.register(Arc::new(ReadFile));
202 reg.register(Arc::new(WriteFile));
203 reg.register(Arc::new(MoveFile));
204 reg.register(Arc::new(MoveFiles));
205 reg.register(Arc::new(DeleteFile::default()));
206 reg.register(Arc::new(MakeChart));
207 reg.register(Arc::new(CreateArtifact));
208 reg.register(Arc::new(UpdateArtifact));
209 reg.register(Arc::new(ReadArtifact));
210}
211
212pub fn builtin() -> Registry {
214 let mut reg = Registry::default();
215 register_builtins(&mut reg);
216 reg
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222
223 struct Echo;
224
225 impl Tool for Echo {
226 fn spec(&self) -> ToolSpec {
227 ToolSpec {
228 name: "echo".into(),
229 description: "Echo text".into(),
230 parameters: serde_json::json!({"type":"object","properties":{"text":{"type":"string"}},"required":["text"]}),
231 }
232 }
233 fn title(&self, _args: &Value) -> String {
234 "Echo".into()
235 }
236 fn access(&self, _args: &Value) -> Access {
237 Access::Read
238 }
239 fn run(&self, args: &Value, _ctx: &ToolContext<'_>) -> ToolOutput {
240 #[derive(serde::Deserialize)]
241 struct Args {
242 text: String,
243 }
244 match parse_args::<Args>(args) {
245 Ok(a) => ToolOutput::ok(a.text),
246 Err(e) => ToolOutput::err(e),
247 }
248 }
249 }
250
251 #[test]
252 fn registry_dispatch_and_bad_args() {
253 let mut reg = Registry::default();
254 reg.register(Arc::new(Echo));
255 let cancel = AtomicBool::new(false);
256 let ctx = ToolContext::new(Path::new("/tmp"), &cancel);
257 let tool = reg.get("echo").unwrap();
258 assert_eq!(tool.run(&serde_json::json!({"text":"hi"}), &ctx), ToolOutput::ok("hi"));
259 assert!(tool.run(&serde_json::json!({"nope":1}), &ctx).failed);
260 assert_eq!(reg.specs().len(), 1);
261 }
262
263 #[test]
264 fn builtins_are_valid() {
265 let reg = builtin();
266 assert_eq!(reg.len(), 10);
267 for spec in reg.specs() {
268 assert!(spec.name.len() <= 64 && spec.name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'));
269 assert_eq!(spec.parameters["type"], "object");
270 let tool = reg.get(&spec.name).unwrap();
271 let expected = if matches!(spec.name.as_str(), "list_files" | "read_file" | "make_chart" | "create_artifact" | "update_artifact" | "read_artifact")
272 {
273 Access::Read
274 } else {
275 Access::Write
276 };
277 assert_eq!(tool.access(&serde_json::json!({})), expected, "{}", spec.name);
278 }
279 }
280
281 #[test]
282 fn default_pending_title_and_preview() {
283 assert_eq!(Echo.pending_title(&serde_json::json!({})), "Run echo");
284 let p = Echo.preview(&serde_json::json!({"text": "hi"}), Path::new("/tmp"));
285 assert_eq!(p, Some(Preview::Args { fields: vec![("text".into(), "hi".into())], code: None, more: 0 }));
286 }
287
288 #[test]
289 fn access_is_ordered_by_danger() {
290 assert!(Access::Read < Access::Write && Access::Write < Access::Network && Access::Network < Access::Execute);
291 }
292}