Skip to main content

agpu/ontology/
action.rs

1//! Agent actions and parameter types for widget interaction.
2
3use serde::{Deserialize, Serialize};
4
5/// An action that an agent can invoke on a widget.
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct AgentAction {
8    /// Unique action name (e.g., "click", "set_text", "scroll_to").
9    pub name: String,
10    /// Human-readable description of what this action does.
11    pub description: String,
12    /// Parameters this action accepts.
13    pub params: Vec<ActionParam>,
14    /// Description of the return value.
15    pub returns: Option<String>,
16    /// Whether this action mutates the widget state.
17    pub mutates: bool,
18    /// Whether this action is idempotent (safe to retry).
19    pub idempotent: bool,
20    /// Keyboard shortcut, if any (e.g., "Ctrl+S").
21    pub shortcut: Option<String>,
22}
23
24/// A parameter for an agent action.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct ActionParam {
27    /// Parameter name.
28    pub name: String,
29    /// Human-readable description.
30    pub description: String,
31    /// The type of this parameter.
32    pub param_type: ActionParamType,
33    /// Whether this parameter is required.
34    pub required: bool,
35    /// Default value as JSON, if optional.
36    pub default_value: Option<serde_json::Value>,
37}
38
39/// Type of an action parameter.
40#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
41pub enum ActionParamType {
42    String,
43    Integer,
44    Float,
45    Boolean,
46    Index,
47    Position { x: bool, y: bool },
48    Size { width: bool, height: bool },
49    Color,
50    Enum(Vec<String>),
51    Any,
52}
53
54impl AgentAction {
55    /// Create a simple action with no parameters.
56    #[must_use]
57    pub fn simple(name: impl Into<String>, description: impl Into<String>, mutates: bool) -> Self {
58        Self {
59            name: name.into(),
60            description: description.into(),
61            params: vec![],
62            returns: None,
63            mutates,
64            idempotent: !mutates,
65            shortcut: None,
66        }
67    }
68
69    /// Create an action with the given parameters.
70    #[must_use]
71    pub fn with_params(
72        name: impl Into<String>,
73        description: impl Into<String>,
74        params: Vec<ActionParam>,
75        mutates: bool,
76    ) -> Self {
77        Self {
78            name: name.into(),
79            description: description.into(),
80            params,
81            returns: None,
82            mutates,
83            idempotent: !mutates,
84            shortcut: None,
85        }
86    }
87
88    /// Validate a JSON params object against this action's declared parameters.
89    pub fn validate_params(&self, params: &serde_json::Value) -> Result<(), String> {
90        for param in &self.params {
91            let val = params.get(&param.name);
92            match val {
93                None | Some(serde_json::Value::Null) => {
94                    if param.required {
95                        return Err(format!("Missing required parameter '{}'", param.name));
96                    }
97                }
98                Some(v) => {
99                    param
100                        .param_type
101                        .check(v)
102                        .map_err(|e| format!("Parameter '{}': {}", param.name, e))?;
103                }
104            }
105        }
106        Ok(())
107    }
108}
109
110impl ActionParamType {
111    fn check(&self, value: &serde_json::Value) -> Result<(), String> {
112        match self {
113            ActionParamType::String => {
114                if !value.is_string() {
115                    return Err(format!("expected string, got {}", json_type_name(value)));
116                }
117            }
118            ActionParamType::Integer => {
119                if !value.is_i64() && !value.is_u64() {
120                    return Err(format!("expected integer, got {}", json_type_name(value)));
121                }
122            }
123            ActionParamType::Float => {
124                if !value.is_number() {
125                    return Err(format!("expected number, got {}", json_type_name(value)));
126                }
127            }
128            ActionParamType::Boolean => {
129                if !value.is_boolean() {
130                    return Err(format!("expected boolean, got {}", json_type_name(value)));
131                }
132            }
133            ActionParamType::Index => {
134                if !value.is_u64() {
135                    return Err(format!(
136                        "expected index (uint), got {}",
137                        json_type_name(value)
138                    ));
139                }
140            }
141            ActionParamType::Position { .. } | ActionParamType::Size { .. } => {
142                if !value.is_object() {
143                    return Err(format!("expected object, got {}", json_type_name(value)));
144                }
145            }
146            ActionParamType::Color => {
147                if !value.is_string() && !value.is_object() {
148                    return Err(format!(
149                        "expected color string or object, got {}",
150                        json_type_name(value)
151                    ));
152                }
153            }
154            ActionParamType::Enum(variants) => {
155                if let Some(s) = value.as_str() {
156                    if !variants.iter().any(|v| v == s) {
157                        return Err(format!("expected one of {:?}, got {:?}", variants, s));
158                    }
159                } else {
160                    return Err(format!(
161                        "expected string enum, got {}",
162                        json_type_name(value)
163                    ));
164                }
165            }
166            ActionParamType::Any => {}
167        }
168        Ok(())
169    }
170}
171
172impl ActionParam {
173    /// Create a required parameter.
174    #[must_use]
175    pub fn required(
176        name: impl Into<String>,
177        description: impl Into<String>,
178        param_type: ActionParamType,
179    ) -> Self {
180        Self {
181            name: name.into(),
182            description: description.into(),
183            param_type,
184            required: true,
185            default_value: None,
186        }
187    }
188
189    /// Create an optional parameter with a default.
190    #[must_use]
191    pub fn optional(
192        name: impl Into<String>,
193        description: impl Into<String>,
194        param_type: ActionParamType,
195        default: serde_json::Value,
196    ) -> Self {
197        Self {
198            name: name.into(),
199            description: description.into(),
200            param_type,
201            required: false,
202            default_value: Some(default),
203        }
204    }
205}
206
207fn json_type_name(v: &serde_json::Value) -> &'static str {
208    match v {
209        serde_json::Value::Null => "null",
210        serde_json::Value::Bool(_) => "boolean",
211        serde_json::Value::Number(_) => "number",
212        serde_json::Value::String(_) => "string",
213        serde_json::Value::Array(_) => "array",
214        serde_json::Value::Object(_) => "object",
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    #[test]
223    fn simple_action_defaults() {
224        let a = AgentAction::simple("click", "Click the button", true);
225        assert_eq!(a.name, "click");
226        assert!(a.mutates);
227        assert!(!a.idempotent);
228        assert!(a.params.is_empty());
229        assert!(a.shortcut.is_none());
230    }
231
232    #[test]
233    fn simple_action_readonly_is_idempotent() {
234        let a = AgentAction::simple("read", "Read value", false);
235        assert!(!a.mutates);
236        assert!(a.idempotent);
237    }
238
239    #[test]
240    fn validate_params_missing_required() {
241        let action = AgentAction::with_params(
242            "set_text",
243            "Set text content",
244            vec![ActionParam::required(
245                "text",
246                "The text",
247                ActionParamType::String,
248            )],
249            true,
250        );
251        let result = action.validate_params(&serde_json::json!({}));
252        assert!(result.is_err());
253        assert!(result.unwrap_err().contains("Missing required"));
254    }
255
256    #[test]
257    fn validate_params_wrong_type() {
258        let action = AgentAction::with_params(
259            "set_value",
260            "Set value",
261            vec![ActionParam::required(
262                "count",
263                "Count",
264                ActionParamType::Integer,
265            )],
266            true,
267        );
268        let result = action.validate_params(&serde_json::json!({"count": "not_a_number"}));
269        assert!(result.is_err());
270    }
271
272    #[test]
273    fn validate_params_correct() {
274        let action = AgentAction::with_params(
275            "set_value",
276            "Set value",
277            vec![ActionParam::required(
278                "count",
279                "Count",
280                ActionParamType::Integer,
281            )],
282            true,
283        );
284        assert!(
285            action
286                .validate_params(&serde_json::json!({"count": 42}))
287                .is_ok()
288        );
289    }
290
291    #[test]
292    fn validate_params_optional_missing_ok() {
293        let action = AgentAction::with_params(
294            "set",
295            "Set",
296            vec![ActionParam::optional(
297                "label",
298                "Label",
299                ActionParamType::String,
300                serde_json::json!("default"),
301            )],
302            true,
303        );
304        assert!(action.validate_params(&serde_json::json!({})).is_ok());
305    }
306
307    #[test]
308    fn validate_boolean_type() {
309        let action = AgentAction::with_params(
310            "toggle",
311            "Toggle",
312            vec![ActionParam::required(
313                "state",
314                "State",
315                ActionParamType::Boolean,
316            )],
317            true,
318        );
319        assert!(
320            action
321                .validate_params(&serde_json::json!({"state": true}))
322                .is_ok()
323        );
324        assert!(
325            action
326                .validate_params(&serde_json::json!({"state": "yes"}))
327                .is_err()
328        );
329    }
330
331    #[test]
332    fn validate_enum_type() {
333        let action = AgentAction::with_params(
334            "set_mode",
335            "Set mode",
336            vec![ActionParam::required(
337                "mode",
338                "Mode",
339                ActionParamType::Enum(vec!["light".into(), "dark".into()]),
340            )],
341            true,
342        );
343        assert!(
344            action
345                .validate_params(&serde_json::json!({"mode": "dark"}))
346                .is_ok()
347        );
348        assert!(
349            action
350                .validate_params(&serde_json::json!({"mode": "blue"}))
351                .is_err()
352        );
353    }
354
355    #[test]
356    fn validate_any_type_accepts_anything() {
357        let action = AgentAction::with_params(
358            "exec",
359            "Execute",
360            vec![ActionParam::required("data", "Data", ActionParamType::Any)],
361            true,
362        );
363        assert!(
364            action
365                .validate_params(&serde_json::json!({"data": [1,2,3]}))
366                .is_ok()
367        );
368        assert!(
369            action
370                .validate_params(&serde_json::json!({"data": "text"}))
371                .is_ok()
372        );
373        assert!(
374            action
375                .validate_params(&serde_json::json!({"data": 42}))
376                .is_ok()
377        );
378    }
379
380    #[test]
381    fn action_param_required_constructor() {
382        let p = ActionParam::required("name", "The name", ActionParamType::String);
383        assert!(p.required);
384        assert!(p.default_value.is_none());
385    }
386
387    #[test]
388    fn action_param_optional_constructor() {
389        let p = ActionParam::optional(
390            "name",
391            "The name",
392            ActionParamType::String,
393            serde_json::json!(""),
394        );
395        assert!(!p.required);
396        assert!(p.default_value.is_some());
397    }
398}