Skip to main content

ravenclaws/
eval.rs

1//! RavenClaws
2//!
3//! Provides a framework for defining, running, and scoring evaluation tasks
4//! against LLM agents. Captures full run traces for inspection and debugging.
5//!
6//! # Architecture
7//!
8//! ```text
9//! EvalConfig (TOML file)
10//!   └── Vec<EvalTask>
11//!         ├── prompt + golden answer
12//!         ├── assertions (contains, not_contains, regex, exact)
13//!         └── scoring weights
14//!
15//! EvalRunner
16//!   ├── run_task() → EvalResult (with RunTrace)
17//!   └── run_suite() → EvalReport (summary of all results)
18//!
19//! RunTrace
20//!   ├── steps: Vec<TraceStep>
21//!   ├── llm_calls: Vec<LlmCallTrace>
22//!   └── tool_calls: Vec<ToolCallTrace>
23//! ```
24
25use crate::agent::{run_agent_loop, AgentLoopConfig};
26use crate::error::{RavenClawsError, Result};
27use crate::llm::LLMProviderTrait;
28use serde::{Deserialize, Serialize};
29use std::sync::Arc;
30use tracing::{info, instrument, warn};
31
32// ── Configuration ───────────────────────────────────────────────────────────
33
34/// Configuration for an eval suite — loaded from a TOML file
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct EvalConfig {
37    /// Name of this eval suite
38    #[serde(default = "default_suite_name")]
39    pub name: String,
40    /// Description of what this suite tests
41    #[serde(default)]
42    pub description: String,
43    /// System prompt to use for all tasks in this suite
44    #[serde(default = "default_system_prompt")]
45    pub system_prompt: String,
46    /// Maximum iterations per task
47    #[serde(default = "default_max_iterations")]
48    pub max_iterations: usize,
49    /// List of eval tasks to run
50    #[serde(default)]
51    pub tasks: Vec<EvalTask>,
52}
53
54fn default_suite_name() -> String {
55    "unnamed".to_string()
56}
57
58fn default_system_prompt() -> String {
59    "You are a helpful assistant. Be concise and accurate.".to_string()
60}
61
62fn default_max_iterations() -> usize {
63    5
64}
65
66/// A single eval task with prompt, golden answer, and assertions
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct EvalTask {
69    /// Name of this task (used in reports)
70    pub name: String,
71    /// Description of what this task tests
72    #[serde(default)]
73    pub description: String,
74    /// The prompt to send to the agent
75    pub prompt: String,
76    /// Expected golden answer (used for exact match scoring)
77    #[serde(default)]
78    pub golden: String,
79    /// List of assertions to check against the response
80    #[serde(default)]
81    pub assertions: Vec<Assertion>,
82    /// Weight of this task in the overall score (0.0 - 1.0)
83    #[serde(default = "default_weight")]
84    pub weight: f64,
85    /// Whether this task is required to pass (fails the suite if not)
86    #[serde(default)]
87    pub required: bool,
88}
89
90fn default_weight() -> f64 {
91    1.0
92}
93
94/// Types of assertions that can be checked against a response
95#[derive(Debug, Clone, Serialize, Deserialize)]
96#[serde(tag = "type", content = "value")]
97pub enum Assertion {
98    /// Response must contain this substring
99    #[serde(rename = "contains")]
100    Contains(String),
101    /// Response must NOT contain this substring
102    #[serde(rename = "not_contains")]
103    NotContains(String),
104    /// Response must exactly match this string
105    #[serde(rename = "exact")]
106    Exact(String),
107    /// Response must match this regex pattern
108    #[serde(rename = "regex")]
109    Regex(String),
110    /// Response must be non-empty
111    #[serde(rename = "non_empty")]
112    NonEmpty,
113    /// Response length must be at least N characters
114    #[serde(rename = "min_length")]
115    MinLength(usize),
116    /// Response length must be at most N characters
117    #[serde(rename = "max_length")]
118    MaxLength(usize),
119    /// A tool with this name must have been called during execution (v0.9.6)
120    #[serde(rename = "tool_called")]
121    ToolCalled(String),
122    /// A tool with this name must NOT have been called during execution (v0.9.6)
123    #[serde(rename = "tool_not_called")]
124    ToolNotCalled(String),
125}
126
127// ── Run Trace ───────────────────────────────────────────────────────────────
128
129/// Full trace of a single agent run — captures every step for inspection
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct RunTrace {
132    /// Task name
133    pub task_name: String,
134    /// When the run started (ISO 8601)
135    pub started_at: String,
136    /// When the run ended (ISO 8601)
137    pub ended_at: String,
138    /// Duration in milliseconds
139    pub duration_ms: u64,
140    /// Number of iterations used
141    pub iterations: usize,
142    /// All steps in chronological order
143    pub steps: Vec<TraceStep>,
144    /// LLM calls made during the run
145    pub llm_calls: Vec<LlmCallTrace>,
146    /// Tool calls made during the run
147    pub tool_calls: Vec<ToolCallTrace>,
148    /// Final response from the agent
149    pub final_response: String,
150}
151
152/// A single step in the agent loop
153#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct TraceStep {
155    /// Step number (0-based)
156    pub number: usize,
157    /// Type of step
158    pub step_type: StepType,
159    /// Content of the step (LLM response, tool result, etc.)
160    pub content: String,
161    /// Duration of this step in milliseconds
162    pub duration_ms: u64,
163}
164
165/// Type of a trace step
166#[derive(Debug, Clone, Serialize, Deserialize)]
167pub enum StepType {
168    /// LLM thought/response
169    Thought,
170    /// Tool call
171    ToolCall,
172    /// Tool result/observation
173    Observation,
174    /// Final answer
175    Final,
176    /// Error
177    Error,
178}
179
180/// Trace of a single LLM call
181#[derive(Debug, Clone, Serialize, Deserialize)]
182pub struct LlmCallTrace {
183    /// Iteration number
184    pub iteration: usize,
185    /// Provider name
186    pub provider: String,
187    /// Model name
188    pub model: String,
189    /// Prompt tokens (if available)
190    pub prompt_tokens: Option<u32>,
191    /// Completion tokens (if available)
192    pub completion_tokens: Option<u32>,
193    /// Duration in milliseconds
194    pub duration_ms: u64,
195    /// Response content (truncated to 1000 chars for storage)
196    pub response_preview: String,
197}
198
199/// Trace of a single tool call
200#[derive(Debug, Clone, Serialize, Deserialize)]
201pub struct ToolCallTrace {
202    /// Iteration number
203    pub iteration: usize,
204    /// Tool name
205    pub tool_name: String,
206    /// Arguments (JSON)
207    pub arguments: serde_json::Value,
208    /// Whether the tool succeeded
209    pub success: bool,
210    /// Output preview (truncated to 500 chars)
211    pub output_preview: String,
212    /// Duration in milliseconds
213    pub duration_ms: u64,
214}
215
216// ── Results ─────────────────────────────────────────────────────────────────
217
218/// Result of a single eval task
219#[derive(Debug, Clone, Serialize, Deserialize)]
220pub struct EvalResult {
221    /// Task name
222    pub task_name: String,
223    /// Whether the task passed all assertions
224    pub passed: bool,
225    /// Score (0.0 - 1.0)
226    pub score: f64,
227    /// Number of assertions that passed
228    pub assertions_passed: usize,
229    /// Number of assertions that failed
230    pub assertions_failed: usize,
231    /// Details of each assertion check
232    pub assertion_results: Vec<AssertionResult>,
233    /// Full run trace for inspection
234    pub trace: RunTrace,
235    /// Error message if the task failed to run
236    pub error: Option<String>,
237}
238
239/// Result of a single assertion check
240#[derive(Debug, Clone, Serialize, Deserialize)]
241pub struct AssertionResult {
242    /// The assertion that was checked
243    pub assertion: String,
244    /// Whether it passed
245    pub passed: bool,
246    /// Details about the check
247    pub details: String,
248}
249
250/// Summary report of an entire eval suite run
251#[derive(Debug, Clone, Serialize, Deserialize)]
252pub struct EvalReport {
253    /// Suite name
254    pub suite_name: String,
255    /// When the suite was run (ISO 8601)
256    pub ran_at: String,
257    /// Duration in milliseconds
258    pub duration_ms: u64,
259    /// Overall score (0.0 - 1.0)
260    pub overall_score: f64,
261    /// Number of tasks
262    pub total_tasks: usize,
263    /// Number of tasks that passed
264    pub passed_tasks: usize,
265    /// Number of tasks that failed
266    pub failed_tasks: usize,
267    /// Individual task results
268    pub results: Vec<EvalResult>,
269}
270
271// ── Eval Runner ─────────────────────────────────────────────────────────────
272
273/// Runs eval tasks against an LLM provider and captures traces
274pub struct EvalRunner {
275    /// The LLM provider to test
276    llm: Arc<dyn LLMProviderTrait>,
277    /// Eval configuration
278    config: EvalConfig,
279}
280
281impl EvalRunner {
282    /// Create a new eval runner
283    pub fn new(llm: Arc<dyn LLMProviderTrait>, config: EvalConfig) -> Self {
284        Self { llm, config }
285    }
286
287    /// Run the full eval suite and return a report
288    #[instrument(skip(self), fields(suite = %self.config.name, task_count = self.config.tasks.len()))]
289    pub async fn run_suite(&self) -> EvalReport {
290        let started_at = chrono::Utc::now().to_rfc3339();
291        let suite_start = std::time::Instant::now();
292        let mut results = Vec::with_capacity(self.config.tasks.len());
293
294        info!(
295            suite = %self.config.name,
296            task_count = self.config.tasks.len(),
297            "Starting eval suite"
298        );
299
300        for task in &self.config.tasks {
301            let result = self.run_task(task).await;
302            let passed = result.passed;
303            let name = &result.task_name;
304
305            if passed {
306                info!(task = %name, score = result.score, "Eval task passed");
307            } else {
308                warn!(
309                    task = %name,
310                    score = result.score,
311                    passed = result.assertions_passed,
312                    failed = result.assertions_failed,
313                    "Eval task failed"
314                );
315            }
316
317            results.push(result);
318        }
319
320        let duration_ms = suite_start.elapsed().as_millis() as u64;
321        let total_tasks = results.len();
322        let passed_tasks = results.iter().filter(|r| r.passed).count();
323        let failed_tasks = total_tasks - passed_tasks;
324        let overall_score = if total_tasks > 0 {
325            results
326                .iter()
327                .map(|r| r.score * r.trace.iterations as f64)
328                .sum::<f64>()
329                / results
330                    .iter()
331                    .map(|r| r.trace.iterations as f64)
332                    .sum::<f64>()
333        } else {
334            0.0
335        };
336
337        info!(
338            suite = %self.config.name,
339            passed = passed_tasks,
340            failed = failed_tasks,
341            overall_score = overall_score,
342            duration_ms = duration_ms,
343            "Eval suite completed"
344        );
345
346        EvalReport {
347            suite_name: self.config.name.clone(),
348            ran_at: started_at,
349            duration_ms,
350            overall_score,
351            total_tasks,
352            passed_tasks,
353            failed_tasks,
354            results,
355        }
356    }
357
358    /// Run a single eval task and return the result with trace
359    ///
360    /// Uses the full agent loop (`run_agent_loop`) instead of a single LLM call,
361    /// so eval tasks exercise the complete ReAct loop with tool use, security
362    /// checks, and iteration limits.
363    #[instrument(skip(self), fields(task = %task.name))]
364    async fn run_task(&self, task: &EvalTask) -> EvalResult {
365        let task_start = std::time::Instant::now();
366        let started_at = chrono::Utc::now().to_rfc3339();
367
368        // Build agent loop config from suite settings
369        let agent_config = AgentLoopConfig {
370            max_iterations: self.config.max_iterations,
371            enable_tools: true,
372            require_approval: false,
373            prompt_injection_protection: true,
374            token_lifetime_secs: 0,
375            no_final_required: false,
376            fallback_chain: None,
377            token_budget: None,
378            ravenfabric: None,
379            checkpoint_dir: None,
380            session_id: None,
381            metrics_callback: None,
382            load_manager: None,
383            retry_config: None,
384            healing_engine: None,
385        };
386
387        // Run the full agent loop (ReAct + tools + security)
388        let result = run_agent_loop(
389            self.llm.clone(),
390            &task.prompt,
391            &self.config.system_prompt,
392            agent_config,
393        )
394        .await;
395
396        let duration_ms = task_start.elapsed().as_millis() as u64;
397
398        match result {
399            Ok(final_response) => {
400                let trace = RunTrace {
401                    task_name: task.name.clone(),
402                    started_at,
403                    ended_at: chrono::Utc::now().to_rfc3339(),
404                    duration_ms,
405                    iterations: self.config.max_iterations, // best-effort; agent loop doesn't expose exact count
406                    steps: vec![TraceStep {
407                        number: 0,
408                        step_type: StepType::Final,
409                        content: final_response.clone(),
410                        duration_ms,
411                    }],
412                    llm_calls: Vec::new(), // agent loop doesn't expose per-call traces
413                    tool_calls: Vec::new(), // agent loop doesn't expose per-call traces
414                    final_response: final_response.clone(),
415                };
416
417                // Run assertions against the final response
418                let (assertion_results, assertions_passed, assertions_failed) =
419                    check_assertions(&final_response, &task.assertions, Some(&trace));
420
421                // Calculate score
422                let score = if task.assertions.is_empty() {
423                    if final_response.is_empty() || final_response.len() < 10 {
424                        0.0
425                    } else {
426                        1.0
427                    }
428                } else if task.assertions.len() == assertions_passed + assertions_failed {
429                    assertions_passed as f64 / task.assertions.len() as f64
430                } else {
431                    0.0
432                };
433
434                let passed = assertions_failed == 0 && !final_response.is_empty();
435
436                EvalResult {
437                    task_name: task.name.clone(),
438                    passed,
439                    score,
440                    assertions_passed,
441                    assertions_failed,
442                    assertion_results,
443                    trace,
444                    error: None,
445                }
446            }
447            Err(e) => {
448                let trace = RunTrace {
449                    task_name: task.name.clone(),
450                    started_at,
451                    ended_at: chrono::Utc::now().to_rfc3339(),
452                    duration_ms,
453                    iterations: 0,
454                    steps: vec![TraceStep {
455                        number: 0,
456                        step_type: StepType::Error,
457                        content: format!("Agent loop failed: {}", e),
458                        duration_ms,
459                    }],
460                    llm_calls: Vec::new(),
461                    tool_calls: Vec::new(),
462                    final_response: String::new(),
463                };
464
465                EvalResult {
466                    task_name: task.name.clone(),
467                    passed: false,
468                    score: 0.0,
469                    assertions_passed: 0,
470                    assertions_failed: 1,
471                    assertion_results: vec![AssertionResult {
472                        assertion: "agent_loop".to_string(),
473                        passed: false,
474                        details: format!("Agent loop failed: {}", e),
475                    }],
476                    trace,
477                    error: Some(e.to_string()),
478                }
479            }
480        }
481    }
482}
483
484// ── Assertion Checking ──────────────────────────────────────────────────────
485
486/// Check all assertions against a response string
487fn check_assertions(
488    response: &str,
489    assertions: &[Assertion],
490    run_trace: Option<&RunTrace>,
491) -> (Vec<AssertionResult>, usize, usize) {
492    let mut results = Vec::with_capacity(assertions.len());
493    let mut passed = 0;
494    let mut failed = 0;
495
496    for assertion in assertions {
497        let result = check_single_assertion(response, assertion, run_trace);
498        if result.passed {
499            passed += 1;
500        } else {
501            failed += 1;
502        }
503        results.push(result);
504    }
505
506    (results, passed, failed)
507}
508
509/// Check a single assertion against a response
510fn check_single_assertion(
511    response: &str,
512    assertion: &Assertion,
513    run_trace: Option<&RunTrace>,
514) -> AssertionResult {
515    match assertion {
516        Assertion::Contains(pattern) => {
517            let passed = response.contains(pattern);
518            AssertionResult {
519                assertion: format!("contains: {}", pattern),
520                passed,
521                details: if passed {
522                    format!("Response contains '{}'", pattern)
523                } else {
524                    format!("Response does not contain '{}'", pattern)
525                },
526            }
527        }
528        Assertion::NotContains(pattern) => {
529            let passed = !response.contains(pattern);
530            AssertionResult {
531                assertion: format!("not_contains: {}", pattern),
532                passed,
533                details: if passed {
534                    format!("Response does not contain '{}'", pattern)
535                } else {
536                    format!("Response contains '{}'", pattern)
537                },
538            }
539        }
540        Assertion::Exact(expected) => {
541            let trimmed_response = response.trim();
542            let passed = trimmed_response == expected.as_str();
543            AssertionResult {
544                assertion: format!("exact: {}", expected),
545                passed,
546                details: if passed {
547                    "Response matches exactly".to_string()
548                } else {
549                    format!(
550                        "Expected '{}', got '{}'",
551                        expected,
552                        trimmed_response.chars().take(100).collect::<String>()
553                    )
554                },
555            }
556        }
557        Assertion::Regex(pattern) => {
558            let re = regex_lite::Regex::new(pattern);
559            match re {
560                Ok(re) => {
561                    let passed = re.is_match(response);
562                    AssertionResult {
563                        assertion: format!("regex: {}", pattern),
564                        passed,
565                        details: if passed {
566                            format!("Response matches pattern '{}'", pattern)
567                        } else {
568                            format!("Response does not match pattern '{}'", pattern)
569                        },
570                    }
571                }
572                Err(e) => AssertionResult {
573                    assertion: format!("regex: {}", pattern),
574                    passed: false,
575                    details: format!("Invalid regex pattern: {}", e),
576                },
577            }
578        }
579        Assertion::NonEmpty => {
580            let passed = !response.is_empty();
581            AssertionResult {
582                assertion: "non_empty".to_string(),
583                passed,
584                details: if passed {
585                    format!("Response is non-empty ({} chars)", response.len())
586                } else {
587                    "Response is empty".to_string()
588                },
589            }
590        }
591        Assertion::MinLength(min) => {
592            let passed = response.len() >= *min;
593            AssertionResult {
594                assertion: format!("min_length: {}", min),
595                passed,
596                details: if passed {
597                    format!("Response length {} >= {}", response.len(), min)
598                } else {
599                    format!("Response length {} < {}", response.len(), min)
600                },
601            }
602        }
603        Assertion::MaxLength(max) => {
604            let passed = response.len() <= *max;
605            AssertionResult {
606                assertion: format!("max_length: {}", max),
607                passed,
608                details: if passed {
609                    format!("Response length {} <= {}", response.len(), max)
610                } else {
611                    format!("Response length {} > {}", response.len(), max)
612                },
613            }
614        }
615        Assertion::ToolCalled(tool_name) => {
616            let tool_calls = run_trace
617                .map(|t| &t.tool_calls)
618                .filter(|calls| calls.iter().any(|tc| tc.tool_name == *tool_name));
619            let passed = tool_calls.is_some();
620            AssertionResult {
621                assertion: format!("tool_called: {}", tool_name),
622                passed,
623                details: if passed {
624                    format!("Tool '{}' was called", tool_name)
625                } else {
626                    let all_tools: Vec<&str> = run_trace
627                        .map(|t| {
628                            t.tool_calls
629                                .iter()
630                                .map(|tc| tc.tool_name.as_str())
631                                .collect()
632                        })
633                        .unwrap_or_default();
634                    if all_tools.is_empty() {
635                        format!("Tool '{}' was not called (no tools were called)", tool_name)
636                    } else {
637                        format!(
638                            "Tool '{}' was not called (called: {})",
639                            tool_name,
640                            all_tools.join(", ")
641                        )
642                    }
643                },
644            }
645        }
646        Assertion::ToolNotCalled(tool_name) => {
647            let tool_calls = run_trace
648                .map(|t| &t.tool_calls)
649                .filter(|calls| calls.iter().any(|tc| tc.tool_name == *tool_name));
650            let passed = tool_calls.is_none();
651            AssertionResult {
652                assertion: format!("tool_not_called: {}", tool_name),
653                passed,
654                details: if passed {
655                    format!("Tool '{}' was not called", tool_name)
656                } else {
657                    format!("Tool '{}' was called but should not have been", tool_name)
658                },
659            }
660        }
661    }
662}
663
664// ── Report Formatting ───────────────────────────────────────────────────────
665
666impl EvalReport {
667    /// Format the report as a human-readable string
668    pub fn format_text(&self) -> String {
669        let mut output = String::new();
670
671        output.push_str(&format!("\n🐦‍⬛ Eval Report: {}\n", self.suite_name));
672        output.push_str(&format!("{:-^60}\n", ""));
673        output.push_str(&format!(
674            "Ran at:       {}\n",
675            self.ran_at[..19].replace('T', " ")
676        ));
677        output.push_str(&format!("Duration:     {} ms\n", self.duration_ms));
678        output.push_str(&format!(
679            "Overall score: {:.1}%\n",
680            self.overall_score * 100.0
681        ));
682        output.push_str(&format!(
683            "Tasks:        {}/{} passed\n",
684            self.passed_tasks, self.total_tasks
685        ));
686        output.push_str(&format!("{:-^60}\n", ""));
687
688        for result in &self.results {
689            output.push_str(&format!(
690                "\n  {} {} — {:.1}%\n",
691                if result.passed { "✅" } else { "❌" },
692                result.task_name,
693                result.score * 100.0
694            ));
695
696            if let Some(ref error) = result.error {
697                output.push_str(&format!("    Error: {}\n", error));
698            }
699
700            if !result.assertion_results.is_empty() {
701                for ar in &result.assertion_results {
702                    output.push_str(&format!(
703                        "    {} {}\n",
704                        if ar.passed { "  ✅" } else { "  ❌" },
705                        ar.details
706                    ));
707                }
708            }
709
710            // Show trace summary
711            let trace = &result.trace;
712            output.push_str(&format!(
713                "    Iterations: {} · LLM calls: {} · Tool calls: {} · Duration: {} ms\n",
714                trace.iterations,
715                trace.llm_calls.len(),
716                trace.tool_calls.len(),
717                trace.duration_ms
718            ));
719
720            // Show response preview
721            let preview: String = trace.final_response.chars().take(200).collect();
722            if !preview.is_empty() {
723                output.push_str(&format!("    Response: {}\n", preview));
724            }
725        }
726
727        output
728    }
729
730    /// Format the report as JSON
731    pub fn format_json(&self) -> serde_json::Value {
732        serde_json::to_value(self).unwrap_or(serde_json::json!({"error": "serialization failed"}))
733    }
734}
735
736// ── Config Loading ──────────────────────────────────────────────────────────
737
738impl EvalConfig {
739    /// Load eval config from a TOML file
740    pub fn from_file(path: &str) -> Result<Self> {
741        let content = std::fs::read_to_string(path).map_err(|e| {
742            RavenClawsError::CommandExecution(format!("Failed to read eval config: {}", e))
743        })?;
744
745        if content.trim().is_empty() {
746            return Err(RavenClawsError::CommandExecution(format!(
747                "Eval config file '{}' is empty — no tasks to run",
748                path
749            )));
750        }
751
752        let config: EvalConfig = toml::from_str(&content).map_err(|e| {
753            RavenClawsError::CommandExecution(format!("Failed to parse eval config: {}", e))
754        })?;
755
756        if config.tasks.is_empty() {
757            return Err(RavenClawsError::CommandExecution(format!(
758                "Eval config file '{}' has no tasks defined",
759                path
760            )));
761        }
762
763        Ok(config)
764    }
765}
766
767// ── Tests ───────────────────────────────────────────────────────────────────
768
769#[cfg(test)]
770mod tests {
771    use super::*;
772
773    #[test]
774    fn test_assertion_contains_pass() {
775        let result = check_single_assertion(
776            "hello world",
777            &Assertion::Contains("world".to_string()),
778            None,
779        );
780        assert!(result.passed);
781        assert!(result.details.contains("contains"));
782    }
783
784    #[test]
785    fn test_assertion_contains_fail() {
786        let result =
787            check_single_assertion("hello world", &Assertion::Contains("foo".to_string()), None);
788        assert!(!result.passed);
789    }
790
791    #[test]
792    fn test_assertion_not_contains_pass() {
793        let result = check_single_assertion(
794            "hello world",
795            &Assertion::NotContains("foo".to_string()),
796            None,
797        );
798        assert!(result.passed);
799    }
800
801    #[test]
802    fn test_assertion_not_contains_fail() {
803        let result = check_single_assertion(
804            "hello world",
805            &Assertion::NotContains("world".to_string()),
806            None,
807        );
808        assert!(!result.passed);
809    }
810
811    #[test]
812    fn test_assertion_exact_pass() {
813        let result = check_single_assertion("hello", &Assertion::Exact("hello".to_string()), None);
814        assert!(result.passed);
815    }
816
817    #[test]
818    fn test_assertion_exact_fail() {
819        let result = check_single_assertion("world", &Assertion::Exact("hello".to_string()), None);
820        assert!(!result.passed);
821    }
822
823    #[test]
824    fn test_assertion_regex_pass() {
825        let result =
826            check_single_assertion("hello 123", &Assertion::Regex(r"\d+".to_string()), None);
827        assert!(result.passed);
828    }
829
830    #[test]
831    fn test_assertion_regex_fail() {
832        let result = check_single_assertion("hello", &Assertion::Regex(r"\d+".to_string()), None);
833        assert!(!result.passed);
834    }
835
836    #[test]
837    fn test_assertion_non_empty_pass() {
838        let result = check_single_assertion("hello", &Assertion::NonEmpty, None);
839        assert!(result.passed);
840    }
841
842    #[test]
843    fn test_assertion_non_empty_fail() {
844        let result = check_single_assertion("", &Assertion::NonEmpty, None);
845        assert!(!result.passed);
846    }
847
848    #[test]
849    fn test_assertion_min_length_pass() {
850        let result = check_single_assertion("hello", &Assertion::MinLength(3), None);
851        assert!(result.passed);
852    }
853
854    #[test]
855    fn test_assertion_min_length_fail() {
856        let result = check_single_assertion("hi", &Assertion::MinLength(5), None);
857        assert!(!result.passed);
858    }
859
860    #[test]
861    fn test_assertion_max_length_pass() {
862        let result = check_single_assertion("hi", &Assertion::MaxLength(5), None);
863        assert!(result.passed);
864    }
865
866    #[test]
867    fn test_assertion_max_length_fail() {
868        let result = check_single_assertion("hello world", &Assertion::MaxLength(5), None);
869        assert!(!result.passed);
870    }
871
872    #[test]
873    fn test_check_assertions_empty() {
874        let (results, passed, failed) = check_assertions("hello", &[], None);
875        assert!(results.is_empty());
876        assert_eq!(passed, 0);
877        assert_eq!(failed, 0);
878    }
879
880    #[test]
881    fn test_check_assertions_multiple() {
882        let assertions = vec![
883            Assertion::Contains("hello".to_string()),
884            Assertion::Contains("world".to_string()),
885            Assertion::NonEmpty,
886        ];
887        let (results, passed, failed) = check_assertions("hello world", &assertions, None);
888        assert_eq!(passed, 3);
889        assert_eq!(failed, 0);
890        assert_eq!(results.len(), 3);
891    }
892
893    #[test]
894    fn test_check_assertions_tool_called() {
895        let trace = RunTrace {
896            task_name: "test".to_string(),
897            started_at: "2026-01-01T00:00:00Z".to_string(),
898            ended_at: "2026-01-01T00:00:01Z".to_string(),
899            duration_ms: 1000,
900            iterations: 1,
901            steps: vec![],
902            llm_calls: vec![],
903            tool_calls: vec![
904                ToolCallTrace {
905                    iteration: 0,
906                    tool_name: "web_search".to_string(),
907                    arguments: serde_json::json!({"query": "test"}),
908                    success: true,
909                    output_preview: "results".to_string(),
910                    duration_ms: 100,
911                },
912                ToolCallTrace {
913                    iteration: 0,
914                    tool_name: "read_file".to_string(),
915                    arguments: serde_json::json!({"path": "/tmp/test"}),
916                    success: true,
917                    output_preview: "content".to_string(),
918                    duration_ms: 50,
919                },
920            ],
921            final_response: "response".to_string(),
922        };
923
924        // ToolCalled — should pass
925        let (results, passed, failed) = check_assertions(
926            "response",
927            &[Assertion::ToolCalled("web_search".to_string())],
928            Some(&trace),
929        );
930        assert_eq!(passed, 1);
931        assert_eq!(failed, 0);
932        assert!(results[0].passed);
933
934        // ToolCalled — should fail (tool not called)
935        let (results, passed, failed) = check_assertions(
936            "response",
937            &[Assertion::ToolCalled("nonexistent".to_string())],
938            Some(&trace),
939        );
940        assert_eq!(passed, 0);
941        assert_eq!(failed, 1);
942        assert!(!results[0].passed);
943
944        // ToolNotCalled — should pass (tool not in list)
945        let (results, passed, failed) = check_assertions(
946            "response",
947            &[Assertion::ToolNotCalled("nonexistent".to_string())],
948            Some(&trace),
949        );
950        assert_eq!(passed, 1);
951        assert_eq!(failed, 0);
952        assert!(results[0].passed);
953
954        // ToolNotCalled — should fail (tool was called)
955        let (results, passed, failed) = check_assertions(
956            "response",
957            &[Assertion::ToolNotCalled("web_search".to_string())],
958            Some(&trace),
959        );
960        assert_eq!(passed, 0);
961        assert_eq!(failed, 1);
962        assert!(!results[0].passed);
963
964        // ToolCalled with no trace — should fail
965        let (results, passed, failed) = check_assertions(
966            "response",
967            &[Assertion::ToolCalled("web_search".to_string())],
968            None,
969        );
970        assert_eq!(passed, 0);
971        assert_eq!(failed, 1);
972        assert!(!results[0].passed);
973    }
974
975    #[test]
976    fn test_eval_config_from_toml() {
977        let toml_str = r#"
978name = "test-suite"
979description = "A test suite"
980system_prompt = "Be concise"
981max_iterations = 3
982
983[[tasks]]
984name = "test-1"
985prompt = "What is 2+2?"
986golden = "4"
987assertions = [{ type = "contains", value = "4" }]
988weight = 1.0
989required = true
990"#;
991
992        let config: EvalConfig = toml::from_str(toml_str).unwrap();
993        assert_eq!(config.name, "test-suite");
994        assert_eq!(config.tasks.len(), 1);
995        assert_eq!(config.tasks[0].name, "test-1");
996        assert_eq!(config.tasks[0].prompt, "What is 2+2?");
997        assert_eq!(config.tasks[0].golden, "4");
998        assert_eq!(config.tasks[0].assertions.len(), 1);
999    }
1000
1001    #[test]
1002    fn test_eval_config_defaults() {
1003        let toml_str = r#"
1004[[tasks]]
1005name = "simple"
1006prompt = "Say hello"
1007"#;
1008
1009        let config: EvalConfig = toml::from_str(toml_str).unwrap();
1010        assert_eq!(config.name, "unnamed");
1011        assert_eq!(config.system_prompt, default_system_prompt());
1012        assert_eq!(config.max_iterations, 5);
1013        assert_eq!(config.tasks[0].weight, 1.0);
1014        assert!(!config.tasks[0].required);
1015    }
1016
1017    #[test]
1018    fn test_report_format_text() {
1019        let report = EvalReport {
1020            suite_name: "test".to_string(),
1021            ran_at: "2026-06-22T12:00:00+00:00".to_string(),
1022            duration_ms: 100,
1023            overall_score: 0.75,
1024            total_tasks: 2,
1025            passed_tasks: 1,
1026            failed_tasks: 1,
1027            results: vec![
1028                EvalResult {
1029                    task_name: "pass-task".to_string(),
1030                    passed: true,
1031                    score: 1.0,
1032                    assertions_passed: 2,
1033                    assertions_failed: 0,
1034                    assertion_results: vec![AssertionResult {
1035                        assertion: "contains: hello".to_string(),
1036                        passed: true,
1037                        details: "Response contains 'hello'".to_string(),
1038                    }],
1039                    trace: RunTrace {
1040                        task_name: "pass-task".to_string(),
1041                        started_at: "2026-06-22T12:00:00+00:00".to_string(),
1042                        ended_at: "2026-06-22T12:00:01+00:00".to_string(),
1043                        duration_ms: 50,
1044                        iterations: 1,
1045                        steps: vec![],
1046                        llm_calls: vec![],
1047                        tool_calls: vec![],
1048                        final_response: "hello world".to_string(),
1049                    },
1050                    error: None,
1051                },
1052                EvalResult {
1053                    task_name: "fail-task".to_string(),
1054                    passed: false,
1055                    score: 0.0,
1056                    assertions_passed: 0,
1057                    assertions_failed: 1,
1058                    assertion_results: vec![AssertionResult {
1059                        assertion: "contains: foo".to_string(),
1060                        passed: false,
1061                        details: "Response does not contain 'foo'".to_string(),
1062                    }],
1063                    trace: RunTrace {
1064                        task_name: "fail-task".to_string(),
1065                        started_at: "2026-06-22T12:00:01+00:00".to_string(),
1066                        ended_at: "2026-06-22T12:00:02+00:00".to_string(),
1067                        duration_ms: 50,
1068                        iterations: 1,
1069                        steps: vec![],
1070                        llm_calls: vec![],
1071                        tool_calls: vec![],
1072                        final_response: "bar".to_string(),
1073                    },
1074                    error: None,
1075                },
1076            ],
1077        };
1078
1079        let text = report.format_text();
1080        assert!(text.contains("Eval Report: test"));
1081        assert!(text.contains("75.0%"));
1082        assert!(text.contains("1/2 passed"));
1083        assert!(text.contains("✅ pass-task"));
1084        assert!(text.contains("❌ fail-task"));
1085    }
1086
1087    #[test]
1088    fn test_report_format_json() {
1089        let report = EvalReport {
1090            suite_name: "test".to_string(),
1091            ran_at: "2026-06-22T12:00:00+00:00".to_string(),
1092            duration_ms: 100,
1093            overall_score: 1.0,
1094            total_tasks: 1,
1095            passed_tasks: 1,
1096            failed_tasks: 0,
1097            results: vec![],
1098        };
1099
1100        let json = report.format_json();
1101        assert_eq!(json["suite_name"], "test");
1102        assert_eq!(json["overall_score"], 1.0);
1103    }
1104
1105    #[test]
1106    fn test_eval_config_from_file_not_found() {
1107        let result = EvalConfig::from_file("/tmp/nonexistent-eval-config.toml");
1108        assert!(result.is_err());
1109    }
1110
1111    #[test]
1112    fn test_assertion_regex_invalid_pattern() {
1113        let result =
1114            check_single_assertion("hello", &Assertion::Regex(r"[invalid".to_string()), None);
1115        assert!(!result.passed);
1116        assert!(result.details.contains("Invalid regex"));
1117    }
1118
1119    #[test]
1120    fn test_trace_step_serialization() {
1121        let step = TraceStep {
1122            number: 0,
1123            step_type: StepType::Thought,
1124            content: "test".to_string(),
1125            duration_ms: 100,
1126        };
1127        let json = serde_json::to_string(&step).unwrap();
1128        assert!(json.contains("Thought"));
1129    }
1130
1131    #[test]
1132    fn test_tool_call_trace_serialization() {
1133        let trace = ToolCallTrace {
1134            iteration: 0,
1135            tool_name: "shell_exec".to_string(),
1136            arguments: serde_json::json!({"command": "echo hello"}),
1137            success: true,
1138            output_preview: "hello".to_string(),
1139            duration_ms: 50,
1140        };
1141        let json = serde_json::to_string(&trace).unwrap();
1142        assert!(json.contains("shell_exec"));
1143        assert!(json.contains("echo hello"));
1144    }
1145}