1use serde_json::Value;
44
45pub fn eval_condition(expr: &str, state: &Value) -> bool {
62 let expr = expr.trim();
63 if expr.is_empty() {
64 return false;
65 }
66
67 match find_first_op(expr) {
68 Some((op_pos, op)) => eval_comparison(expr, state, op_pos, op),
69 None => eval_truthiness(expr, state),
70 }
71}
72
73fn find_first_op(expr: &str) -> Option<(usize, &'static str)> {
81 let b = expr.as_bytes();
82 let len = b.len();
83 if len < 2 {
84 return None;
85 }
86 for i in 0..len - 1 {
87 match (b[i], b[i + 1]) {
88 (b'!', b'=') => return Some((i, "!=")),
89 (b'=', b'=') => return Some((i, "==")),
90 _ => {}
91 }
92 }
93 None
94}
95
96fn eval_comparison(expr: &str, state: &Value, op_pos: usize, op: &str) -> bool {
98 let lhs = expr[..op_pos].trim();
99 let rhs = expr[op_pos + 2..].trim();
102
103 let path = match lhs.strip_prefix("state.") {
104 Some(p) if !p.is_empty() => p,
105 _ => {
106 tracing::warn!(
107 "eval_condition: lhs `{}` does not start with `state.<path>`; returning false",
108 lhs
109 );
110 return false;
111 }
112 };
113
114 let rhs_val = match parse_literal(rhs) {
117 Some(v) => v,
118 None => {
119 tracing::warn!(
120 "eval_condition: RHS `{}` is not a valid literal \
121 (must be a quoted string, true, false, null, or a number); returning false",
122 rhs
123 );
124 return false;
125 }
126 };
127
128 let resolved = resolve_path(state, path);
129 let equal = values_equal(resolved, &rhs_val);
130
131 match op {
132 "==" => equal,
133 "!=" => !equal,
134 _ => false, }
136}
137
138fn eval_truthiness(expr: &str, state: &Value) -> bool {
140 let path = match expr.strip_prefix("state.") {
141 Some(p) if !p.is_empty() => p,
142 _ => {
143 tracing::warn!(
144 "eval_condition: expr `{}` does not start with `state.<path>`; returning false",
145 expr
146 );
147 return false;
148 }
149 };
150 is_truthy(resolve_path(state, path))
151}
152
153fn resolve_path<'a>(root: &'a Value, dotted_path: &str) -> Option<&'a Value> {
158 let mut current = root;
159 for segment in dotted_path.split('.') {
160 if segment.is_empty() {
161 return None;
162 }
163 match current {
164 Value::Object(map) => {
165 current = map.get(segment)?;
166 }
167 Value::Array(arr) => {
168 let idx: usize = segment.parse().ok()?;
169 current = arr.get(idx)?;
170 }
171 _ => return None,
172 }
173 }
174 Some(current)
175}
176
177fn is_truthy(v: Option<&Value>) -> bool {
180 match v {
181 None => false,
182 Some(Value::Null) => false,
183 Some(Value::Bool(b)) => *b,
184 Some(Value::Number(n)) => n.as_f64().is_some_and(|f| f != 0.0),
185 Some(Value::String(s)) => !s.is_empty(),
186 Some(Value::Array(a)) => !a.is_empty(),
187 Some(Value::Object(o)) => !o.is_empty(),
188 }
189}
190
191fn parse_literal(s: &str) -> Option<Value> {
198 serde_json::from_str::<Value>(s).ok()
199}
200
201fn values_equal(resolved: Option<&Value>, rhs: &Value) -> bool {
206 match resolved {
207 None => rhs == &Value::Null,
208 Some(v) => v == rhs,
209 }
210}
211
212#[cfg(test)]
217mod tests {
218 use super::*;
219 use serde_json::json;
220
221 #[test]
224 fn string_eq_match() {
225 assert!(eval_condition(
226 r#"state.last_model_finish_reason == "tool_calls""#,
227 &json!({"last_model_finish_reason": "tool_calls"}),
228 ));
229 }
230
231 #[test]
232 fn string_eq_mismatch() {
233 assert!(!eval_condition(
234 r#"state.last_model_finish_reason == "tool_calls""#,
235 &json!({"last_model_finish_reason": "stop"}),
236 ));
237 }
238
239 #[test]
240 fn string_eq_missing_key() {
241 assert!(!eval_condition(
242 r#"state.last_model_finish_reason == "tool_calls""#,
243 &json!({}),
244 ));
245 }
246
247 #[test]
250 fn string_neq_true() {
251 assert!(eval_condition(r#"state.x != "a""#, &json!({"x": "b"}),));
252 }
253
254 #[test]
255 fn string_neq_false() {
256 assert!(!eval_condition(r#"state.x != "a""#, &json!({"x": "a"}),));
257 }
258
259 #[test]
262 fn truthiness_bool_true() {
263 assert!(eval_condition(
264 "state.__cost_exceeded__",
265 &json!({"__cost_exceeded__": true}),
266 ));
267 }
268
269 #[test]
270 fn truthiness_bool_false() {
271 assert!(!eval_condition(
272 "state.__cost_exceeded__",
273 &json!({"__cost_exceeded__": false}),
274 ));
275 }
276
277 #[test]
278 fn truthiness_absent_key() {
279 assert!(!eval_condition("state.__cost_exceeded__", &json!({})));
280 }
281
282 #[test]
283 fn truthiness_non_empty_string() {
284 assert!(eval_condition(
285 "state.__cost_exceeded__",
286 &json!({"__cost_exceeded__": "yes"}),
287 ));
288 }
289
290 #[test]
291 fn truthiness_zero_is_falsy() {
292 assert!(!eval_condition("state.count", &json!({"count": 0})));
293 }
294
295 #[test]
296 fn truthiness_empty_string_is_falsy() {
297 assert!(!eval_condition("state.s", &json!({"s": ""})));
298 }
299
300 #[test]
301 fn truthiness_empty_array_is_falsy() {
302 assert!(!eval_condition("state.arr", &json!({"arr": []})));
303 }
304
305 #[test]
306 fn truthiness_empty_object_is_falsy() {
307 assert!(!eval_condition("state.obj", &json!({"obj": {}})));
308 }
309
310 #[test]
313 fn nested_bool_eq_true() {
314 assert!(eval_condition(
315 "state.__critic_0_verdict__.passed == true",
316 &json!({"__critic_0_verdict__": {"passed": true}}),
317 ));
318 }
319
320 #[test]
321 fn nested_bool_eq_false_value() {
322 assert!(!eval_condition(
323 "state.__critic_0_verdict__.passed == true",
324 &json!({"__critic_0_verdict__": {"passed": false}}),
325 ));
326 }
327
328 #[test]
329 fn nested_missing_parent() {
330 assert!(!eval_condition(
331 "state.__critic_0_verdict__.passed == true",
332 &json!({}),
333 ));
334 }
335
336 #[test]
339 fn null_eq_missing_path() {
340 assert!(eval_condition("state.missing_key == null", &json!({})));
342 }
343
344 #[test]
345 fn null_eq_explicit_null() {
346 assert!(eval_condition("state.x == null", &json!({"x": null}),));
347 }
348
349 #[test]
350 fn null_eq_non_null_is_false() {
351 assert!(!eval_condition(
352 "state.x == null",
353 &json!({"x": "something"}),
354 ));
355 }
356
357 #[test]
360 fn number_eq_true() {
361 assert!(eval_condition("state.count == 5", &json!({"count": 5})));
362 }
363
364 #[test]
365 fn number_eq_string_is_false() {
366 assert!(!eval_condition("state.count == 5", &json!({"count": "5"}),));
368 }
369
370 #[test]
373 fn bool_false_literal_match() {
374 assert!(eval_condition(
375 "state.flag == false",
376 &json!({"flag": false}),
377 ));
378 }
379
380 #[test]
383 fn malformed_garbage() {
384 assert!(!eval_condition("garbage", &json!({})));
385 }
386
387 #[test]
388 fn malformed_lhs_not_state() {
389 assert!(!eval_condition(r#"x == "y""#, &json!({"x": "y"})));
390 }
391
392 #[test]
393 fn malformed_empty_expr() {
394 assert!(!eval_condition("", &json!({})));
395 }
396
397 #[test]
398 fn malformed_just_state_dot() {
399 assert!(!eval_condition("state.", &json!({})));
400 }
401
402 #[test]
403 fn malformed_whitespace_only() {
404 assert!(!eval_condition(" ", &json!({})));
405 }
406
407 #[test]
410 fn unquoted_rhs_bareword_fail_closed() {
411 assert!(!eval_condition(
415 "state.status == done",
416 &json!({"status": "done"}),
417 ));
418 }
419
420 #[test]
421 fn quoted_rhs_string_still_works() {
422 assert!(eval_condition(
424 r#"state.status == "done""#,
425 &json!({"status": "done"}),
426 ));
427 }
428}