Skip to main content

agentd/
cel.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! CEL (Common Expression Language) evaluation — the ONE gated exception to the
3//! zero-dependency moat (`--features cel`, default OFF).
4//!
5//! CEL is used wherever agentd evaluates a deterministic expression over run
6//! data: workflow `{"op":"cel"}` predicates, computed `assign.expr` values,
7//! `infer.check` value constraints, and reactive `{"op":"cel"}` wake conditions.
8//! Its design properties are exactly the requirements here: non-Turing-complete,
9//! no I/O, guaranteed termination — the one form of "code" a model can safely
10//! author and agentd can immediately execute.
11//!
12//! This module is ALWAYS compiled; only its internals are feature-gated. A
13//! non-cel build answers every call with a clear "requires the 'cel' build
14//! feature" error, so authoring surfaces reject CEL at define/parse time
15//! (fail-closed) instead of silently mis-evaluating at run time.
16
17use serde_json::Value;
18
19/// Expression length cap — a routing/shaping expression is a line, not a
20/// program; an oversized one is refused at compile-check time.
21pub const MAX_CEL_EXPR: usize = 4096;
22
23/// The message every entry point returns on a build without the feature.
24pub const FEATURE_MSG: &str = "CEL expressions require the 'cel' build feature";
25
26/// Compile-check an expression (define/parse-time validation): length cap +
27/// full CEL parse. `Err` carries the parser's message for the author.
28pub fn compile_check(expr: &str) -> Result<(), String> {
29    if expr.trim().is_empty() {
30        return Err("empty CEL expression".into());
31    }
32    if expr.len() > MAX_CEL_EXPR {
33        return Err(format!(
34            "CEL expression is {} bytes (max {MAX_CEL_EXPR})",
35            expr.len()
36        ));
37    }
38    #[cfg(feature = "cel")]
39    {
40        imp::compile(expr).map(|_| ())
41    }
42    #[cfg(not(feature = "cel"))]
43    {
44        Err(FEATURE_MSG.into())
45    }
46}
47
48/// Evaluate an expression to a BOOLEAN with the given variables in scope
49/// (each `(name, value)` becomes a top-level identifier). A non-bool result is
50/// an error — a predicate must decide, not coerce.
51pub fn eval_bool(expr: &str, vars: &[(&str, &Value)]) -> Result<bool, String> {
52    #[cfg(feature = "cel")]
53    {
54        match imp::eval(expr, vars)? {
55            cel_interpreter::Value::Bool(b) => Ok(b),
56            other => Err(format!(
57                "CEL expression returned {:?}, want bool",
58                other.type_of()
59            )),
60        }
61    }
62    #[cfg(not(feature = "cel"))]
63    {
64        let _ = (expr, vars);
65        Err(FEATURE_MSG.into())
66    }
67}
68
69/// Evaluate an expression to a JSON VALUE with the given variables in scope —
70/// the computed-`assign` path.
71pub fn eval_value(expr: &str, vars: &[(&str, &Value)]) -> Result<Value, String> {
72    #[cfg(feature = "cel")]
73    {
74        imp::eval(expr, vars)?
75            .json()
76            .map_err(|e| format!("CEL result is not JSON-representable: {e}"))
77    }
78    #[cfg(not(feature = "cel"))]
79    {
80        let _ = (expr, vars);
81        Err(FEATURE_MSG.into())
82    }
83}
84
85/// Convenience: a blackboard-shaped variable list (`BTreeMap<String, Value>` →
86/// the `(name, value)` slice shape the eval fns take).
87pub fn vars_of(map: &std::collections::BTreeMap<String, Value>) -> Vec<(&str, &Value)> {
88    map.iter().map(|(k, v)| (k.as_str(), v)).collect()
89}
90
91#[cfg(feature = "cel")]
92mod imp {
93    use serde_json::Value;
94
95    /// Compile an expression. The upstream parser (`antlr4rust`) can PANIC on
96    /// some malformed inputs (an unfinished binary expression such as `1 +`
97    /// hits an `unreachable!` in its generated tree walker); a config/workflow
98    /// author's typo must never take the process down, so the parse runs
99    /// under `catch_unwind` and reports a parse error instead.
100    pub fn compile(expr: &str) -> Result<cel_interpreter::Program, String> {
101        let owned = expr.to_string();
102        match std::panic::catch_unwind(move || cel_interpreter::Program::compile(&owned)) {
103            Ok(r) => r.map_err(|e| format!("CEL parse: {e}")),
104            Err(_) => Err("CEL parse: malformed expression (the parser rejected it)".into()),
105        }
106    }
107
108    /// Convert JSON → CEL with CANONICAL number typing: JSON has one number
109    /// type, CEL has three (Int/UInt/Float) that do not mix in arithmetic or
110    /// comparison. The default Serialize-based conversion maps a non-negative
111    /// integer to UInt — making `count + 1` a type error against the Int
112    /// literal. Normalizing every i64-fitting integer to Int (else Float) makes
113    /// expressions over JSON data behave the way their authors expect.
114    fn to_cel(v: &Value) -> cel_interpreter::Value {
115        use cel_interpreter::Value as C;
116        use std::sync::Arc;
117        match v {
118            Value::Null => C::Null,
119            Value::Bool(b) => C::Bool(*b),
120            Value::Number(n) => {
121                if let Some(i) = n.as_i64() {
122                    C::Int(i)
123                } else if let Some(f) = n.as_f64() {
124                    C::Float(f)
125                } else {
126                    C::Null
127                }
128            }
129            Value::String(s) => C::String(Arc::new(s.clone())),
130            Value::Array(a) => C::List(Arc::new(a.iter().map(to_cel).collect())),
131            Value::Object(o) => {
132                let map: std::collections::HashMap<String, C> =
133                    o.iter().map(|(k, v)| (k.clone(), to_cel(v))).collect();
134                C::Map(map.into())
135            }
136        }
137    }
138
139    /// The two list operations CEL's standard library lacks and every real
140    /// template wants: a bounded slice and a string join. Registered on every
141    /// evaluation context, so they work in workflow expressions too — a `take`
142    /// in a `filter:` is the same idea as a `take` in a prompt template.
143    fn register_helpers(ctx: &mut cel_interpreter::Context) {
144        use cel_interpreter::Value as C;
145        use cel_interpreter::extractors::This;
146        use std::sync::Arc;
147
148        /// `take(list, n)` / `list.take(n)` — the first `n` elements. CEL has
149        /// no slicing, so "the top 16 services" is otherwise inexpressible.
150        fn take(This(this): This<C>, n: i64) -> Result<C, cel_interpreter::ExecutionError> {
151            let n = n.max(0) as usize;
152            match this {
153                C::List(items) => Ok(C::List(Arc::new(
154                    items.iter().take(n).cloned().collect::<Vec<_>>(),
155                ))),
156                C::String(s) => Ok(C::String(Arc::new(s.chars().take(n).collect::<String>()))),
157                other => Ok(other),
158            }
159        }
160
161        /// `join(list, sep)` / `list.join(sep)` — non-strings are rendered the
162        /// way the prompt renderer would render them.
163        fn join(
164            This(this): This<C>,
165            sep: Arc<String>,
166        ) -> Result<C, cel_interpreter::ExecutionError> {
167            let C::List(items) = this else {
168                return Ok(this);
169            };
170            let parts: Vec<String> = items
171                .iter()
172                .map(|v| match v {
173                    C::String(s) => s.to_string(),
174                    C::Int(i) => i.to_string(),
175                    C::UInt(u) => u.to_string(),
176                    C::Float(f) => f.to_string(),
177                    C::Bool(b) => b.to_string(),
178                    C::Null => String::new(),
179                    other => format!("{other:?}"),
180                })
181                .collect();
182            Ok(C::String(Arc::new(parts.join(sep.as_str()))))
183        }
184
185        ctx.add_function("take", take);
186        ctx.add_function("join", join);
187    }
188
189    pub fn eval(expr: &str, vars: &[(&str, &Value)]) -> Result<cel_interpreter::Value, String> {
190        // Programs are memoized per expression text: workflows evaluate the
191        // same `when:`/value expressions once per step per run, and the ANTLR
192        // parse (under its catch_unwind) dominated evaluation cost. The
193        // reactor is single-threaded, so a thread-local map IS the process
194        // cache; the cap only guards a pathological generator of unique
195        // expressions.
196        use std::cell::RefCell;
197        use std::collections::HashMap;
198        use std::rc::Rc;
199        thread_local! {
200            static PROGRAMS: RefCell<HashMap<String, Rc<cel_interpreter::Program>>> =
201                RefCell::new(HashMap::new());
202        }
203        let program = PROGRAMS.with(|cache| -> Result<Rc<cel_interpreter::Program>, String> {
204            if let Some(p) = cache.borrow().get(expr) {
205                return Ok(p.clone());
206            }
207            let p = Rc::new(compile(expr)?);
208            let mut c = cache.borrow_mut();
209            if c.len() >= 4096 {
210                c.clear();
211            }
212            c.insert(expr.to_string(), p.clone());
213            Ok(p)
214        })?;
215        let mut ctx = cel_interpreter::Context::default();
216        register_helpers(&mut ctx);
217        for (name, value) in vars {
218            ctx.add_variable_from_value(name.to_string(), to_cel(value));
219        }
220        // Same panic guard on evaluation (defensive: the interpreter is a
221        // third-party dependency running author-supplied expressions).
222        match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| program.execute(&ctx))) {
223            Ok(r) => r.map_err(|e| format!("CEL eval: {e}")),
224            Err(_) => Err("CEL eval: the interpreter failed on this expression".into()),
225        }
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232    use serde_json::json;
233
234    #[test]
235    fn empty_and_oversized_expressions_are_refused() {
236        assert!(compile_check("").is_err());
237        assert!(compile_check(&"1 + ".repeat(2000)).is_err());
238    }
239
240    #[cfg(feature = "cel")]
241    mod with_cel {
242        use super::*;
243
244        #[test]
245        fn compile_check_accepts_valid_and_names_parse_errors() {
246            assert!(compile_check("a.b >= 3 && c in ['x','y']").is_ok());
247            let e = compile_check("a >=< 3").unwrap_err();
248            assert!(e.contains("CEL parse"), "{e}");
249        }
250
251        #[test]
252        fn eval_bool_computes_arithmetic_and_macros_over_variables() {
253            let a = json!({"count": 7, "items": [{"s": "ok"}, {"s": "bad"}]});
254            let b = json!({"limit": 5});
255            let vars = vec![("a", &a), ("b", &b)];
256            assert!(eval_bool("a.count + 1 > b.limit * 1", &vars).unwrap());
257            assert!(eval_bool("a.items.exists(i, i.s == 'bad')", &vars).unwrap());
258            assert!(eval_bool("a.items.filter(i, i.s == 'ok').size() == 1", &vars).unwrap());
259            // Non-bool result is an error, not a coercion.
260            assert!(eval_bool("a.count", &vars).is_err());
261            // An undeclared reference is an eval error (callers fail closed).
262            assert!(eval_bool("ghost > 1", &vars).is_err());
263        }
264
265        #[test]
266        fn take_and_join_fill_cels_list_gaps() {
267            // The two helpers a prompt template needs and CEL lacks.
268            let svc = json!([{"name":"billing"},{"name":"docs"},{"name":"crm"}]);
269            let vars = vec![("services", &svc)];
270            assert_eq!(
271                eval_value("services.map(s, s.name).take(2).join(\", \")", &vars).unwrap(),
272                json!("billing, docs")
273            );
274            assert_eq!(
275                eval_value("take(services, 1).map(s, s.name)", &vars).unwrap(),
276                json!(["billing"])
277            );
278            // take() past the end is the whole list, never an error.
279            assert_eq!(
280                eval_value("services.take(99).size()", &vars).unwrap(),
281                json!(3)
282            );
283        }
284
285        #[test]
286        fn eval_value_shapes_json() {
287            let scan = json!({"items": [{"id": 1, "ok": true}, {"id": 2, "ok": false}, {"id": 3, "ok": true}]});
288            let vars = vec![("scan", &scan)];
289            let v = eval_value("scan.items.filter(i, i.ok).map(i, i.id)", &vars).unwrap();
290            assert_eq!(v, json!([1, 3]));
291            let v = eval_value(
292                "{'total': scan.items.size(), 'first': scan.items[0].id}",
293                &vars,
294            )
295            .unwrap();
296            assert_eq!(v, json!({"total": 3, "first": 1}));
297        }
298    }
299
300    #[cfg(not(feature = "cel"))]
301    #[test]
302    fn without_the_feature_every_entry_point_names_it() {
303        let v = json!(1);
304        let vars = vec![("a", &v)];
305        assert!(compile_check("a > 0").unwrap_err().contains("'cel'"));
306        assert!(eval_bool("a > 0", &vars).unwrap_err().contains("'cel'"));
307        assert!(eval_value("a", &vars).unwrap_err().contains("'cel'"));
308    }
309}