1use serde::Serialize;
5use serde_json::{json, Value};
6use std::fmt;
7use std::sync::atomic::{AtomicBool, Ordering};
8use std::sync::Arc;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
12#[serde(rename_all = "snake_case")]
13pub enum Effect {
14 Observe,
16 Act,
18 Launch,
20 Capture,
22 Network,
24}
25
26impl Effect {
27 pub fn label(self) -> &'static str {
28 match self {
29 Effect::Observe => "observe",
30 Effect::Act => "act",
31 Effect::Launch => "launch",
32 Effect::Capture => "capture",
33 Effect::Network => "network",
34 }
35 }
36}
37
38#[derive(Debug, Clone, Serialize)]
39pub struct ToolSpec {
40 pub name: String,
42 pub description: String,
43 pub input_schema: Value,
45 pub effect: Effect,
46}
47
48impl ToolSpec {
49 pub fn new(
50 name: impl Into<String>,
51 description: impl Into<String>,
52 effect: Effect,
53 input_schema: Value,
54 ) -> Self {
55 Self {
56 name: name.into(),
57 description: description.into(),
58 input_schema,
59 effect,
60 }
61 }
62}
63
64#[derive(Debug, Clone, PartialEq, Serialize)]
66pub struct ToolError {
67 pub code: String,
68 pub message: String,
69 pub retryable: bool,
70}
71
72impl ToolError {
73 pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
74 Self {
75 code: code.into(),
76 message: message.into(),
77 retryable: false,
78 }
79 }
80 pub fn retryable(code: impl Into<String>, message: impl Into<String>) -> Self {
81 Self {
82 code: code.into(),
83 message: message.into(),
84 retryable: true,
85 }
86 }
87 pub fn to_value(&self) -> Value {
88 json!({"code": self.code, "message": self.message, "retryable": self.retryable})
89 }
90}
91
92impl fmt::Display for ToolError {
93 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94 write!(f, "{}: {}", self.code, self.message)
95 }
96}
97impl std::error::Error for ToolError {}
98
99pub const ERR_UNKNOWN_TOOL: &str = "unknown_tool";
100pub const ERR_NOT_EXPOSED: &str = "not_exposed";
101pub const ERR_NOT_ALLOWED: &str = "tool_not_allowed";
102pub const ERR_DENIED: &str = "denied_by_admission";
103pub const ERR_INVALID_ARGS: &str = "invalid_arguments";
104pub const ERR_CANCELLED: &str = "cancelled";
105
106#[derive(Debug, Clone)]
108pub struct Caller {
109 pub app: String,
110}
111
112impl Caller {
113 pub fn new(app: impl Into<String>) -> Self {
114 Self { app: app.into() }
115 }
116}
117
118#[derive(Clone, Default)]
120pub struct CancelToken(Arc<AtomicBool>);
121impl CancelToken {
122 pub fn new() -> Self {
123 Self::default()
124 }
125 pub fn cancel(&self) {
126 self.0.store(true, Ordering::SeqCst);
127 }
128 pub fn is_cancelled(&self) -> bool {
129 self.0.load(Ordering::SeqCst)
130 }
131}
132
133pub struct CallCtx<'a> {
134 pub caller: &'a Caller,
135 pub cancel: &'a CancelToken,
136}
137
138pub trait Tool: Send + Sync {
139 fn spec(&self) -> &ToolSpec;
140 fn subject(&self, _args: &Value) -> Option<String> {
144 None
145 }
146 fn call(&self, ctx: &CallCtx, args: Value) -> Result<Value, ToolError>;
147}
148
149pub fn validate_args_strict(schema: &Value, args: &Value) -> Result<(), String> {
156 validate_args(schema, args)?;
157 let open = match &schema["additionalProperties"] {
158 Value::Bool(b) => *b,
159 Value::Object(_) => true,
160 _ => false,
161 };
162 if open {
163 return Ok(());
164 }
165 if let Value::Object(obj) = args {
166 let props = schema["properties"].as_object();
167 let mut unknown: Vec<&str> = obj
168 .keys()
169 .map(String::as_str)
170 .filter(|k| !props.is_some_and(|p| p.contains_key(*k)))
171 .collect();
172 unknown.sort_unstable();
173 if let Some(first) = unknown.first() {
174 return Err(format!("unknown argument `{first}`"));
175 }
176 }
177 Ok(())
178}
179
180pub fn validate_args(schema: &Value, args: &Value) -> Result<(), String> {
189 let obj = match args {
190 Value::Object(o) => o,
191 Value::Null => return validate_args(schema, &json!({})),
192 _ => return Err("arguments must be a JSON object".into()),
193 };
194 if let Some(req) = schema["required"].as_array() {
195 for r in req.iter().filter_map(Value::as_str) {
196 if !obj.contains_key(r) {
197 return Err(format!("missing required argument `{r}`"));
198 }
199 }
200 }
201 if let Some(props) = schema["properties"].as_object() {
202 for (k, v) in obj {
203 let Some(p) = props.get(k) else { continue };
204 if let Some(t) = p["type"].as_str() {
205 let ok = match t {
206 "string" => v.is_string(),
207 "number" => v.is_number(),
208 "integer" => v.is_i64() || v.is_u64(),
209 "boolean" => v.is_boolean(),
210 "object" => v.is_object(),
211 "array" => v.is_array(),
212 _ => true,
213 };
214 if !ok {
215 return Err(format!("argument `{k}` must be {t}"));
216 }
217 }
218 if let Some(e) = p["enum"].as_array() {
219 if !e.contains(v) {
220 return Err(format!("argument `{k}` is not one of the allowed values"));
221 }
222 }
223 }
224 }
225 if let Value::Bool(false) = &schema["additionalProperties"] {
226 let props = schema["properties"].as_object();
227 let mut unknown: Vec<&str> = obj
228 .keys()
229 .map(String::as_str)
230 .filter(|k| !props.is_some_and(|p| p.contains_key(*k)))
231 .collect();
232 unknown.sort_unstable();
233 if let Some(first) = unknown.first() {
234 return Err(format!("unknown argument `{first}`"));
235 }
236 }
237 Ok(())
238}
239
240#[cfg(test)]
241mod strict_tests {
242 use super::*;
243
244 fn schema() -> Value {
245 json!({"type": "object", "required": ["a"], "properties": {"a": {"type": "string"}}})
246 }
247
248 #[test]
249 fn strict_rejects_unknown_field() {
250 assert!(validate_args_strict(&schema(), &json!({"a": "x"})).is_ok());
251 let err = validate_args_strict(&schema(), &json!({"a": "x", "b": 1})).unwrap_err();
252 assert!(err.contains("`b`"));
253 assert!(validate_args(&schema(), &json!({"a": "x", "b": 1})).is_ok());
255 }
256
257 #[test]
258 fn strict_still_enforces_base_rules() {
259 assert!(validate_args_strict(&schema(), &json!({})).is_err());
260 assert!(validate_args_strict(&schema(), &json!({"a": 1})).is_err());
261 assert!(validate_args_strict(&schema(), &json!("x")).is_err());
262 }
263
264 #[test]
265 fn strict_honors_explicit_additional_properties() {
266 let mut open = schema();
267 open["additionalProperties"] = json!(true);
268 assert!(validate_args_strict(&open, &json!({"a": "x", "b": 1})).is_ok());
269 let mut closed = schema();
270 closed["additionalProperties"] = json!(false);
271 assert!(validate_args_strict(&closed, &json!({"a": "x", "b": 1})).is_err());
272 }
273
274 #[test]
275 fn lenient_rejects_extras_only_on_explicit_false() {
276 let args = json!({"a": "x", "b": 1});
277 assert!(validate_args(&schema(), &args).is_ok());
280 let mut open = schema();
281 open["additionalProperties"] = json!(true);
282 assert!(validate_args(&open, &args).is_ok());
283 let mut typed = schema();
284 typed["additionalProperties"] = json!({"type": "string"});
285 assert!(validate_args(&typed, &args).is_ok());
286 let mut closed = schema();
288 closed["additionalProperties"] = json!(false);
289 let err = validate_args(&closed, &args).unwrap_err();
290 assert!(err.contains("`b`"), "{err}");
291 assert!(validate_args(&closed, &json!({"a": "x"})).is_ok());
292 assert!(validate_args(
294 &json!({"type": "object", "additionalProperties": false}),
295 &Value::Null
296 )
297 .is_ok());
298 }
299
300 #[test]
301 fn strict_with_no_properties_rejects_any_field() {
302 assert!(validate_args_strict(&json!({"type": "object"}), &json!({"z": 1})).is_err());
303 assert!(validate_args_strict(&json!({"type": "object"}), &Value::Null).is_ok());
304 }
305}