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/// Minimal schema check: `required` present, declared primitive types and enums match.
150pub fn validate_args(schema: &Value, args: &Value) -> Result<(), String> {
151    let obj = match args {
152        Value::Object(o) => o,
153        Value::Null => return validate_args(schema, &json!({})),
154        _ => return Err("arguments must be a JSON object".into()),
155    };
156    if let Some(req) = schema["required"].as_array() {
157        for r in req.iter().filter_map(Value::as_str) {
158            if !obj.contains_key(r) {
159                return Err(format!("missing required argument `{r}`"));
160            }
161        }
162    }
163    if let Some(props) = schema["properties"].as_object() {
164        for (k, v) in obj {
165            let Some(p) = props.get(k) else { continue };
166            if let Some(t) = p["type"].as_str() {
167                let ok = match t {
168                    "string" => v.is_string(),
169                    "number" => v.is_number(),
170                    "integer" => v.is_i64() || v.is_u64(),
171                    "boolean" => v.is_boolean(),
172                    "object" => v.is_object(),
173                    "array" => v.is_array(),
174                    _ => true,
175                };
176                if !ok {
177                    return Err(format!("argument `{k}` must be {t}"));
178                }
179            }
180            if let Some(e) = p["enum"].as_array() {
181                if !e.contains(v) {
182                    return Err(format!("argument `{k}` is not one of the allowed values"));
183                }
184            }
185        }
186    }
187    Ok(())
188}