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.
181pub fn validate_args(schema: &Value, args: &Value) -> Result<(), String> {
182    let obj = match args {
183        Value::Object(o) => o,
184        Value::Null => return validate_args(schema, &json!({})),
185        _ => return Err("arguments must be a JSON object".into()),
186    };
187    if let Some(req) = schema["required"].as_array() {
188        for r in req.iter().filter_map(Value::as_str) {
189            if !obj.contains_key(r) {
190                return Err(format!("missing required argument `{r}`"));
191            }
192        }
193    }
194    if let Some(props) = schema["properties"].as_object() {
195        for (k, v) in obj {
196            let Some(p) = props.get(k) else { continue };
197            if let Some(t) = p["type"].as_str() {
198                let ok = match t {
199                    "string" => v.is_string(),
200                    "number" => v.is_number(),
201                    "integer" => v.is_i64() || v.is_u64(),
202                    "boolean" => v.is_boolean(),
203                    "object" => v.is_object(),
204                    "array" => v.is_array(),
205                    _ => true,
206                };
207                if !ok {
208                    return Err(format!("argument `{k}` must be {t}"));
209                }
210            }
211            if let Some(e) = p["enum"].as_array() {
212                if !e.contains(v) {
213                    return Err(format!("argument `{k}` is not one of the allowed values"));
214                }
215            }
216        }
217    }
218    Ok(())
219}
220
221#[cfg(test)]
222mod strict_tests {
223    use super::*;
224
225    fn schema() -> Value {
226        json!({"type": "object", "required": ["a"], "properties": {"a": {"type": "string"}}})
227    }
228
229    #[test]
230    fn strict_rejects_unknown_field() {
231        assert!(validate_args_strict(&schema(), &json!({"a": "x"})).is_ok());
232        let err = validate_args_strict(&schema(), &json!({"a": "x", "b": 1})).unwrap_err();
233        assert!(err.contains("`b`"));
234        // The lenient check still accepts it.
235        assert!(validate_args(&schema(), &json!({"a": "x", "b": 1})).is_ok());
236    }
237
238    #[test]
239    fn strict_still_enforces_base_rules() {
240        assert!(validate_args_strict(&schema(), &json!({})).is_err());
241        assert!(validate_args_strict(&schema(), &json!({"a": 1})).is_err());
242        assert!(validate_args_strict(&schema(), &json!("x")).is_err());
243    }
244
245    #[test]
246    fn strict_honors_explicit_additional_properties() {
247        let mut open = schema();
248        open["additionalProperties"] = json!(true);
249        assert!(validate_args_strict(&open, &json!({"a": "x", "b": 1})).is_ok());
250        let mut closed = schema();
251        closed["additionalProperties"] = json!(false);
252        assert!(validate_args_strict(&closed, &json!({"a": "x", "b": 1})).is_err());
253    }
254
255    #[test]
256    fn strict_with_no_properties_rejects_any_field() {
257        assert!(validate_args_strict(&json!({"type": "object"}), &json!({"z": 1})).is_err());
258        assert!(validate_args_strict(&json!({"type": "object"}), &Value::Null).is_ok());
259    }
260}