Skip to main content

adk_eval/
evaluator.rs

1//! Core evaluator implementation
2//!
3//! The Evaluator orchestrates test execution and applies evaluation criteria.
4
5use crate::cost_tracker::CostTracker;
6use crate::criteria::EvaluationCriteria;
7use crate::error::Result;
8use crate::llm_judge::LlmJudge;
9use crate::report::{EvaluationReport, EvaluationResult, Failure, TurnResult};
10use crate::schema::{EvalCase, TestFile, ToolUse, Turn};
11use crate::scoring::{ResponseScorer, ToolTrajectoryScorer};
12use crate::structured_judge::StructuredJudge;
13use crate::trace_analyzer::TraceAnalyzer;
14
15use adk_core::{Agent, Content, Event, Llm};
16use async_trait::async_trait;
17use futures::StreamExt;
18use serde::{Deserialize, Serialize};
19use serde_json::Value;
20use std::collections::HashMap;
21use std::path::Path;
22use std::sync::Arc;
23use std::time::{Duration, Instant};
24
25#[cfg(feature = "embedding")]
26use crate::embedding_scorer::EmbeddingScorer;
27
28/// Configuration for the evaluator
29#[derive(Debug, Clone, Default, Serialize, Deserialize)]
30pub struct EvaluationConfig {
31    /// Evaluation criteria to apply
32    #[serde(default)]
33    pub criteria: EvaluationCriteria,
34    /// Whether to continue on failure
35    #[serde(default)]
36    pub continue_on_failure: bool,
37    /// Maximum time per test case
38    #[serde(default)]
39    pub timeout_per_case: Option<Duration>,
40    /// Number of retries for flaky tests
41    #[serde(default)]
42    pub retries: usize,
43    /// Whether to collect detailed turn results
44    #[serde(default = "default_true")]
45    pub collect_turn_details: bool,
46}
47
48fn default_true() -> bool {
49    true
50}
51
52impl EvaluationConfig {
53    /// Create config with specific criteria
54    pub fn with_criteria(criteria: EvaluationCriteria) -> Self {
55        Self { criteria, ..Default::default() }
56    }
57}
58
59/// The main evaluator struct
60pub struct Evaluator {
61    config: EvaluationConfig,
62    tool_scorer: ToolTrajectoryScorer,
63    response_scorer: ResponseScorer,
64    llm_judge: Option<LlmJudge>,
65    /// Optional structured judge for typed verdicts
66    structured_judge: Option<Arc<StructuredJudge>>,
67    /// Optional cost tracker for token usage and latency
68    cost_tracker: Option<CostTracker>,
69    /// Optional trace analyzer for detecting execution inefficiencies
70    trace_analyzer: Option<TraceAnalyzer>,
71    /// Optional embedding scorer for semantic similarity (requires `embedding` feature)
72    #[cfg(feature = "embedding")]
73    embedding_scorer: Option<Arc<EmbeddingScorer>>,
74    /// Optional conversation scorer for multi-turn metrics
75    conversation_scorer: Option<Arc<crate::conversation_scorer::ConversationScorer>>,
76}
77
78impl Evaluator {
79    /// Create a new evaluator with default configuration
80    pub fn new(config: EvaluationConfig) -> Self {
81        let tool_scorer = if let Some(tc) = &config.criteria.tool_trajectory_config {
82            ToolTrajectoryScorer::with_config(tc.clone())
83        } else {
84            ToolTrajectoryScorer::new()
85        };
86
87        let response_scorer = if let Some(rc) = &config.criteria.response_match_config {
88            ResponseScorer::with_config(rc.clone())
89        } else {
90            ResponseScorer::new()
91        };
92
93        Self {
94            config,
95            tool_scorer,
96            response_scorer,
97            llm_judge: None,
98            structured_judge: None,
99            cost_tracker: None,
100            trace_analyzer: None,
101            #[cfg(feature = "embedding")]
102            embedding_scorer: None,
103            conversation_scorer: None,
104        }
105    }
106
107    /// Create an evaluator with an LLM judge for semantic matching and rubric evaluation
108    pub fn with_llm_judge(config: EvaluationConfig, judge_model: Arc<dyn Llm>) -> Self {
109        let tool_scorer = if let Some(tc) = &config.criteria.tool_trajectory_config {
110            ToolTrajectoryScorer::with_config(tc.clone())
111        } else {
112            ToolTrajectoryScorer::new()
113        };
114
115        let response_scorer = if let Some(rc) = &config.criteria.response_match_config {
116            ResponseScorer::with_config(rc.clone())
117        } else {
118            ResponseScorer::new()
119        };
120
121        Self {
122            config,
123            tool_scorer,
124            response_scorer,
125            llm_judge: Some(LlmJudge::new(judge_model)),
126            structured_judge: None,
127            cost_tracker: None,
128            trace_analyzer: None,
129            #[cfg(feature = "embedding")]
130            embedding_scorer: None,
131            conversation_scorer: None,
132        }
133    }
134
135    /// Set the LLM judge model
136    pub fn set_llm_judge(&mut self, judge_model: Arc<dyn Llm>) {
137        self.llm_judge = Some(LlmJudge::new(judge_model));
138    }
139
140    /// Check if LLM judge is available
141    pub fn has_llm_judge(&self) -> bool {
142        self.llm_judge.is_some()
143    }
144
145    /// Set the structured judge for typed verdict evaluation
146    pub fn set_structured_judge(&mut self, judge: Arc<StructuredJudge>) {
147        self.structured_judge = Some(judge);
148    }
149
150    /// Set the cost tracker for token usage and latency metrics
151    pub fn set_cost_tracker(&mut self, tracker: CostTracker) {
152        self.cost_tracker = Some(tracker);
153    }
154
155    /// Set the trace analyzer for execution inefficiency detection
156    pub fn set_trace_analyzer(&mut self, analyzer: TraceAnalyzer) {
157        self.trace_analyzer = Some(analyzer);
158    }
159
160    /// Set the embedding scorer for semantic similarity (requires `embedding` feature)
161    #[cfg(feature = "embedding")]
162    pub fn set_embedding_scorer(&mut self, scorer: Arc<EmbeddingScorer>) {
163        self.embedding_scorer = Some(scorer);
164    }
165
166    /// Set the conversation scorer for multi-turn metrics
167    pub fn set_conversation_scorer(
168        &mut self,
169        scorer: Arc<crate::conversation_scorer::ConversationScorer>,
170    ) {
171        self.conversation_scorer = Some(scorer);
172    }
173
174    /// Check if a structured judge is configured
175    pub fn has_structured_judge(&self) -> bool {
176        self.structured_judge.is_some()
177    }
178
179    /// Check if a cost tracker is configured
180    pub fn has_cost_tracker(&self) -> bool {
181        self.cost_tracker.is_some()
182    }
183
184    /// Check if a trace analyzer is configured
185    pub fn has_trace_analyzer(&self) -> bool {
186        self.trace_analyzer.is_some()
187    }
188
189    /// Check if an embedding scorer is configured (requires `embedding` feature)
190    #[cfg(feature = "embedding")]
191    pub fn has_embedding_scorer(&self) -> bool {
192        self.embedding_scorer.is_some()
193    }
194
195    /// Check if a conversation scorer is configured
196    pub fn has_conversation_scorer(&self) -> bool {
197        self.conversation_scorer.is_some()
198    }
199
200    /// Evaluate a test file against an agent
201    pub async fn evaluate_file(
202        &self,
203        agent: Arc<dyn Agent>,
204        path: impl AsRef<Path>,
205    ) -> Result<EvaluationReport> {
206        let test_file = TestFile::load(path)?;
207        self.evaluate_test_file(agent, &test_file).await
208    }
209
210    /// Evaluate a TestFile struct
211    pub async fn evaluate_test_file(
212        &self,
213        agent: Arc<dyn Agent>,
214        test_file: &TestFile,
215    ) -> Result<EvaluationReport> {
216        let started_at = chrono::Utc::now();
217        let run_id = format!("{}_{}", test_file.eval_set_id, uuid::Uuid::new_v4());
218        let mut results = Vec::new();
219
220        for eval_case in &test_file.eval_cases {
221            let result = self.evaluate_case(agent.clone(), eval_case).await;
222
223            match result {
224                Ok(r) => {
225                    let passed = r.passed;
226                    results.push(r);
227                    if !passed && !self.config.continue_on_failure {
228                        break;
229                    }
230                }
231                Err(e) => {
232                    // Create a failed result for the error
233                    results.push(EvaluationResult::failed(
234                        &eval_case.eval_id,
235                        HashMap::new(),
236                        vec![Failure::new(
237                            "execution",
238                            Value::Null,
239                            Value::String(e.to_string()),
240                            0.0,
241                            1.0,
242                        )],
243                        Duration::from_secs(0),
244                    ));
245                    if !self.config.continue_on_failure {
246                        break;
247                    }
248                }
249            }
250        }
251
252        Ok(EvaluationReport::new(&run_id, results, started_at))
253    }
254
255    /// Evaluate a single test case
256    pub async fn evaluate_case(
257        &self,
258        agent: Arc<dyn Agent>,
259        eval_case: &EvalCase,
260    ) -> Result<EvaluationResult> {
261        let start = Instant::now();
262        let mut all_scores: HashMap<String, f64> = HashMap::new();
263        let mut all_failures: Vec<Failure> = Vec::new();
264        let mut turn_results: Vec<TurnResult> = Vec::new();
265        let mut all_events: Vec<Event> = Vec::new();
266
267        // Execute each turn in the conversation
268        for turn in &eval_case.conversation {
269            let turn_result = self.execute_turn(agent.clone(), turn).await?;
270
271            // Score this turn
272            let (scores, failures) = self.score_turn(turn, &turn_result).await;
273
274            // Merge scores
275            for (criterion, score) in &scores {
276                all_scores
277                    .entry(criterion.clone())
278                    .and_modify(|s| *s = (*s + score) / 2.0)
279                    .or_insert(*score);
280            }
281            all_failures.extend(failures);
282
283            if self.config.collect_turn_details {
284                turn_results.push(turn_result);
285            }
286        }
287
288        // Collect events for the full case by re-running (or using last turn's events)
289        // For cost/trace analysis we re-run the agent to get the full event stream
290        let case_events = self.collect_case_events(agent.clone(), eval_case).await;
291        if let Ok(events) = case_events {
292            all_events = events;
293        }
294
295        let duration = start.elapsed();
296
297        // Invoke CostTracker if configured
298        let cost_metrics = self
299            .cost_tracker
300            .as_ref()
301            .map(|tracker| tracker.extract_metrics(&all_events, duration));
302
303        // Invoke TraceAnalyzer if configured
304        let trace_analysis =
305            self.trace_analyzer.as_ref().map(|analyzer| analyzer.analyze(&all_events));
306
307        // Invoke StructuredJudge if configured
308        let mut verdicts = Vec::new();
309        if let Some(judge) = &self.structured_judge
310            && let Some(last_turn_result) = turn_results.last()
311            && let (Some(expected), Some(actual)) =
312                (&last_turn_result.expected_response, &last_turn_result.actual_response)
313        {
314            match judge.judge(expected, actual, "overall_quality").await {
315                Ok(verdict) => {
316                    all_scores.insert("structured_judge".to_string(), verdict.score);
317                    verdicts.push(verdict);
318                }
319                Err(e) => {
320                    tracing::warn!("Structured judge failed: {e}");
321                    // Create a fallback verdict with score 0.0
322                    let fallback = crate::structured_judge::StructuredVerdict {
323                        score: 0.0,
324                        reasoning: format!("Judge error: {e}"),
325                        verdict: crate::structured_judge::Verdict::Fail,
326                    };
327                    verdicts.push(fallback);
328                }
329            }
330        }
331
332        // Invoke EmbeddingScorer if configured
333        #[cfg(feature = "embedding")]
334        if let Some(scorer) = &self.embedding_scorer
335            && let Some(last_turn_result) = turn_results.last()
336            && let (Some(expected), Some(actual)) =
337                (&last_turn_result.expected_response, &last_turn_result.actual_response)
338        {
339            match scorer.score(expected, actual).await {
340                Ok(score) => {
341                    all_scores.insert("embedding_similarity".to_string(), score);
342                }
343                Err(e) => {
344                    tracing::warn!("Embedding scorer failed: {e}");
345                }
346            }
347        }
348
349        let passed = all_failures.is_empty();
350
351        let mut result = if passed {
352            EvaluationResult::passed(&eval_case.eval_id, all_scores, duration)
353        } else {
354            EvaluationResult::failed(&eval_case.eval_id, all_scores, all_failures, duration)
355        };
356
357        if self.config.collect_turn_details {
358            result = result.with_turn_results(turn_results);
359        }
360
361        // Populate extended fields
362        result.cost_metrics = cost_metrics;
363        result.trace_analysis = trace_analysis;
364        result.verdicts = verdicts;
365
366        Ok(result)
367    }
368
369    /// Collect all events for a case by running the agent on the first turn input.
370    /// Used by cost tracker and trace analyzer to analyze the full execution.
371    async fn collect_case_events(
372        &self,
373        agent: Arc<dyn Agent>,
374        eval_case: &EvalCase,
375    ) -> Result<Vec<Event>> {
376        // Only collect events if we have a cost tracker or trace analyzer configured
377        if self.cost_tracker.is_none() && self.trace_analyzer.is_none() {
378            return Ok(Vec::new());
379        }
380
381        // Use events from the first turn as representative
382        if let Some(first_turn) = eval_case.conversation.first() {
383            let input_content = first_turn.user_content.to_adk_content();
384            self.run_agent(agent, input_content).await
385        } else {
386            Ok(Vec::new())
387        }
388    }
389
390    /// Execute a single turn and collect results
391    async fn execute_turn(&self, agent: Arc<dyn Agent>, turn: &Turn) -> Result<TurnResult> {
392        // Create input content
393        let input_content = turn.user_content.to_adk_content();
394
395        // Run the agent
396        let events = self.run_agent(agent, input_content).await?;
397
398        // Extract response and tool calls from events
399        let (actual_response, actual_tool_calls) = self.extract_from_events(&events);
400
401        // Get expected values
402        let expected_response = turn.final_response.as_ref().map(|c| c.get_text());
403        let expected_tool_calls =
404            turn.intermediate_data.as_ref().map(|d| d.tool_uses.clone()).unwrap_or_default();
405
406        Ok(TurnResult {
407            invocation_id: turn.invocation_id.clone(),
408            actual_response,
409            expected_response,
410            actual_tool_calls,
411            expected_tool_calls,
412            scores: HashMap::new(),
413        })
414    }
415
416    /// Run agent and collect events
417    async fn run_agent(&self, agent: Arc<dyn Agent>, input: Content) -> Result<Vec<Event>> {
418        // Create a minimal invocation context for evaluation
419        let invocation_id = uuid::Uuid::new_v4().to_string();
420        let ctx = Arc::new(EvalInvocationContext::new(invocation_id, input, agent.clone()));
421
422        // Run the agent and collect all events
423        let stream = agent.run(ctx).await.map_err(|e| {
424            crate::error::EvalError::ExecutionError(format!("Agent run failed: {}", e))
425        })?;
426
427        // Collect all events from the stream
428        let events: Vec<Event> = stream.filter_map(|r| async { r.ok() }).collect().await;
429
430        Ok(events)
431    }
432
433    /// Extract response text and tool calls from events
434    fn extract_from_events(&self, events: &[Event]) -> (Option<String>, Vec<ToolUse>) {
435        let mut response_text = String::new();
436        let mut tool_calls = Vec::new();
437        let complete_ids: std::collections::HashSet<_> = events
438            .iter()
439            .filter(|event| {
440                !event.llm_response.partial
441                    && event
442                        .llm_response
443                        .provider_metadata
444                        .as_ref()
445                        .and_then(|metadata| metadata.get("content_complete"))
446                        .and_then(serde_json::Value::as_bool)
447                        == Some(true)
448            })
449            .map(|event| (&event.invocation_id, &event.id))
450            .collect();
451
452        for event in events {
453            if event.llm_response.partial
454                && complete_ids.contains(&(&event.invocation_id, &event.id))
455            {
456                continue;
457            }
458            // Extract text content
459            if let Some(content) = event.content() {
460                for part in &content.parts {
461                    // Extract text content
462                    if let Some(text) = part.text() {
463                        response_text.push_str(text);
464                    }
465                    // Extract function calls using pattern matching
466                    if let adk_core::Part::FunctionCall { name, args, .. } = part {
467                        tool_calls.push(ToolUse {
468                            name: name.clone(),
469                            args: args.clone(),
470                            expected_response: None,
471                        });
472                    }
473                }
474            }
475        }
476
477        let response = if response_text.is_empty() { None } else { Some(response_text) };
478
479        (response, tool_calls)
480    }
481
482    /// Score a turn against criteria
483    async fn score_turn(
484        &self,
485        turn: &Turn,
486        result: &TurnResult,
487    ) -> (HashMap<String, f64>, Vec<Failure>) {
488        let mut scores = HashMap::new();
489        let mut failures = Vec::new();
490
491        // Tool trajectory scoring
492        if let Some(threshold) = self.config.criteria.tool_trajectory_score {
493            let score =
494                self.tool_scorer.score(&result.expected_tool_calls, &result.actual_tool_calls);
495            scores.insert("tool_trajectory".to_string(), score);
496
497            if score < threshold {
498                failures.push(
499                    Failure::new(
500                        "tool_trajectory",
501                        serde_json::to_value(&result.expected_tool_calls).unwrap_or_default(),
502                        serde_json::to_value(&result.actual_tool_calls).unwrap_or_default(),
503                        score,
504                        threshold,
505                    )
506                    .with_details(&format!(
507                        "Expected {} tool calls, got {}",
508                        result.expected_tool_calls.len(),
509                        result.actual_tool_calls.len()
510                    )),
511                );
512            }
513        }
514
515        // Response similarity scoring (text-based)
516        if let Some(threshold) = self.config.criteria.response_similarity {
517            if let (Some(expected), Some(actual)) =
518                (&result.expected_response, &result.actual_response)
519            {
520                let score = self.response_scorer.score(expected, actual);
521                scores.insert("response_similarity".to_string(), score);
522
523                if score < threshold {
524                    failures.push(
525                        Failure::new(
526                            "response_similarity",
527                            Value::String(expected.clone()),
528                            Value::String(actual.clone()),
529                            score,
530                            threshold,
531                        )
532                        .with_details("Response text differs from expected"),
533                    );
534                }
535            } else if result.expected_response.is_some() && result.actual_response.is_none() {
536                scores.insert("response_similarity".to_string(), 0.0);
537                failures.push(
538                    Failure::new(
539                        "response_similarity",
540                        Value::String(result.expected_response.clone().unwrap_or_default()),
541                        Value::Null,
542                        0.0,
543                        threshold,
544                    )
545                    .with_details("No response received"),
546                );
547            }
548        }
549
550        // LLM-judged semantic matching
551        if let Some(threshold) = self.config.criteria.semantic_match_score
552            && let Some(judge) = &self.llm_judge
553            && let (Some(expected), Some(actual)) =
554                (&result.expected_response, &result.actual_response)
555        {
556            match judge
557                .semantic_match(
558                    expected,
559                    actual,
560                    self.config.criteria.semantic_match_config.as_ref(),
561                )
562                .await
563            {
564                Ok(semantic_result) => {
565                    scores.insert("semantic_match".to_string(), semantic_result.score);
566                    if semantic_result.score < threshold {
567                        failures.push(
568                            Failure::new(
569                                "semantic_match",
570                                Value::String(expected.clone()),
571                                Value::String(actual.clone()),
572                                semantic_result.score,
573                                threshold,
574                            )
575                            .with_details(&semantic_result.reasoning),
576                        );
577                    }
578                }
579                Err(e) => {
580                    // Record error but don't fail the whole evaluation
581                    failures.push(
582                        Failure::new(
583                            "semantic_match",
584                            Value::String(expected.clone()),
585                            Value::String(actual.clone()),
586                            0.0,
587                            threshold,
588                        )
589                        .with_details(&format!("LLM judge error: {}", e)),
590                    );
591                }
592            }
593        }
594
595        // Rubric-based evaluation
596        if let Some(threshold) = self.config.criteria.rubric_quality_score
597            && let Some(judge) = &self.llm_judge
598            && let Some(rubric_config) = &self.config.criteria.rubric_config
599            && let Some(actual) = &result.actual_response
600        {
601            // Use user input as context for rubric evaluation
602            let context = turn.user_content.get_text();
603            match judge.evaluate_rubrics(actual, &context, rubric_config).await {
604                Ok(rubric_result) => {
605                    scores.insert("rubric_quality".to_string(), rubric_result.overall_score);
606                    // Also store individual rubric scores
607                    for rs in &rubric_result.rubric_scores {
608                        scores.insert(format!("rubric_{}", rs.name), rs.score);
609                    }
610                    if rubric_result.overall_score < threshold {
611                        let details = rubric_result
612                            .rubric_scores
613                            .iter()
614                            .map(|rs| format!("{}: {:.2} - {}", rs.name, rs.score, rs.reasoning))
615                            .collect::<Vec<_>>()
616                            .join("; ");
617                        failures.push(
618                            Failure::new(
619                                "rubric_quality",
620                                Value::Number(
621                                    serde_json::Number::from_f64(threshold)
622                                        .unwrap_or(serde_json::Number::from(0)),
623                                ),
624                                Value::Number(
625                                    serde_json::Number::from_f64(rubric_result.overall_score)
626                                        .unwrap_or(serde_json::Number::from(0)),
627                                ),
628                                rubric_result.overall_score,
629                                threshold,
630                            )
631                            .with_details(&details),
632                        );
633                    }
634                }
635                Err(e) => {
636                    failures.push(
637                        Failure::new("rubric_quality", Value::Null, Value::Null, 0.0, threshold)
638                            .with_details(&format!("LLM judge error: {}", e)),
639                    );
640                }
641            }
642        }
643
644        // Safety evaluation
645        if let Some(threshold) = self.config.criteria.safety_score
646            && let Some(judge) = &self.llm_judge
647            && let Some(actual) = &result.actual_response
648        {
649            match judge.evaluate_safety(actual).await {
650                Ok(safety_result) => {
651                    scores.insert("safety".to_string(), safety_result.score);
652                    if safety_result.score < threshold {
653                        failures.push(
654                            Failure::new(
655                                "safety",
656                                Value::Number(
657                                    serde_json::Number::from_f64(threshold)
658                                        .unwrap_or(serde_json::Number::from(0)),
659                                ),
660                                Value::Number(
661                                    serde_json::Number::from_f64(safety_result.score)
662                                        .unwrap_or(serde_json::Number::from(0)),
663                                ),
664                                safety_result.score,
665                                threshold,
666                            )
667                            .with_details(&format!(
668                                "Safety issues: {}",
669                                safety_result.issues.join(", ")
670                            )),
671                        );
672                    }
673                }
674                Err(e) => {
675                    failures.push(
676                        Failure::new("safety", Value::Null, Value::Null, 0.0, threshold)
677                            .with_details(&format!("LLM judge error: {}", e)),
678                    );
679                }
680            }
681        }
682
683        // Hallucination detection
684        if let Some(threshold) = self.config.criteria.hallucination_score
685            && let Some(judge) = &self.llm_judge
686            && let Some(actual) = &result.actual_response
687        {
688            let context = turn.user_content.get_text();
689            let ground_truth = result.expected_response.as_deref();
690            match judge.detect_hallucinations(actual, &context, ground_truth).await {
691                Ok(hallucination_result) => {
692                    scores.insert("hallucination".to_string(), hallucination_result.score);
693                    if hallucination_result.score < threshold {
694                        failures.push(
695                            Failure::new(
696                                "hallucination",
697                                Value::Number(
698                                    serde_json::Number::from_f64(threshold)
699                                        .unwrap_or(serde_json::Number::from(0)),
700                                ),
701                                Value::Number(
702                                    serde_json::Number::from_f64(hallucination_result.score)
703                                        .unwrap_or(serde_json::Number::from(0)),
704                                ),
705                                hallucination_result.score,
706                                threshold,
707                            )
708                            .with_details(&format!(
709                                "Hallucinations detected: {}",
710                                hallucination_result.issues.join(", ")
711                            )),
712                        );
713                    }
714                }
715                Err(e) => {
716                    failures.push(
717                        Failure::new("hallucination", Value::Null, Value::Null, 0.0, threshold)
718                            .with_details(&format!("LLM judge error: {}", e)),
719                    );
720                }
721            }
722        }
723
724        (scores, failures)
725    }
726
727    /// Evaluate multiple test cases in parallel
728    pub async fn evaluate_cases_parallel(
729        &self,
730        agent: Arc<dyn Agent>,
731        cases: &[EvalCase],
732        concurrency: usize,
733    ) -> Vec<Result<EvaluationResult>> {
734        use futures::stream::{self, StreamExt};
735
736        let results: Vec<_> = stream::iter(cases)
737            .map(|case| {
738                let agent = agent.clone();
739                async move { self.evaluate_case(agent, case).await }
740            })
741            .buffer_unordered(concurrency)
742            .collect()
743            .await;
744
745        results
746    }
747
748    /// Evaluate a directory of test files
749    pub async fn evaluate_directory(
750        &self,
751        agent: Arc<dyn Agent>,
752        dir: impl AsRef<Path>,
753    ) -> Result<Vec<EvaluationReport>> {
754        let mut reports = Vec::new();
755
756        let entries = std::fs::read_dir(dir)?;
757        for entry in entries {
758            let entry = entry?;
759            let path = entry.path();
760
761            if path.extension().is_some_and(|ext| ext == "json")
762                && let Some(name) = path.file_name().and_then(|n| n.to_str())
763                && name.ends_with(".test.json")
764            {
765                let report = self.evaluate_file(agent.clone(), &path).await?;
766                reports.push(report);
767            }
768        }
769
770        Ok(reports)
771    }
772
773    /// Run a multi-turn conversation between a [`UserSimulator`](crate::personas::UserSimulator)
774    /// and the agent under test for a configurable number of turns.
775    ///
776    /// Each turn consists of:
777    /// 1. The `UserSimulator` generates a user message based on the conversation history
778    /// 2. The agent processes the user message and produces a response
779    /// 3. Both messages are appended to the conversation history
780    ///
781    /// Returns the full conversation history as a `Vec<Content>`.
782    ///
783    /// # Errors
784    ///
785    /// Returns an error if the simulator or agent fails during any turn.
786    #[cfg(feature = "personas")]
787    pub async fn evaluate_multi_turn(
788        &self,
789        agent: Arc<dyn Agent>,
790        simulator: &crate::personas::UserSimulator,
791        num_turns: usize,
792    ) -> Result<Vec<Content>> {
793        let mut history: Vec<Content> = Vec::new();
794
795        for _turn_idx in 0..num_turns {
796            // 1. Generate user message from the simulator
797            let user_message = simulator.generate_message(&history).await?;
798            history.push(user_message.clone());
799
800            // 2. Run the agent with the user message
801            let events = self.run_agent(agent.clone(), user_message).await?;
802
803            // 3. Extract the agent's response text
804            let (response_text, _tool_calls) = self.extract_from_events(&events);
805            if let Some(text) = response_text {
806                history.push(Content::new("model").with_text(text));
807            }
808        }
809
810        Ok(history)
811    }
812}
813
814impl Default for Evaluator {
815    fn default() -> Self {
816        Self::new(EvaluationConfig::default())
817    }
818}
819
820// ============================================================================
821// EvalInvocationContext - Minimal context for running agents during evaluation
822// ============================================================================
823
824/// Minimal InvocationContext implementation for evaluation
825struct EvalInvocationContext {
826    invocation_id: String,
827    user_content: Content,
828    agent: Arc<dyn Agent>,
829    session: EvalSession,
830    run_config: adk_core::RunConfig,
831    ended: std::sync::atomic::AtomicBool,
832}
833
834impl EvalInvocationContext {
835    fn new(invocation_id: String, user_content: Content, agent: Arc<dyn Agent>) -> Self {
836        let session_id = format!("eval-session-{}", uuid::Uuid::new_v4());
837        Self {
838            invocation_id,
839            user_content,
840            agent,
841            session: EvalSession::new(session_id),
842            run_config: adk_core::RunConfig::default(),
843            ended: std::sync::atomic::AtomicBool::new(false),
844        }
845    }
846}
847
848impl adk_core::ReadonlyContext for EvalInvocationContext {
849    fn invocation_id(&self) -> &str {
850        &self.invocation_id
851    }
852
853    fn agent_name(&self) -> &str {
854        self.agent.name()
855    }
856
857    fn user_id(&self) -> &str {
858        "eval_user"
859    }
860
861    fn app_name(&self) -> &str {
862        "eval_app"
863    }
864
865    fn session_id(&self) -> &str {
866        &self.session.id
867    }
868
869    fn branch(&self) -> &str {
870        "main"
871    }
872
873    fn user_content(&self) -> &Content {
874        &self.user_content
875    }
876}
877
878#[async_trait]
879impl adk_core::CallbackContext for EvalInvocationContext {
880    fn artifacts(&self) -> Option<Arc<dyn adk_core::Artifacts>> {
881        None
882    }
883}
884
885#[async_trait]
886impl adk_core::InvocationContext for EvalInvocationContext {
887    fn agent(&self) -> Arc<dyn Agent> {
888        self.agent.clone()
889    }
890
891    fn memory(&self) -> Option<Arc<dyn adk_core::Memory>> {
892        None
893    }
894
895    fn session(&self) -> &dyn adk_core::Session {
896        &self.session
897    }
898
899    fn run_config(&self) -> &adk_core::RunConfig {
900        &self.run_config
901    }
902
903    fn end_invocation(&self) {
904        self.ended.store(true, std::sync::atomic::Ordering::SeqCst);
905    }
906
907    fn ended(&self) -> bool {
908        self.ended.load(std::sync::atomic::Ordering::SeqCst)
909    }
910}
911
912/// Minimal Session implementation for evaluation
913struct EvalSession {
914    id: String,
915    state: EvalState,
916}
917
918impl EvalSession {
919    fn new(id: String) -> Self {
920        Self { id, state: EvalState::new() }
921    }
922}
923
924impl adk_core::Session for EvalSession {
925    fn id(&self) -> &str {
926        &self.id
927    }
928
929    fn app_name(&self) -> &str {
930        "eval_app"
931    }
932
933    fn user_id(&self) -> &str {
934        "eval_user"
935    }
936
937    fn state(&self) -> &dyn adk_core::State {
938        &self.state
939    }
940
941    fn conversation_history(&self) -> Vec<Content> {
942        vec![]
943    }
944}
945
946/// Minimal State implementation for evaluation
947struct EvalState {
948    data: std::sync::RwLock<HashMap<String, serde_json::Value>>,
949}
950
951impl EvalState {
952    fn new() -> Self {
953        Self { data: std::sync::RwLock::new(HashMap::new()) }
954    }
955}
956
957impl adk_core::State for EvalState {
958    fn get(&self, key: &str) -> Option<serde_json::Value> {
959        self.data.read().ok()?.get(key).cloned()
960    }
961
962    fn set(&mut self, key: String, value: serde_json::Value) {
963        if let Err(msg) = adk_core::validate_state_key(&key) {
964            tracing::warn!(key = %key, "rejecting invalid state key: {msg}");
965            return;
966        }
967        if let Ok(mut data) = self.data.write() {
968            data.insert(key, value);
969        }
970    }
971
972    fn all(&self) -> HashMap<String, serde_json::Value> {
973        self.data.read().ok().map(|d| d.clone()).unwrap_or_default()
974    }
975}
976
977#[cfg(test)]
978mod tests {
979    use super::*;
980
981    #[test]
982    fn complete_snapshots_replace_partial_text_and_calls() {
983        let evaluator = Evaluator::new(EvaluationConfig::default());
984        let mut partial = Event::with_id("response", "invocation");
985        partial.llm_response.partial = true;
986        partial.llm_response.content = Some(Content::new("model").with_text("Draft"));
987        let mut final_event = partial.clone();
988        final_event.llm_response.partial = false;
989        final_event.llm_response.provider_metadata =
990            Some(serde_json::json!({"content_complete":true}));
991        final_event.llm_response.content = Some(Content::new("model").with_text("Corrected"));
992        let (text, calls) = evaluator.extract_from_events(&[partial, final_event]);
993        assert_eq!(text.as_deref(), Some("Corrected"));
994        assert!(calls.is_empty());
995    }
996
997    #[test]
998    fn test_evaluator_creation() {
999        let config = EvaluationConfig::with_criteria(
1000            EvaluationCriteria::exact_tools().with_response_similarity(0.8),
1001        );
1002        let evaluator = Evaluator::new(config);
1003        assert!(evaluator.config.criteria.tool_trajectory_score.is_some());
1004        assert!(evaluator.config.criteria.response_similarity.is_some());
1005    }
1006
1007    #[tokio::test]
1008    async fn test_turn_scoring() {
1009        let config = EvaluationConfig::with_criteria(EvaluationCriteria {
1010            tool_trajectory_score: Some(1.0),
1011            response_similarity: Some(0.8),
1012            ..Default::default()
1013        });
1014        let evaluator = Evaluator::new(config);
1015
1016        let turn = Turn {
1017            invocation_id: "test".to_string(),
1018            user_content: crate::schema::ContentData::text("Hello"),
1019            final_response: Some(crate::schema::ContentData::model_response("Hi there!")),
1020            intermediate_data: Some(crate::schema::IntermediateData {
1021                tool_uses: vec![ToolUse::new("greet")],
1022                ..Default::default()
1023            }),
1024        };
1025
1026        let result = TurnResult {
1027            invocation_id: "test".to_string(),
1028            actual_response: Some("Hi there!".to_string()),
1029            expected_response: Some("Hi there!".to_string()),
1030            actual_tool_calls: vec![ToolUse::new("greet")],
1031            expected_tool_calls: vec![ToolUse::new("greet")],
1032            scores: HashMap::new(),
1033        };
1034
1035        let (scores, failures) = evaluator.score_turn(&turn, &result).await;
1036        assert!(failures.is_empty());
1037        assert_eq!(scores.get("tool_trajectory"), Some(&1.0));
1038        assert_eq!(scores.get("response_similarity"), Some(&1.0));
1039    }
1040}