Skip to main content

agent_scroll/
schema.rs

1use once_cell::sync::Lazy;
2use regex::Regex;
3use serde_json::Value;
4
5static HASH_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^sha256:[0-9a-f]{64}$").unwrap());
6const ROLES: &[&str] = &["user", "assistant", "tool", "system"];
7const TOOL_STATUS: &[&str] = &["ok", "error"];
8
9#[derive(Debug, Clone)]
10pub struct VerifyFailure {
11    pub turn: usize,
12    pub reason: &'static str,
13    pub detail: Option<String>,
14}
15
16#[derive(Debug, Clone)]
17pub struct VerifyResult {
18    pub ok: bool,
19    pub failures: Vec<VerifyFailure>,
20}
21
22fn check_hash(v: Option<&Value>) -> bool {
23    v.and_then(|x| x.as_str())
24        .map(|s| HASH_RE.is_match(s))
25        .unwrap_or(false)
26}
27
28pub fn validate_turn(value: &Value) -> Result<(), String> {
29    let obj = value
30        .as_object()
31        .ok_or_else(|| "not an object".to_string())?;
32    if obj.get("version").and_then(|v| v.as_str()) != Some("scroll/0.1") {
33        return Err("version must be \"scroll/0.1\"".into());
34    }
35    match obj.get("turn").and_then(|v| v.as_u64()) {
36        Some(_) => {}
37        None => return Err("turn must be non-negative integer".into()),
38    }
39    let role = obj.get("role").and_then(|v| v.as_str()).unwrap_or("");
40    if !ROLES.contains(&role) {
41        return Err(format!("role must be one of {ROLES:?}"));
42    }
43    let model = obj
44        .get("model")
45        .and_then(|v| v.as_object())
46        .ok_or("model required")?;
47    if model
48        .get("vendor")
49        .and_then(|v| v.as_str())
50        .filter(|s| !s.is_empty())
51        .is_none()
52        || model
53            .get("id")
54            .and_then(|v| v.as_str())
55            .filter(|s| !s.is_empty())
56            .is_none()
57    {
58        return Err("model.vendor and model.id required".into());
59    }
60    let params = obj
61        .get("params")
62        .and_then(|v| v.as_object())
63        .ok_or("params required")?;
64    for k in ["temperature", "top_p"] {
65        if !params.get(k).map(|v| v.is_number()).unwrap_or(false) {
66            return Err(format!("params.{k} must be number"));
67        }
68    }
69    for k in ["seed", "max_tokens"] {
70        if let Some(v) = params.get(k) {
71            if v.as_i64().is_none() {
72                return Err(format!("params.{k} must be integer"));
73            }
74        }
75    }
76    let msgs = obj
77        .get("messages")
78        .and_then(|v| v.as_array())
79        .ok_or("messages must be array")?;
80    for m in msgs {
81        let mo = m.as_object().ok_or("message not object")?;
82        if !mo.get("role").map(|v| v.is_string()).unwrap_or(false) {
83            return Err("message.role must be string".into());
84        }
85        let c = mo.get("content");
86        if !c.map(|v| v.is_string() || v.is_array()).unwrap_or(false) {
87            return Err("message.content must be string or array".into());
88        }
89    }
90    for k in ["tool_calls", "tool_results"] {
91        if let Some(arr) = obj.get(k) {
92            let arr = arr.as_array().ok_or(format!("{k} must be array"))?;
93            for tc in arr {
94                let tco = tc.as_object().ok_or(format!("{k} item not object"))?;
95                if !tco.get("id").map(|v| v.is_string()).unwrap_or(false) {
96                    return Err(format!("{k}.id must be string"));
97                }
98                if k == "tool_calls" {
99                    if !tco.get("name").map(|v| v.is_string()).unwrap_or(false) {
100                        return Err("tool_calls.name must be string".into());
101                    }
102                    if !check_hash(tco.get("args_hash")) {
103                        return Err("tool_calls.args_hash invalid".into());
104                    }
105                } else {
106                    let st = tco.get("status").and_then(|v| v.as_str()).unwrap_or("");
107                    if !TOOL_STATUS.contains(&st) {
108                        return Err("tool_results.status must be ok|error".into());
109                    }
110                    if !check_hash(tco.get("response_hash")) {
111                        return Err("tool_results.response_hash invalid".into());
112                    }
113                }
114            }
115        }
116    }
117    if obj.get("timestamp_ns").and_then(|v| v.as_u64()).is_none() {
118        return Err("timestamp_ns must be non-negative integer".into());
119    }
120    if obj.contains_key("prev_hash") && !check_hash(obj.get("prev_hash")) {
121        return Err("prev_hash invalid".into());
122    }
123    Ok(())
124}
125
126pub fn validate_sealed_turn(value: &Value) -> Result<(), String> {
127    validate_turn(value)?;
128    let obj = value.as_object().unwrap();
129    if !check_hash(obj.get("hash")) {
130        return Err("hash invalid".into());
131    }
132    if let Some(sig) = obj.get("sig") {
133        let so = sig.as_object().ok_or("sig not object")?;
134        if so.get("alg").and_then(|v| v.as_str()) != Some("ed25519") {
135            return Err("sig.alg must be \"ed25519\"".into());
136        }
137        if !so.get("pubkey").map(|v| v.is_string()).unwrap_or(false)
138            || !so.get("sig").map(|v| v.is_string()).unwrap_or(false)
139        {
140            return Err("sig.pubkey and sig.sig required".into());
141        }
142    }
143    Ok(())
144}