1use anyhow::Context;
4use serde::{Deserialize, Serialize};
5use std::path::{Path, PathBuf};
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
13#[serde(deny_unknown_fields)]
14pub struct LlmTrace {
15 pub model_name: String,
17 pub turns: Vec<TraceTurn>,
22 #[serde(default)]
24 pub expects: TraceExpects,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct TraceTurn {
30 pub user_input: String,
31 pub steps: Vec<TraceStep>,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct TraceStep {
37 pub response: TraceResponse,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
42#[serde(tag = "type")]
43pub enum TraceResponse {
44 #[serde(rename = "text")]
45 Text {
46 content: String,
47 #[serde(default)]
48 input_tokens: u64,
49 #[serde(default)]
50 output_tokens: u64,
51 },
52 #[serde(rename = "tool_calls")]
53 ToolCalls {
54 tool_calls: Vec<TraceToolCall>,
55 #[serde(default)]
56 input_tokens: u64,
57 #[serde(default)]
58 output_tokens: u64,
59 },
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct TraceToolCall {
65 pub id: String,
66 pub name: String,
67 pub arguments: serde_json::Value,
68}
69
70#[derive(Debug, Clone, Default, Serialize, Deserialize)]
76#[serde(deny_unknown_fields)]
77pub struct TraceExpects {
78 #[serde(default)]
80 pub response_contains: Vec<String>,
81 #[serde(default)]
83 pub response_not_contains: Vec<String>,
84 #[serde(default)]
86 pub tools_used: Vec<String>,
87 #[serde(default)]
89 pub tools_not_used: Vec<String>,
90 #[serde(default)]
92 pub max_tool_calls: Option<usize>,
93 #[serde(default)]
95 pub all_tools_succeeded: Option<bool>,
96 #[serde(default)]
98 pub response_matches: Vec<String>,
99 #[serde(default)]
101 pub min_tool_calls: Option<usize>,
102 #[serde(default)]
106 pub exact_tool_calls: Option<usize>,
107 #[serde(default)]
109 pub tool_arguments_contain: Vec<ToolPayloadExpect>,
110 #[serde(default)]
112 pub tool_results_contain: Vec<ToolPayloadExpect>,
113}
114
115#[derive(Debug, Clone, Default, Serialize, Deserialize)]
122#[serde(deny_unknown_fields)]
123pub struct ToolPayloadExpect {
124 pub tool: String,
126 pub needle: String,
128 #[serde(default)]
130 pub call_index: Option<usize>,
131}
132
133impl TraceExpects {
134 pub fn is_empty(&self) -> bool {
141 self.response_contains.is_empty()
142 && self.response_not_contains.is_empty()
143 && self.tools_used.is_empty()
144 && self.tools_not_used.is_empty()
145 && self.max_tool_calls.is_none()
146 && self.all_tools_succeeded.is_none()
147 && self.response_matches.is_empty()
148 && self.min_tool_calls.is_none()
149 && self.exact_tool_calls.is_none()
150 && self.tool_arguments_contain.is_empty()
151 && self.tool_results_contain.is_empty()
152 }
153
154 fn empty_entry_family(&self) -> Option<&'static str> {
165 [
166 ("response_contains", &self.response_contains),
167 ("response_not_contains", &self.response_not_contains),
168 ("tools_used", &self.tools_used),
169 ("tools_not_used", &self.tools_not_used),
170 ("response_matches", &self.response_matches),
171 ]
172 .into_iter()
173 .find(|(_, values)| values.iter().any(|value| value.is_empty()))
174 .map(|(name, _)| name)
175 }
176
177 fn empty_payload_field(&self) -> Option<&'static str> {
179 [
180 ("tool_arguments_contain", &self.tool_arguments_contain),
181 ("tool_results_contain", &self.tool_results_contain),
182 ]
183 .into_iter()
184 .find_map(|(family, entries)| {
185 entries.iter().find_map(|entry| {
186 if entry.tool.is_empty() {
187 Some(match family {
188 "tool_arguments_contain" => "tool_arguments_contain.tool",
189 _ => "tool_results_contain.tool",
190 })
191 } else if entry.needle.is_empty() {
192 Some(match family {
193 "tool_arguments_contain" => "tool_arguments_contain.needle",
194 _ => "tool_results_contain.needle",
195 })
196 } else {
197 None
198 }
199 })
200 })
201 }
202
203 fn invalid_tool_call_bounds(&self) -> Option<String> {
204 if self.min_tool_calls == Some(0) {
205 return Some("`min_tool_calls` must be at least 1".to_string());
206 }
207 if let (Some(min), Some(max)) = (self.min_tool_calls, self.max_tool_calls)
208 && min > max
209 {
210 return Some(format!(
211 "`min_tool_calls` ({min}) exceeds `max_tool_calls` ({max})"
212 ));
213 }
214 if let Some(exact) = self.exact_tool_calls {
215 if let Some(min) = self.min_tool_calls
216 && exact < min
217 {
218 return Some(format!(
219 "`exact_tool_calls` ({exact}) is below `min_tool_calls` ({min})"
220 ));
221 }
222 if let Some(max) = self.max_tool_calls
223 && exact > max
224 {
225 return Some(format!(
226 "`exact_tool_calls` ({exact}) exceeds `max_tool_calls` ({max})"
227 ));
228 }
229 }
230 None
231 }
232}
233
234impl LlmTrace {
235 pub fn from_file(path: &Path) -> anyhow::Result<Self> {
237 let content = std::fs::read_to_string(path)
238 .with_context(|| format!("reading trace fixture {}", path.display()))?;
239 let trace: LlmTrace = serde_json::from_str(&content)
240 .with_context(|| format!("parsing trace fixture {}", path.display()))?;
241 if trace.expects.is_empty() {
242 anyhow::bail!(
243 "trace fixture {} declares no effective expectation; a case that asserts \
244 nothing would pass vacuously and cannot certify the required regression gate",
245 path.display()
246 );
247 }
248 if let Some(family) = trace.expects.empty_entry_family() {
249 anyhow::bail!(
250 "trace fixture {} declares a zero-length entry in `{}`; an empty value asserts \
251 nothing and cannot certify the required regression gate",
252 path.display(),
253 family
254 );
255 }
256 if let Some(field) = trace.expects.empty_payload_field() {
257 anyhow::bail!(
258 "trace fixture {} declares an empty `{}` value; an empty tool or needle cannot \
259 certify the required regression gate",
260 path.display(),
261 field
262 );
263 }
264 if let Some(reason) = trace.expects.invalid_tool_call_bounds() {
265 anyhow::bail!(
266 "trace fixture {} declares invalid tool-call bounds: {}",
267 path.display(),
268 reason
269 );
270 }
271 if trace.turns.is_empty() {
272 anyhow::bail!(
273 "trace fixture {} declares no conversation turns; the replay would drive the \
274 agent zero times and grade a real-looking expectation such as \
275 `max_tool_calls: 0` against an empty run, certifying the required regression \
276 gate green without exercising any behavior",
277 path.display()
278 );
279 }
280 Ok(trace)
281 }
282}
283
284fn collect_fixture_paths(
292 entries: impl Iterator<Item = std::io::Result<PathBuf>>,
293 dir: &Path,
294) -> anyhow::Result<Vec<PathBuf>> {
295 let mut paths = Vec::new();
296 for entry in entries {
297 let path = entry.with_context(|| {
298 format!("reading an entry in eval suite directory {}", dir.display())
299 })?;
300 if path.extension().and_then(|e| e.to_str()) == Some("json") {
301 paths.push(path);
302 }
303 }
304 paths.sort();
305 Ok(paths)
306}
307
308pub fn load_suite(dir: &Path) -> anyhow::Result<Vec<(PathBuf, LlmTrace)>> {
310 let read = std::fs::read_dir(dir)
311 .with_context(|| format!("reading eval suite directory {}", dir.display()))?;
312
313 let paths = collect_fixture_paths(read.map(|entry| entry.map(|e| e.path())), dir)?;
314
315 let mut out = Vec::with_capacity(paths.len());
316 for path in paths {
317 let trace = LlmTrace::from_file(&path)?;
318 out.push((path, trace));
319 }
320 Ok(out)
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326
327 fn admissible_fixture(name: &str) -> String {
330 format!(
331 r#"{{"model_name":"{name}","turns":[{{"user_input":"hi","steps":[{{"response":{{"type":"text","content":"{name}"}}}}]}}],"expects":{{"response_contains":["{name}"]}}}}"#
332 )
333 }
334
335 #[test]
336 fn trace_response_text_variant_defaults_tokens_to_zero() {
337 let r: TraceResponse = serde_json::from_str(r#"{"type":"text","content":"hi"}"#).unwrap();
338 match r {
339 TraceResponse::Text {
340 content,
341 input_tokens,
342 output_tokens,
343 } => {
344 assert_eq!(content, "hi");
345 assert_eq!(input_tokens, 0);
346 assert_eq!(output_tokens, 0);
347 }
348 _ => panic!("expected Text variant"),
349 }
350 }
351
352 #[test]
353 fn trace_response_tool_calls_variant_parses() {
354 let j = r#"{"type":"tool_calls","tool_calls":[{"id":"1","name":"search","arguments":{"q":"x"}}],"input_tokens":5}"#;
355 let r: TraceResponse = serde_json::from_str(j).unwrap();
356 match r {
357 TraceResponse::ToolCalls {
358 tool_calls,
359 input_tokens,
360 output_tokens,
361 } => {
362 assert_eq!(tool_calls.len(), 1);
363 assert_eq!(tool_calls[0].id, "1");
364 assert_eq!(tool_calls[0].name, "search");
365 assert_eq!(input_tokens, 5);
366 assert_eq!(output_tokens, 0);
367 }
368 _ => panic!("expected ToolCalls variant"),
369 }
370 }
371
372 #[test]
373 fn llm_trace_uses_default_expects_when_omitted() {
374 let t: LlmTrace = serde_json::from_str(r#"{"model_name":"m","turns":[]}"#).unwrap();
375 assert_eq!(t.model_name, "m");
376 assert!(t.turns.is_empty());
377 assert!(t.expects.response_contains.is_empty());
378 assert!(t.expects.max_tool_calls.is_none());
379 assert!(t.expects.is_empty());
382 }
383
384 #[test]
385 fn from_file_reads_and_parses_trace() {
386 let path = std::env::temp_dir().join("zeroclaw_eval_case_from_file_test.json");
387 std::fs::write(&path, admissible_fixture("demo")).unwrap();
388 let t = LlmTrace::from_file(&path).unwrap();
389 assert_eq!(t.model_name, "demo");
390 assert_eq!(t.turns.len(), 1);
391 let _ = std::fs::remove_file(&path);
392 }
393
394 #[test]
395 fn from_file_rejects_a_fixture_with_omitted_expectations() {
396 let path = std::env::temp_dir().join("zeroclaw_eval_case_omitted_expects_test.json");
397 std::fs::write(&path, r#"{"model_name":"demo","turns":[]}"#).unwrap();
398
399 let err = LlmTrace::from_file(&path)
400 .expect_err("a fixture that asserts nothing must not load into a required gate");
401
402 let rendered = format!("{err:#}");
403 assert!(
404 rendered.contains("declares no effective expectation"),
405 "error must explain the vacuous-pass rejection, got: {rendered}"
406 );
407 let _ = std::fs::remove_file(&path);
408 }
409
410 #[test]
411 fn from_file_rejects_a_fixture_with_an_empty_expects_block() {
412 let path = std::env::temp_dir().join("zeroclaw_eval_case_empty_expects_test.json");
413 std::fs::write(&path, r#"{"model_name":"demo","turns":[],"expects":{}}"#).unwrap();
414
415 let err = LlmTrace::from_file(&path)
416 .expect_err("an explicitly empty expects block asserts nothing either");
417
418 assert!(
419 format!("{err:#}").contains("declares no effective expectation"),
420 "an empty `expects` object must be rejected like an omitted one"
421 );
422 let _ = std::fs::remove_file(&path);
423 }
424
425 #[test]
426 fn from_file_rejects_a_typoed_expectation_key() {
427 let path = std::env::temp_dir().join("zeroclaw_eval_case_typo_expects_test.json");
430 std::fs::write(
431 &path,
432 r#"{"model_name":"demo","turns":[],"expects":{"response_contain":["ok"]}}"#,
433 )
434 .unwrap();
435
436 let err = LlmTrace::from_file(&path)
437 .expect_err("a typoed expectation key must fail loudly, not degrade to a no-op");
438
439 let rendered = format!("{err:#}");
440 assert!(
441 rendered.contains("response_contain"),
442 "error must name the offending key, got: {rendered}"
443 );
444 let _ = std::fs::remove_file(&path);
445 }
446
447 #[test]
448 fn from_file_rejects_an_unknown_top_level_key() {
449 let path = std::env::temp_dir().join("zeroclaw_eval_case_typo_toplevel_test.json");
450 std::fs::write(
451 &path,
452 r#"{"model_name":"demo","turns":[],"expect":{"response_contains":["ok"]}}"#,
453 )
454 .unwrap();
455
456 let err = LlmTrace::from_file(&path)
457 .expect_err("a misspelled top-level key silently discards every expectation");
458
459 assert!(
460 format!("{err:#}").contains("expect"),
461 "error must name the offending top-level key"
462 );
463 let _ = std::fs::remove_file(&path);
464 }
465
466 #[test]
467 fn trace_expects_is_empty_is_false_for_each_individual_expectation() {
468 let cases = [
471 r#"{"response_contains":["a"]}"#,
472 r#"{"response_not_contains":["a"]}"#,
473 r#"{"tools_used":["echo"]}"#,
474 r#"{"tools_not_used":["echo"]}"#,
475 r#"{"max_tool_calls":0}"#,
476 r#"{"all_tools_succeeded":false}"#,
477 r#"{"response_matches":["^a"]}"#,
478 r#"{"min_tool_calls":1}"#,
479 r#"{"exact_tool_calls":0}"#,
480 r#"{"tool_arguments_contain":[{"tool":"echo","needle":"alpha"}]}"#,
481 r#"{"tool_results_contain":[{"tool":"echo","needle":"alpha"}]}"#,
482 ];
483 for raw in cases {
484 let expects: TraceExpects = serde_json::from_str(raw).unwrap();
485 assert!(
486 !expects.is_empty(),
487 "{raw} declares a real assertion and must not count as empty"
488 );
489 }
490 let zero: TraceExpects = serde_json::from_str(r#"{"max_tool_calls":0}"#).unwrap();
493 assert_eq!(zero.max_tool_calls, Some(0));
494 }
495
496 #[test]
497 fn from_file_rejects_a_zero_length_entry_in_every_string_backed_family() {
498 let cases = [
503 ("response_contains", r#"{"response_contains":[""]}"#),
504 ("response_matches", r#"{"response_matches":[""]}"#),
505 ("tools_not_used", r#"{"tools_not_used":[""]}"#),
506 ("response_not_contains", r#"{"response_not_contains":[""]}"#),
507 ("tools_used", r#"{"tools_used":[""]}"#),
508 ];
509
510 for (family, expects) in cases {
511 let path = std::env::temp_dir()
512 .join(format!("zeroclaw_eval_case_empty_entry_{family}_test.json"));
513 std::fs::write(
514 &path,
515 format!(r#"{{"model_name":"demo","turns":[],"expects":{expects}}}"#),
516 )
517 .unwrap();
518
519 let err = LlmTrace::from_file(&path)
520 .expect_err("a zero-length entry must not load into a required gate");
521 let rendered = format!("{err:#}");
522 assert!(
523 rendered.contains("declares a zero-length entry"),
524 "error must explain the zero-length rejection, got: {rendered}"
525 );
526 assert!(
527 rendered.contains(family),
528 "error must name the offending family {family}, got: {rendered}"
529 );
530 let _ = std::fs::remove_file(&path);
531 }
532 }
533
534 #[test]
535 fn from_file_rejects_a_fixture_with_no_conversation_turns() {
536 let path = std::env::temp_dir().join("zeroclaw_eval_case_zero_turns_test.json");
541 std::fs::write(
542 &path,
543 r#"{"model_name":"empty-turns","turns":[],"expects":{"max_tool_calls":0}}"#,
544 )
545 .unwrap();
546
547 let err = LlmTrace::from_file(&path)
548 .expect_err("a fixture that replays no turn must not load into a required gate");
549
550 let rendered = format!("{err:#}");
551 assert!(
552 rendered.contains("declares no conversation turns"),
553 "error must explain the zero-turn rejection, got: {rendered}"
554 );
555 assert!(
556 rendered.contains("zeroclaw_eval_case_zero_turns_test.json"),
557 "error must name the offending fixture, got: {rendered}"
558 );
559 let _ = std::fs::remove_file(&path);
560 }
561
562 #[test]
563 fn from_file_admits_a_single_turn_fixture() {
564 let path = std::env::temp_dir().join("zeroclaw_eval_case_single_turn_test.json");
567 std::fs::write(&path, admissible_fixture("single-turn")).unwrap();
568
569 let trace = LlmTrace::from_file(&path).expect("one turn is a real replay");
570
571 assert_eq!(trace.turns.len(), 1);
572 assert_eq!(trace.turns[0].user_input, "hi");
573 let _ = std::fs::remove_file(&path);
574 }
575
576 #[test]
577 fn trace_expects_keeps_non_empty_entries_admissible() {
578 let expects: TraceExpects = serde_json::from_str(
579 r#"{"response_contains":["ok"],"tools_not_used":["shell"],"response_matches":["^o"]}"#,
580 )
581 .unwrap();
582 assert!(expects.empty_entry_family().is_none());
583 }
584
585 #[test]
586 fn from_file_rejects_empty_dispatch_payload_fields() {
587 let cases = [
588 (
589 "tool_arguments_contain.tool",
590 r#"{"tool_arguments_contain":[{"tool":"","needle":"alpha"}]}"#,
591 ),
592 (
593 "tool_arguments_contain.needle",
594 r#"{"tool_arguments_contain":[{"tool":"echo","needle":""}]}"#,
595 ),
596 (
597 "tool_results_contain.tool",
598 r#"{"tool_results_contain":[{"tool":"","needle":"alpha"}]}"#,
599 ),
600 (
601 "tool_results_contain.needle",
602 r#"{"tool_results_contain":[{"tool":"echo","needle":""}]}"#,
603 ),
604 ];
605
606 for (field, expects) in cases {
607 let path = std::env::temp_dir().join(format!(
608 "zeroclaw_eval_case_empty_payload_{}_test.json",
609 field.replace('.', "_")
610 ));
611 std::fs::write(
612 &path,
613 format!(r#"{{"model_name":"demo","turns":[],"expects":{expects}}}"#),
614 )
615 .unwrap();
616 let err = LlmTrace::from_file(&path).expect_err("empty payload fields must fail");
617 assert!(
618 format!("{err:#}").contains(field),
619 "error must name {field}: {err:#}"
620 );
621 let _ = std::fs::remove_file(path);
622 }
623 }
624
625 #[test]
626 fn from_file_rejects_unknown_dispatch_expectation_fields() {
627 let path = std::env::temp_dir().join("zeroclaw_eval_case_payload_typo_test.json");
628 std::fs::write(
629 &path,
630 r#"{"model_name":"demo","turns":[],"expects":{"tool_arguments_contain":[{"tool":"echo","nedle":"alpha"}]}}"#,
631 )
632 .unwrap();
633 let err = LlmTrace::from_file(&path).expect_err("nested expectation typos must fail");
634 assert!(format!("{err:#}").contains("nedle"));
635 let _ = std::fs::remove_file(path);
636 }
637
638 #[test]
639 fn from_file_rejects_vacuous_or_contradictory_tool_call_bounds() {
640 let cases = [
641 ("min_tool_calls", r#"{"min_tool_calls":0}"#),
642 ("exceeds", r#"{"min_tool_calls":2,"max_tool_calls":1}"#),
643 ("below", r#"{"min_tool_calls":2,"exact_tool_calls":1}"#),
644 ("exceeds", r#"{"max_tool_calls":1,"exact_tool_calls":2}"#),
645 ];
646
647 for (reason, expects) in cases {
648 let path = std::env::temp_dir().join(format!(
649 "zeroclaw_eval_case_invalid_bounds_{}_test.json",
650 reason
651 ));
652 std::fs::write(
653 &path,
654 format!(r#"{{"model_name":"demo","turns":[],"expects":{expects}}}"#),
655 )
656 .unwrap();
657 let err = LlmTrace::from_file(&path).expect_err("invalid bounds must fail");
658 assert!(
659 format!("{err:#}").contains(reason),
660 "error must explain {reason}: {err:#}"
661 );
662 let _ = std::fs::remove_file(path);
663 }
664 }
665
666 #[test]
667 fn load_suite_filters_json_and_sorts_by_path() {
668 let dir = std::env::temp_dir().join("zeroclaw_eval_case_suite_test");
669 let _ = std::fs::remove_dir_all(&dir);
670 std::fs::create_dir_all(&dir).unwrap();
671 std::fs::write(dir.join("b.json"), admissible_fixture("b")).unwrap();
672 std::fs::write(dir.join("a.json"), admissible_fixture("a")).unwrap();
673 std::fs::write(dir.join("note.txt"), "ignored").unwrap();
674 let suite = load_suite(&dir).unwrap();
675 assert_eq!(suite.len(), 2); assert_eq!(suite[0].1.model_name, "a"); assert_eq!(suite[1].1.model_name, "b");
678 let _ = std::fs::remove_dir_all(&dir);
679 }
680
681 #[test]
682 fn collect_fixture_paths_propagates_entry_errors() {
683 let dir = Path::new("/eval/suite");
684 let entries = vec![
685 Ok(dir.join("a.json")),
686 Err(std::io::Error::new(
687 std::io::ErrorKind::PermissionDenied,
688 "entry unreadable",
689 )),
690 Ok(dir.join("b.json")),
691 ];
692
693 let err = collect_fixture_paths(entries.into_iter(), dir)
694 .expect_err("an unreadable entry must abort the suite, not shrink it");
695
696 let rendered = format!("{err:#}");
697 assert!(
698 rendered.contains("reading an entry in eval suite directory /eval/suite"),
699 "error must name the suite directory, got: {rendered}"
700 );
701 assert!(
702 rendered.contains("entry unreadable"),
703 "error must preserve the underlying io cause, got: {rendered}"
704 );
705 }
706
707 #[test]
708 fn collect_fixture_paths_keeps_only_sorted_json_on_the_happy_path() {
709 let dir = Path::new("/eval/suite");
710 let entries = vec![
711 Ok(dir.join("b.json")),
712 Ok(dir.join("note.txt")),
713 Ok(dir.join("a.json")),
714 ];
715
716 let paths = collect_fixture_paths(entries.into_iter(), dir).unwrap();
717
718 assert_eq!(paths, vec![dir.join("a.json"), dir.join("b.json")]);
719 }
720}