Skip to main content

agentd/engine/
data.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! The **data steps**: array and text operations that need no model —
3//! `map`, `filter`, `reduce`, `sort`, `dedupe`, `chunk`, `parse` — as pure
4//! functions over JSON values. Element expressions are CEL (`item`, `index`,
5//! `acc`, plus the run data) or `{{template}}` strings; `by` keys are dotted
6//! paths. Being pure and model-free, these steps cost nothing to replay, so
7//! the runtime may re-run them after a crash instead of checkpointing each one.
8
9use super::template::{self, Data};
10use serde_json::{Map, Value, json};
11
12/// Evaluate an element expression: `CEL: …` (or a bare CEL when it does not
13/// look like a template), or a `{{…}}` template.
14fn eval_expr(expr: &str, data: &Data) -> Result<Value, String> {
15    let t = expr.trim();
16    if t.contains("{{") {
17        return template::render_str(t, data);
18    }
19    let cel = t.strip_prefix("CEL:").unwrap_or(t).trim();
20    let vars: Vec<(&str, &Value)> = data.iter().map(|(k, v)| (k.as_str(), v)).collect();
21    crate::cel::eval_value(cel, &vars).map_err(|e| format!("CEL: {e}"))
22}
23
24fn with_item(data: &Data, alias: &str, item: &Value, index: usize) -> Data {
25    let mut d = data.clone();
26    d.insert(alias.to_string(), item.clone());
27    d.insert("item".to_string(), item.clone());
28    d.insert("index".to_string(), json!(index));
29    d
30}
31
32fn as_array(over: &Value, what: &str) -> Result<Vec<Value>, String> {
33    match over {
34        Value::Array(a) => Ok(a.clone()),
35        Value::Object(o) => Ok(o
36            .iter()
37            .map(|(k, v)| json!({"key": k, "value": v}))
38            .collect()),
39        Value::Null => Ok(Vec::new()),
40        other => Err(format!(
41            "{what}: `over` must be an array (got {})",
42            type_name(other)
43        )),
44    }
45}
46
47fn type_name(v: &Value) -> &'static str {
48    match v {
49        Value::Null => "null",
50        Value::Bool(_) => "boolean",
51        Value::Number(_) => "number",
52        Value::String(_) => "string",
53        Value::Array(_) => "array",
54        Value::Object(_) => "object",
55    }
56}
57
58/// `map`: `expr` over every element.
59pub fn map(over: &Value, expr: &str, alias: &str, data: &Data) -> Result<Value, String> {
60    let items = as_array(over, "map")?;
61    let mut out = Vec::with_capacity(items.len());
62    for (i, it) in items.iter().enumerate() {
63        out.push(eval_expr(expr, &with_item(data, alias, it, i))?);
64    }
65    Ok(Value::Array(out))
66}
67
68/// `filter`: keep the elements whose `expr` is true.
69pub fn filter(over: &Value, expr: &str, alias: &str, data: &Data) -> Result<Value, String> {
70    let items = as_array(over, "filter")?;
71    let mut out = Vec::new();
72    for (i, it) in items.iter().enumerate() {
73        let v = eval_expr(expr, &with_item(data, alias, it, i))?;
74        match v {
75            Value::Bool(true) => out.push(it.clone()),
76            Value::Bool(false) => {}
77            other => {
78                return Err(format!(
79                    "filter: expr must yield a boolean (got {})",
80                    type_name(&other)
81                ));
82            }
83        }
84    }
85    Ok(Value::Array(out))
86}
87
88/// `reduce`: fold `expr` over the elements with `acc` (starting at `initial`).
89pub fn reduce(
90    over: &Value,
91    expr: &str,
92    initial: Value,
93    alias: &str,
94    acc_alias: &str,
95    data: &Data,
96) -> Result<Value, String> {
97    let items = as_array(over, "reduce")?;
98    let mut acc = initial;
99    for (i, it) in items.iter().enumerate() {
100        let mut d = with_item(data, alias, it, i);
101        d.insert(acc_alias.to_string(), acc.clone());
102        d.insert("acc".to_string(), acc.clone());
103        acc = eval_expr(expr, &d)?;
104    }
105    Ok(acc)
106}
107
108/// `sort`: by a dotted path (or the element itself), `asc|desc`; stable.
109pub fn sort(over: &Value, by: Option<&str>, order: Option<&str>) -> Result<Value, String> {
110    let mut items = as_array(over, "sort")?;
111    let desc = matches!(order, Some("desc") | Some("descending"));
112    let key = |v: &Value| -> Value {
113        match by {
114            None | Some("") => v.clone(),
115            Some(p) => path_of(v, p).unwrap_or(Value::Null),
116        }
117    };
118    items.sort_by(|a, b| {
119        let o = cmp_values(&key(a), &key(b));
120        if desc { o.reverse() } else { o }
121    });
122    Ok(Value::Array(items))
123}
124
125/// `dedupe`: keep the first occurrence per key (`by` path) / value.
126pub fn dedupe(over: &Value, by: Option<&str>) -> Result<Value, String> {
127    let items = as_array(over, "dedupe")?;
128    let mut seen: Vec<Value> = Vec::new();
129    let mut out = Vec::new();
130    for it in items {
131        let k = match by {
132            None | Some("") => it.clone(),
133            Some(p) => path_of(&it, p).unwrap_or(Value::Null),
134        };
135        if !seen.contains(&k) {
136            seen.push(k);
137            out.push(it);
138        }
139    }
140    Ok(Value::Array(out))
141}
142
143/// `chunk`: split text by `chars|lines|tokens` (approximate) or an array into
144/// slices of `size`, with `overlap` elements/chars carried over.
145pub fn chunk(
146    value: &Value,
147    by: Option<&str>,
148    size: usize,
149    overlap: usize,
150) -> Result<Value, String> {
151    if size == 0 {
152        return Err("chunk: size must be > 0".into());
153    }
154    let overlap = overlap.min(size.saturating_sub(1));
155    match value {
156        Value::Array(a) => {
157            let mut out = Vec::new();
158            let mut start = 0;
159            while start < a.len() {
160                let end = (start + size).min(a.len());
161                out.push(Value::Array(a[start..end].to_vec()));
162                if end == a.len() {
163                    break;
164                }
165                start = end - overlap;
166            }
167            Ok(Value::Array(out))
168        }
169        Value::String(s) => {
170            let mode = by.unwrap_or("chars");
171            let out: Vec<Value> = match mode {
172                "lines" => {
173                    let lines: Vec<&str> = s.lines().collect();
174                    windows(&lines, size, overlap)
175                        .into_iter()
176                        .map(|w| Value::String(w.join("\n")))
177                        .collect()
178                }
179                "words" | "tokens" => {
180                    // tokens ≈ words × 1.3; chunk by words with size/1.3 words per chunk.
181                    let words: Vec<&str> = s.split_whitespace().collect();
182                    let per = if mode == "tokens" {
183                        ((size as f64) / 1.3).max(1.0) as usize
184                    } else {
185                        size
186                    };
187                    let ov = if mode == "tokens" {
188                        ((overlap as f64) / 1.3) as usize
189                    } else {
190                        overlap
191                    };
192                    windows(&words, per, ov.min(per.saturating_sub(1)))
193                        .into_iter()
194                        .map(|w| Value::String(w.join(" ")))
195                        .collect()
196                }
197                "chars" => {
198                    let chars: Vec<char> = s.chars().collect();
199                    windows(&chars, size, overlap)
200                        .into_iter()
201                        .map(|w| Value::String(w.into_iter().collect()))
202                        .collect()
203                }
204                other => {
205                    return Err(format!(
206                        "chunk: by must be chars|lines|words|tokens (got {other:?})"
207                    ));
208                }
209            };
210            Ok(Value::Array(out))
211        }
212        other => Err(format!(
213            "chunk: value must be a string or an array (got {})",
214            type_name(other)
215        )),
216    }
217}
218
219fn windows<T: Clone>(items: &[T], size: usize, overlap: usize) -> Vec<Vec<T>> {
220    let mut out = Vec::new();
221    let mut start = 0;
222    while start < items.len() {
223        let end = (start + size).min(items.len());
224        out.push(items[start..end].to_vec());
225        if end == items.len() {
226            break;
227        }
228        start = end - overlap;
229    }
230    out
231}
232
233/// `parse`: text → JSON (`json` | `yaml` | `csv` | `lines` | `auto`).
234pub fn parse(text: &str, format: Option<&str>) -> Result<Value, String> {
235    let f = format.unwrap_or("auto");
236    match f {
237        "json" => serde_json::from_str::<Value>(text).map_err(|e| format!("parse json: {e}")),
238        "yaml" => crate::config::yaml::parse(text).map_err(|e| format!("parse yaml: {e}")),
239        "lines" => Ok(Value::Array(
240            text.lines().map(|l| Value::String(l.to_string())).collect(),
241        )),
242        "csv" => Ok(parse_csv(text)),
243        "auto" => {
244            let t = text.trim();
245            if let Ok(v) = serde_json::from_str::<Value>(t) {
246                return Ok(v);
247            }
248            if let Ok(v) = crate::config::yaml::parse(t)
249                && !matches!(v, Value::String(_))
250            {
251                return Ok(v);
252            }
253            Ok(Value::Array(
254                text.lines().map(|l| Value::String(l.to_string())).collect(),
255            ))
256        }
257        other => Err(format!(
258            "parse: format must be json|yaml|csv|lines|auto (got {other:?})"
259        )),
260    }
261}
262
263/// A minimal CSV reader with RFC 4180 quoting: the first non-blank line is the
264/// header, every later
265/// row becomes an object keyed by it, and a short row pads with nulls rather
266/// than dropping columns. Quoting is the usual convention — a `"` toggles the
267/// quoted state, `""` inside it is a literal quote, and a comma inside quotes
268/// is data. Rows are read line by line, so an embedded newline inside a quoted
269/// field is not supported.
270fn parse_csv(text: &str) -> Value {
271    let rows: Vec<Vec<String>> = text
272        .lines()
273        .filter(|l| !l.trim().is_empty())
274        .map(csv_row)
275        .collect();
276    let Some((header, body)) = rows.split_first() else {
277        return json!([]);
278    };
279    Value::Array(
280        body.iter()
281            .map(|r| {
282                let mut o = Map::new();
283                for (i, h) in header.iter().enumerate() {
284                    o.insert(
285                        h.clone(),
286                        r.get(i).map(|c| coerce_scalar(c)).unwrap_or(Value::Null),
287                    );
288                }
289                Value::Object(o)
290            })
291            .collect(),
292    )
293}
294
295fn csv_row(line: &str) -> Vec<String> {
296    let mut out = Vec::new();
297    let mut cur = String::new();
298    let mut quoted = false;
299    let mut chars = line.chars().peekable();
300    while let Some(c) = chars.next() {
301        match c {
302            '"' if quoted && chars.peek() == Some(&'"') => {
303                cur.push('"');
304                chars.next();
305            }
306            '"' => quoted = !quoted,
307            ',' if !quoted => {
308                out.push(std::mem::take(&mut cur));
309            }
310            other => cur.push(other),
311        }
312    }
313    out.push(cur);
314    out
315}
316
317fn coerce_scalar(s: &str) -> Value {
318    if let Ok(i) = s.parse::<i64>() {
319        return json!(i);
320    }
321    if let Ok(f) = s.parse::<f64>() {
322        return json!(f);
323    }
324    match s {
325        "true" => json!(true),
326        "false" => json!(false),
327        _ => Value::String(s.to_string()),
328    }
329}
330
331/// A dotted path inside a value.
332pub fn path_of(v: &Value, path: &str) -> Option<Value> {
333    let mut cur = v;
334    for seg in path.split('.') {
335        cur = match cur {
336            Value::Object(m) => m.get(seg)?,
337            Value::Array(a) => a.get(seg.parse::<usize>().ok()?)?,
338            _ => return None,
339        };
340    }
341    Some(cur.clone())
342}
343
344/// A total order over JSON values: null < bool < number < string < array < object.
345pub fn cmp_values(a: &Value, b: &Value) -> std::cmp::Ordering {
346    use std::cmp::Ordering::*;
347    let rank = |v: &Value| match v {
348        Value::Null => 0,
349        Value::Bool(_) => 1,
350        Value::Number(_) => 2,
351        Value::String(_) => 3,
352        Value::Array(_) => 4,
353        Value::Object(_) => 5,
354    };
355    match (a, b) {
356        (Value::Bool(x), Value::Bool(y)) => x.cmp(y),
357        (Value::Number(x), Value::Number(y)) => {
358            x.as_f64().partial_cmp(&y.as_f64()).unwrap_or(Equal)
359        }
360        (Value::String(x), Value::String(y)) => x.cmp(y),
361        (Value::Array(x), Value::Array(y)) => {
362            for (p, q) in x.iter().zip(y.iter()) {
363                let o = cmp_values(p, q);
364                if o != Equal {
365                    return o;
366                }
367            }
368            x.len().cmp(&y.len())
369        }
370        _ => rank(a).cmp(&rank(b)),
371    }
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377
378    #[cfg(feature = "cel")]
379    fn data() -> Data {
380        let mut d = Data::new();
381        d.insert("vars".into(), json!({"min": 2}));
382        d
383    }
384
385    #[cfg(feature = "cel")]
386    #[test]
387    fn map_filter_reduce_with_cel_and_templates() {
388        let d = data();
389        assert_eq!(
390            map(&json!([1, 2, 3]), "item * 2", "item", &d).unwrap(),
391            json!([2, 4, 6])
392        );
393        assert_eq!(
394            map(
395                &json!([{"n": 1}, {"n": 5}]),
396                "{{item.n}}-{{index}}",
397                "item",
398                &d
399            )
400            .unwrap(),
401            json!(["1-0", "5-1"])
402        );
403        assert_eq!(
404            filter(&json!([1, 2, 3, 4]), "CEL: x > vars.min", "x", &d).unwrap(),
405            json!([3, 4])
406        );
407        assert!(
408            filter(&json!([1]), "item", "item", &d).is_err(),
409            "non-boolean"
410        );
411        assert_eq!(
412            reduce(&json!([1, 2, 3]), "acc + item", json!(0), "item", "acc", &d).unwrap(),
413            json!(6)
414        );
415        assert_eq!(
416            reduce(
417                &json!(["a", "b"]),
418                "total + \"|\" + s",
419                json!(""),
420                "s",
421                "total",
422                &d
423            )
424            .unwrap(),
425            json!("|a|b")
426        );
427        // Objects iterate as {key, value}.
428        assert_eq!(
429            map(
430                &json!({"a": 1, "b": 2}),
431                "item.key + \"=\" + string(item.value)",
432                "item",
433                &d
434            )
435            .unwrap(),
436            json!(["a=1", "b=2"])
437        );
438    }
439
440    #[test]
441    fn sort_dedupe_chunk_parse() {
442        assert_eq!(
443            sort(&json!([3, 1, 2]), None, None).unwrap(),
444            json!([1, 2, 3])
445        );
446        assert_eq!(
447            sort(
448                &json!([{"n": 3, "s": "c"}, {"n": 1, "s": "a"}]),
449                Some("n"),
450                Some("desc")
451            )
452            .unwrap(),
453            json!([{"n": 3, "s": "c"}, {"n": 1, "s": "a"}])
454        );
455        assert_eq!(
456            sort(&json!(["b", null, 2, "a", true]), None, None).unwrap(),
457            json!([null, true, 2, "a", "b"])
458        );
459        assert_eq!(
460            dedupe(&json!([1, 2, 1, 3, 2]), None).unwrap(),
461            json!([1, 2, 3])
462        );
463        assert_eq!(
464            dedupe(
465                &json!([{"id": 1, "x": "a"}, {"id": 1, "x": "b"}, {"id": 2}]),
466                Some("id")
467            )
468            .unwrap(),
469            json!([{"id": 1, "x": "a"}, {"id": 2}])
470        );
471        assert_eq!(
472            chunk(&json!([1, 2, 3, 4, 5]), None, 2, 0).unwrap(),
473            json!([[1, 2], [3, 4], [5]])
474        );
475        assert_eq!(
476            chunk(&json!([1, 2, 3, 4, 5]), None, 3, 1).unwrap(),
477            json!([[1, 2, 3], [3, 4, 5]])
478        );
479        assert_eq!(
480            chunk(&json!("abcdefg"), Some("chars"), 3, 0).unwrap(),
481            json!(["abc", "def", "g"])
482        );
483        assert_eq!(
484            chunk(&json!("l1\nl2\nl3"), Some("lines"), 2, 0).unwrap(),
485            json!(["l1\nl2", "l3"])
486        );
487        assert_eq!(
488            chunk(&json!("a b c d"), Some("words"), 2, 0).unwrap(),
489            json!(["a b", "c d"])
490        );
491        assert!(chunk(&json!("x"), None, 0, 0).is_err());
492        assert_eq!(parse("{\"a\": 1}", None).unwrap(), json!({"a": 1}));
493        assert_eq!(
494            parse("a: 1\nb: [x, y]", Some("yaml")).unwrap(),
495            json!({"a": 1, "b": ["x", "y"]})
496        );
497        assert_eq!(parse("x\ny", Some("lines")).unwrap(), json!(["x", "y"]));
498        assert_eq!(
499            parse("name,age\n\"Doe, J\",42\nAnn,7", Some("csv")).unwrap(),
500            json!([{"name": "Doe, J", "age": 42}, {"name": "Ann", "age": 7}])
501        );
502        assert_eq!(parse("plain text", None).unwrap(), json!(["plain text"]));
503        assert!(parse("x", Some("xml")).is_err());
504        assert_eq!(
505            path_of(&json!({"a": {"b": [10, 20]}}), "a.b.1"),
506            Some(json!(20))
507        );
508    }
509}