Skip to main content

rightkit_agent_tools/
spec.rs

1//! The typed tool contract: a [`ToolSpec`] (name, description, JSON schema,
2//! effect class) and a [`Tool`] that runs it.
3
4use serde::Serialize;
5use serde_json::{json, Value};
6use std::fmt;
7use std::sync::atomic::{AtomicBool, Ordering};
8use std::sync::Arc;
9
10/// What a tool does to the world, for admission and journaling. Content-free.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
12#[serde(rename_all = "snake_case")]
13pub enum Effect {
14    /// Reads state; no side effect on the target.
15    Observe,
16    /// Input or a command that changes a target app.
17    Act,
18    /// Starts or stops a process / session.
19    Launch,
20    /// Captures pixels (screenshot).
21    Capture,
22    /// Reaches the network (browser navigation).
23    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    /// `<family>.<verb>`, e.g. `genright.click`, `desktop.snapshot`, `browser.open`.
41    pub name: String,
42    pub description: String,
43    /// JSON Schema (subset: object, properties, required, type, enum).
44    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/// Stable machine-readable failure.
65#[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/// Who is calling (the allowlist key).
107#[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/// Cooperative cancellation a tool may poll.
119#[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    /// The inner verb for tools that carry one (e.g. the app command name of
141    /// `<app>.command`). The exposure rules and allowlist see it as
142    /// `<tool>:<subject>`.
143    fn subject(&self, _args: &Value) -> Option<String> {
144        None
145    }
146    fn call(&self, ctx: &CallCtx, args: Value) -> Result<Value, ToolError>;
147}
148
149/// Strict schema check: everything [`validate_args`] checks, plus unknown-field rejection.
150///
151/// An argument is unknown when the schema does not list it under `properties`. The check is
152/// skipped only when the schema explicitly sets `additionalProperties` to `true` or to a schema
153/// object. A missing `additionalProperties` is treated as `false` (strict), so a typo such as
154/// `{"nmae": ..}` fails closed instead of being silently ignored.
155pub 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
180/// Minimal schema check: `required` present, declared primitive types and enums match.
181///
182/// Undeclared arguments are accepted, except when the schema explicitly sets
183/// `additionalProperties: false`: then an argument the schema does not list under
184/// `properties` is rejected. An omitted, `true` or schema-object
185/// `additionalProperties` keeps the open acceptance, and the values of extras allowed
186/// by a schema object are not themselves validated (full recursive validation is a
187/// separate extension). `Null` arguments read as `{}`.
188pub 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        // The lenient check still accepts it.
254        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        // Omitted, true and schema-object keep the open acceptance; the schema
278        // object's own rules are not applied to the extra's value (`b` is not a string).
279        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        // Explicit false closes the object, in the lenient check too.
287        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        // Null still reads as `{}` (and so has no extras to reject).
293        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}