Skip to main content

ai_agents_eval/
runner.rs

1use std::collections::{HashMap, HashSet};
2use std::path::{Path, PathBuf};
3use std::sync::{Arc, Mutex, OnceLock, RwLock, RwLockReadGuard, RwLockWriteGuard};
4use std::time::Instant;
5
6use ai_agents_hooks::AgentHooks;
7use ai_agents_observability::config::{ExportConfig, ExportFormat};
8use ai_agents_observability::{CostEstimator, ObservabilityConfig};
9use ai_agents_runtime::spec::{AgentSpec, LLMConfigOrSelector, StorageConfig};
10use ai_agents_runtime::{Agent, AgentBuilder, AgentStreamEvent, RuntimeAgent, StreamChunk};
11use async_trait::async_trait;
12use futures::{StreamExt, stream};
13use serde_json::{Value, json};
14use tokio::time::{Duration, Instant as TokioInstant, timeout, timeout_at};
15
16use crate::assertion::{
17    Assertion, AssertionEvalContext, AssertionOutcome, AssertionResultDetail, evaluate_assertion,
18};
19use crate::budget::{BudgetProviderConfig, ScenarioBudgetTracker};
20use crate::compatibility::suite_from_jsonl;
21use crate::evidence::{
22    ApprovalEvidence, LlmRequestEvidence, collect_turn_evidence, relationship_snapshot,
23};
24use crate::fixtures::{
25    AttemptFixtureContext, AttemptWorkspace, LlmFixtureMode, RecordingToolLog,
26    WorkspacePolicyFixtureConfig, build_approval_handler, build_llm_registry, build_tool_registry,
27    resolve_fixture_context, start_mock_server,
28};
29use crate::judge::{JudgeConfig, JudgeResolver};
30use crate::metrics::compute_metrics;
31use crate::redaction::{redact_text, redact_value};
32use crate::suite::{
33    AttemptResult, EvalResult, EvalSuite, FailureCategory, IsolationMode, ResetStepConfig,
34    Scenario, ScenarioResult, ScenarioStatus, ScenarioStep, Turn, TurnResult, turn_expected_error,
35    turn_runtime_context,
36};
37use crate::{EvalError, Result};
38
39/// Eval hook that records LLM requests, tools, and resolved approvals.
40struct EvalRecordHooks {
41    tool_log: RecordingToolLog,
42    approval_log: RecordingApprovalLog,
43    llm_log: RecordingLlmLog,
44}
45
46#[derive(Clone, Default)]
47struct RecordingLlmLog {
48    records: Arc<Mutex<Vec<LlmRequestEvidence>>>,
49}
50
51impl RecordingLlmLog {
52    fn len(&self) -> usize {
53        self.records
54            .lock()
55            .unwrap_or_else(|poisoned| poisoned.into_inner())
56            .len()
57    }
58
59    fn push_messages(&self, messages: &[ai_agents_core::ChatMessage]) {
60        self.records
61            .lock()
62            .unwrap_or_else(|poisoned| poisoned.into_inner())
63            .push(LlmRequestEvidence::from_messages(messages));
64    }
65
66    fn records_since(&self, start: usize) -> Vec<LlmRequestEvidence> {
67        self.records
68            .lock()
69            .unwrap_or_else(|poisoned| poisoned.into_inner())
70            .get(start..)
71            .unwrap_or_default()
72            .to_vec()
73    }
74}
75
76#[derive(Clone, Default)]
77struct RecordingApprovalLog {
78    records: Arc<Mutex<Vec<ApprovalEvidence>>>,
79}
80
81impl RecordingApprovalLog {
82    fn len(&self) -> usize {
83        self.records
84            .lock()
85            .unwrap_or_else(|poisoned| poisoned.into_inner())
86            .len()
87    }
88
89    fn push(&self, evidence: ApprovalEvidence) {
90        self.records
91            .lock()
92            .unwrap_or_else(|poisoned| poisoned.into_inner())
93            .push(evidence);
94    }
95
96    fn records_since(&self, start: usize) -> Vec<ApprovalEvidence> {
97        self.records
98            .lock()
99            .unwrap_or_else(|poisoned| poisoned.into_inner())
100            .get(start..)
101            .unwrap_or_default()
102            .to_vec()
103    }
104}
105
106#[async_trait]
107impl AgentHooks for EvalRecordHooks {
108    async fn on_llm_start(&self, messages: &[ai_agents_core::ChatMessage]) {
109        self.llm_log.push_messages(messages);
110    }
111
112    async fn on_tool_execution_record(&self, record: &ai_agents_core::ToolExecutionRecord) {
113        self.tool_log.push_executor_record(record);
114    }
115
116    async fn on_approval_resolved(
117        &self,
118        request: &ai_agents_hitl::ApprovalRequest,
119        raw_result: &ai_agents_hitl::ApprovalResult,
120        outcome: &ai_agents_hitl::ApprovalResolvedOutcome,
121    ) {
122        self.approval_log.push(ApprovalEvidence::from_resolution(
123            request, raw_result, outcome,
124        ));
125    }
126}
127
128/// Runtime options supplied by CLI or Rust callers.
129#[derive(Debug, Clone, Default)]
130pub struct EvalRunnerOptions {
131    /// Agent YAML path used for this run.
132    pub agent: Option<PathBuf>,
133    /// Scenario test cases in this suite.
134    pub scenarios: Option<PathBuf>,
135    /// Directory where output artifacts are written.
136    pub output: PathBuf,
137    /// Scenario IDs selected for execution.
138    pub ids: Vec<String>,
139    /// Tags used by filters and grouped metrics.
140    pub tags: Vec<String>,
141    /// Whether all selected tags must match.
142    pub tag_mode_all: bool,
143    /// Language labels selected for execution.
144    pub languages: Vec<String>,
145    /// Optional retry count or suite retry count.
146    pub retries: Option<u32>,
147    /// Optional timeout override for this turn.
148    pub timeout_ms: Option<u64>,
149    /// Optional scenario concurrency override.
150    pub parallel: Option<usize>,
151    /// Stop after the first failed or errored scenario.
152    pub fail_fast: bool,
153    /// Observability assertion, setting, or report value.
154    pub observability: bool,
155    /// Optional LLM fixture mode override.
156    pub llm_mode: Option<LlmFixtureMode>,
157    /// Optional cassette JSONL file for replay or record mode.
158    pub cassette: Option<PathBuf>,
159}
160
161/// Parsed suite runner with immutable options and suite state.
162pub struct EvalRunner {
163    /// Path to the loaded suite file.
164    suite_path: PathBuf,
165    /// Parsed and validated suite.
166    suite: EvalSuite,
167    /// Runtime options applied to the suite.
168    options: EvalRunnerOptions,
169}
170
171// Bundles attempt-local construction state so resets rebuild with the same isolation, logging, approval, and budget inputs.
172struct BuildAgentParams<'a> {
173    agent_path: &'a Path,
174    base_dir: &'a Path,
175    attempt_context: &'a AttemptFixtureContext,
176    tool_log: RecordingToolLog,
177    approval_log: RecordingApprovalLog,
178    llm_log: RecordingLlmLog,
179    approval_handler: Option<Arc<dyn ai_agents_hitl::ApprovalHandler>>,
180    budget: Option<ScenarioBudgetTracker>,
181}
182
183// Bundles the agent, scenario, turn, and attempt-local evidence logs used to produce one turn result.
184struct RunTurnParams<'a> {
185    agent: &'a RuntimeAgent,
186    scenario: &'a Scenario,
187    turn: &'a Turn,
188    index: usize,
189    tool_log: &'a RecordingToolLog,
190    approval_log: &'a RecordingApprovalLog,
191    llm_log: &'a RecordingLlmLog,
192}
193
194fn load_eval_suite(path: &Path) -> Result<EvalSuite> {
195    let content = std::fs::read_to_string(path)?;
196    if path.extension().and_then(|extension| extension.to_str()) == Some("jsonl") {
197        suite_from_jsonl(
198            path.file_stem()
199                .and_then(|stem| stem.to_str())
200                .unwrap_or("eval")
201                .to_string(),
202            &content,
203        )
204    } else {
205        parse_eval_suite_yaml(&content)
206    }
207}
208
209fn parse_eval_suite_yaml(content: &str) -> Result<EvalSuite> {
210    let mut unknown_fields = Vec::new();
211    let deserializer = serde_yaml::Deserializer::from_str(content);
212    let suite = serde_ignored::deserialize(deserializer, |path| {
213        unknown_fields.push(path.to_string());
214    })?;
215    if !unknown_fields.is_empty() {
216        unknown_fields.sort();
217        unknown_fields.dedup();
218        return Err(EvalError::Config(format!(
219            "unknown eval configuration field(s): {}",
220            unknown_fields.join(", ")
221        )));
222    }
223    Ok(suite)
224}
225
226fn authorize_llm_mode(
227    suite_mode: LlmFixtureMode,
228    override_mode: Option<LlmFixtureMode>,
229) -> Result<()> {
230    if override_mode.is_some()
231        || matches!(suite_mode, LlmFixtureMode::Mock | LlmFixtureMode::Replay)
232    {
233        return Ok(());
234    }
235    match suite_mode {
236        LlmFixtureMode::Real => Err(EvalError::Config(
237            "suite-declared fixtures.llm.mode real requires --real-llm or EvalRunnerOptions.llm_mode = Some(LlmFixtureMode::Real)"
238                .to_string(),
239        )),
240        LlmFixtureMode::Record => Err(EvalError::Config(
241            "suite-declared fixtures.llm.mode record requires --record or EvalRunnerOptions.llm_mode = Some(LlmFixtureMode::Record)"
242                .to_string(),
243        )),
244        LlmFixtureMode::Mock | LlmFixtureMode::Replay => Ok(()),
245    }
246}
247
248impl EvalRunner {
249    pub fn from_file(path: impl AsRef<Path>, options: EvalRunnerOptions) -> Result<Self> {
250        let path = path.as_ref().to_path_buf();
251        let mut suite = load_eval_suite(&path)?;
252        if let Some(agent) = &options.agent {
253            suite.agent = Some(agent.clone());
254        }
255        if let Some(retries) = options.retries {
256            suite.settings.retries = retries;
257        }
258        if let Some(timeout_ms) = options.timeout_ms {
259            suite.settings.timeout_per_turn_ms = timeout_ms;
260        }
261        if let Some(parallel) = options.parallel {
262            suite.settings.parallel = parallel > 1;
263            suite.settings.max_concurrent = parallel.max(1);
264        }
265        if options.fail_fast {
266            suite.settings.fail_fast = true;
267        }
268        authorize_llm_mode(suite.fixtures.llm.mode, options.llm_mode)?;
269        if let Some(mode) = options.llm_mode {
270            suite.fixtures.llm.mode = mode;
271        }
272        if let Some(cassette) = &options.cassette {
273            suite.fixtures.llm.cassette = Some(cassette.clone());
274        }
275        suite.validate(options.agent.as_ref())?;
276        Ok(Self {
277            suite_path: path,
278            suite,
279            options,
280        })
281    }
282
283    pub fn validate_file(path: impl AsRef<Path>, agent_override: Option<PathBuf>) -> Result<()> {
284        let path = path.as_ref();
285        let mut suite = load_eval_suite(path)?;
286        if let Some(agent) = &agent_override {
287            suite.agent = Some(agent.clone());
288        }
289        suite.validate(agent_override.as_ref())?;
290
291        let base_dir = path.parent().unwrap_or_else(|| Path::new("."));
292        let agent_path = if let Some(agent) = agent_override {
293            agent
294        } else {
295            let agent = suite.agent.ok_or_else(|| {
296                EvalError::Config("agent path is required in suite or CLI".into())
297            })?;
298            if agent.is_absolute() {
299                agent
300            } else {
301                base_dir.join(agent)
302            }
303        };
304        let content = std::fs::read_to_string(&agent_path)?;
305        let spec = AgentSpec::from_yaml_strict(&content).map_err(|error| {
306            EvalError::Config(format!(
307                "invalid agent configuration '{}': {}",
308                agent_path.display(),
309                error
310            ))
311        })?;
312        spec.validate().map_err(|error| {
313            EvalError::Config(format!(
314                "invalid agent configuration '{}': {}",
315                agent_path.display(),
316                error
317            ))
318        })
319    }
320
321    pub async fn run(&self) -> Result<EvalResult> {
322        let start = Instant::now();
323        let base_dir = self.suite_path.parent().unwrap_or_else(|| Path::new("."));
324        let scenarios = self.filtered_scenarios();
325        if scenarios.is_empty() {
326            return Err(EvalError::Config(
327                "scenario selection matched zero scenarios".to_string(),
328            ));
329        }
330        let agent_path = self.resolve_agent_path(base_dir)?;
331        let results = if self.suite.settings.parallel && !self.suite.settings.fail_fast {
332            self.run_scenarios_parallel(&agent_path, base_dir, scenarios)
333                .await
334        } else {
335            self.run_scenarios_serial(&agent_path, base_dir, scenarios)
336                .await
337        };
338
339        let total = results.len();
340        let passed = results.iter().filter(|r| r.status.is_passed()).count();
341        let failed = results
342            .iter()
343            .filter(|r| r.status.is_failed() || r.status.is_error())
344            .count();
345        let skipped = results
346            .iter()
347            .filter(|r| matches!(r.status, ScenarioStatus::Skipped { .. }))
348            .count();
349        let metrics = compute_metrics(&results);
350
351        let observability = final_observability_report(&results);
352
353        Ok(EvalResult {
354            schema_version: 1,
355            suite: self.suite.name.clone(),
356            agent: agent_path.display().to_string(),
357            total,
358            passed,
359            failed,
360            skipped,
361            duration_ms: start.elapsed().as_millis() as u64,
362            scenarios: results,
363            metrics,
364            observability,
365        })
366    }
367
368    async fn run_scenarios_serial(
369        &self,
370        agent_path: &Path,
371        base_dir: &Path,
372        scenarios: Vec<&Scenario>,
373    ) -> Vec<ScenarioResult> {
374        let mut results = Vec::new();
375        for scenario in scenarios {
376            let result = self.run_scenario(agent_path, base_dir, scenario).await;
377            match result {
378                Ok(result) => {
379                    let stop = self.suite.settings.fail_fast
380                        && (result.status.is_failed() || result.status.is_error());
381                    results.push(result);
382                    if stop {
383                        break;
384                    }
385                }
386                Err(error) => {
387                    results.push(error_result(scenario, error, FailureCategory::RuntimeError));
388                    if self.suite.settings.fail_fast {
389                        break;
390                    }
391                }
392            }
393        }
394        results
395    }
396
397    async fn run_scenarios_parallel(
398        &self,
399        agent_path: &Path,
400        base_dir: &Path,
401        scenarios: Vec<&Scenario>,
402    ) -> Vec<ScenarioResult> {
403        let max_concurrent = self.suite.settings.max_concurrent.max(1);
404        let mut indexed = stream::iter(scenarios.into_iter().enumerate())
405            .map(|(idx, scenario)| async move {
406                let result = self.run_scenario(agent_path, base_dir, scenario).await;
407                let result = result.unwrap_or_else(|error| {
408                    error_result(scenario, error, FailureCategory::RuntimeError)
409                });
410                (idx, result)
411            })
412            .buffer_unordered(max_concurrent)
413            .collect::<Vec<_>>()
414            .await;
415        indexed.sort_by_key(|(idx, _)| *idx);
416        indexed.into_iter().map(|(_, result)| result).collect()
417    }
418
419    fn resolve_agent_path(&self, base_dir: &Path) -> Result<PathBuf> {
420        if let Some(agent) = &self.options.agent {
421            return Ok(agent.clone());
422        }
423        let agent =
424            self.suite.agent.clone().ok_or_else(|| {
425                EvalError::Config("agent path is required in suite or CLI".into())
426            })?;
427        Ok(if agent.is_absolute() {
428            agent
429        } else {
430            base_dir.join(agent)
431        })
432    }
433
434    fn filtered_scenarios(&self) -> Vec<&Scenario> {
435        let ids: HashSet<_> = self.options.ids.iter().collect();
436        let tags: HashSet<_> = self.options.tags.iter().collect();
437        let languages: HashSet<_> = self.options.languages.iter().collect();
438        self.suite
439            .scenarios
440            .iter()
441            .filter(|scenario| {
442                if !ids.is_empty() && !ids.contains(&scenario.id) {
443                    return false;
444                }
445                if !languages.is_empty() {
446                    let Some(language) = &scenario.language else {
447                        return false;
448                    };
449                    if !languages.contains(language) {
450                        return false;
451                    }
452                }
453                if !tags.is_empty() {
454                    let scenario_tags: HashSet<_> = scenario.tags.iter().collect();
455                    if self.options.tag_mode_all {
456                        if !tags.iter().all(|tag| scenario_tags.contains(*tag)) {
457                            return false;
458                        }
459                    } else if !tags.iter().any(|tag| scenario_tags.contains(*tag)) {
460                        return false;
461                    }
462                }
463                true
464            })
465            .collect()
466    }
467
468    async fn run_scenario(
469        &self,
470        agent_path: &Path,
471        base_dir: &Path,
472        scenario: &Scenario,
473    ) -> Result<ScenarioResult> {
474        let start = Instant::now();
475        if scenario.skip.is_skipped() {
476            return Ok(ScenarioResult {
477                id: scenario.id.clone(),
478                name: scenario.name.clone(),
479                tags: scenario.tags.clone(),
480                language: scenario.language.clone(),
481                status: ScenarioStatus::Skipped {
482                    reason: scenario.skip.reason(),
483                },
484                failure_category: None,
485                flaky: false,
486                attempts: Vec::new(),
487                duration_ms: 0,
488                retries_used: 0,
489            });
490        }
491
492        let mut attempts = Vec::new();
493        let mut final_status = ScenarioStatus::Failed {
494            reason: "not run".to_string(),
495        };
496        let mut category = Some(FailureCategory::AssertionFailed);
497        let max_attempt = self.suite.settings.retries + 1;
498        let budget = if scenario.budget.is_configured() {
499            Some(ScenarioBudgetTracker::new(
500                scenario.budget.clone(),
501                self.budget_cost_estimator(base_dir, scenario)?,
502            ))
503        } else {
504            None
505        };
506
507        for attempt_idx in 0..max_attempt {
508            let attempt_future =
509                self.run_attempt(agent_path, base_dir, scenario, attempt_idx, budget.clone());
510            let attempt = if let Some(timeout_ms) = self.suite.settings.timeout_per_scenario_ms {
511                match timeout(Duration::from_millis(timeout_ms), attempt_future).await {
512                    Ok(result) => result,
513                    Err(_) => Err(EvalError::Runtime(format!(
514                        "scenario '{}' attempt {} timed out after {}ms",
515                        scenario.id, attempt_idx, timeout_ms
516                    ))),
517                }
518            } else {
519                attempt_future.await
520            };
521            match attempt {
522                Ok(attempt_result) => {
523                    final_status = attempt_result.status.clone();
524                    if final_status.is_passed() {
525                        attempts.push(attempt_result);
526                        category = if attempt_idx > 0 {
527                            Some(FailureCategory::FlakyPass)
528                        } else {
529                            None
530                        };
531                        break;
532                    }
533                    category = Some(if final_status.is_error() {
534                        FailureCategory::RuntimeError
535                    } else {
536                        failure_category_for_attempt(&attempt_result)
537                    });
538                    attempts.push(attempt_result);
539                }
540                Err(error) => {
541                    final_status = ScenarioStatus::Error {
542                        message: error.to_string(),
543                    };
544                    category = Some(FailureCategory::RuntimeError);
545                    attempts.push(AttemptResult {
546                        attempt: attempt_idx,
547                        turns: Vec::new(),
548                        status: final_status.clone(),
549                        duration_ms: 0,
550                    });
551                }
552            }
553            if budget
554                .as_ref()
555                .is_some_and(ScenarioBudgetTracker::has_failed)
556            {
557                break;
558            }
559            if attempt_idx + 1 < max_attempt {
560                tokio::time::sleep(Duration::from_millis(self.suite.settings.retry_delay_ms)).await;
561            }
562        }
563
564        let flaky = final_status.is_passed() && attempts.len() > 1;
565        Ok(ScenarioResult {
566            id: scenario.id.clone(),
567            name: scenario.name.clone(),
568            tags: scenario.tags.clone(),
569            language: scenario.language.clone(),
570            status: final_status,
571            failure_category: category,
572            flaky,
573            duration_ms: start.elapsed().as_millis() as u64,
574            retries_used: attempts.len().saturating_sub(1) as u32,
575            attempts,
576        })
577    }
578
579    async fn run_attempt(
580        &self,
581        agent_path: &Path,
582        base_dir: &Path,
583        scenario: &Scenario,
584        attempt: u32,
585        budget: Option<ScenarioBudgetTracker>,
586    ) -> Result<AttemptResult> {
587        let start = Instant::now();
588        let _env_guard = EnvGuard::apply(&scenario.env)?;
589        let mock_server = start_mock_server(self.suite.fixtures.mock_server.as_ref()).await?;
590        let attempt_context = AttemptWorkspace::create(mock_server.as_ref())?;
591        let tool_log = RecordingToolLog::new();
592        let approval_log = RecordingApprovalLog::default();
593        let llm_log = RecordingLlmLog::default();
594        let approval_handler = self
595            .suite
596            .fixtures
597            .approvals
598            .as_ref()
599            .map(build_approval_handler);
600        let mut agent = self
601            .build_agent(BuildAgentParams {
602                agent_path,
603                base_dir,
604                attempt_context: &attempt_context,
605                tool_log: tool_log.clone(),
606                approval_log: approval_log.clone(),
607                llm_log: llm_log.clone(),
608                approval_handler: approval_handler.clone(),
609                budget: budget.clone(),
610            })
611            .await?;
612        apply_base_context(&agent, &self.suite, base_dir, scenario, &attempt_context)?;
613        let mut turns = Vec::new();
614        let mut status = ScenarioStatus::Passed;
615
616        if !scenario.turns.is_empty() {
617            for (idx, turn) in scenario.turns.iter().enumerate() {
618                let turn_execution = self
619                    .run_turn(RunTurnParams {
620                        agent: &agent,
621                        scenario,
622                        turn,
623                        index: idx,
624                        tool_log: &tool_log,
625                        approval_log: &approval_log,
626                        llm_log: &llm_log,
627                    })
628                    .await?;
629                let turn_failed = turn_execution
630                    .result
631                    .assertion_results
632                    .iter()
633                    .any(|result| !result.passed);
634                let runtime_error = turn_execution.unhandled_runtime_error;
635                turns.push(turn_execution.result);
636                if let Some(message) = runtime_error {
637                    status = ScenarioStatus::Error { message };
638                    break;
639                }
640                if turn_failed {
641                    status = ScenarioStatus::Failed {
642                        reason: format!("turn {} assertion failed", idx + 1),
643                    };
644                    break;
645                }
646                if self.suite.settings.isolation == IsolationMode::Turn
647                    && idx + 1 < scenario.turns.len()
648                {
649                    agent.reset().await?;
650                    apply_base_context(&agent, &self.suite, base_dir, scenario, &attempt_context)?;
651                }
652            }
653        }
654
655        for step in &scenario.steps {
656            if !status.is_passed() {
657                break;
658            }
659            match step {
660                ScenarioStep::Run(run) => {
661                    for turn in &run.turns {
662                        let idx = turns.len();
663                        let turn_execution = self
664                            .run_turn(RunTurnParams {
665                                agent: &agent,
666                                scenario,
667                                turn,
668                                index: idx,
669                                tool_log: &tool_log,
670                                approval_log: &approval_log,
671                                llm_log: &llm_log,
672                            })
673                            .await?;
674                        let turn_failed = turn_execution
675                            .result
676                            .assertion_results
677                            .iter()
678                            .any(|result| !result.passed);
679                        let runtime_error = turn_execution.unhandled_runtime_error;
680                        turns.push(turn_execution.result);
681                        if let Some(message) = runtime_error {
682                            status = ScenarioStatus::Error { message };
683                            break;
684                        }
685                        if turn_failed {
686                            status = ScenarioStatus::Failed {
687                                reason: format!("turn {} assertion failed", idx + 1),
688                            };
689                            break;
690                        }
691                    }
692                    if status.is_passed()
693                        && let Some(session) = &run.save_session
694                    {
695                        agent.save_session(session).await?;
696                    }
697                }
698                ScenarioStep::ResetAgent(reset) => {
699                    if let Some(options) = reset_options(reset) {
700                        if options.delete_persistence || !options.preserve_storage {
701                            let _ = std::fs::remove_dir_all(&attempt_context.workspace);
702                            std::fs::create_dir_all(&attempt_context.workspace)?;
703                        }
704                        let preserved_actor = options
705                            .preserve_actor_id
706                            .then(|| agent.actor_id())
707                            .flatten();
708                        if matches!(options.profile, crate::reset::ResetProfile::Conversation)
709                            && !options.delete_persistence
710                        {
711                            agent.reset().await?;
712                        } else {
713                            agent = self
714                                .build_agent(BuildAgentParams {
715                                    agent_path,
716                                    base_dir,
717                                    attempt_context: &attempt_context,
718                                    tool_log: tool_log.clone(),
719                                    approval_log: approval_log.clone(),
720                                    llm_log: llm_log.clone(),
721                                    approval_handler: approval_handler.clone(),
722                                    budget: budget.clone(),
723                                })
724                                .await?;
725                        }
726                        if options.preserve_host_context {
727                            apply_base_context(
728                                &agent,
729                                &self.suite,
730                                base_dir,
731                                scenario,
732                                &attempt_context,
733                            )?;
734                        } else {
735                            apply_context_map(&agent, attempt_context.runtime_context())?;
736                        }
737                        if let Some(actor) = preserved_actor.or_else(|| scenario.actor.clone()) {
738                            agent.set_actor_id(&actor)?;
739                            agent.load_actor_memory().await?;
740                            agent.load_actor_relationship().await?;
741                        }
742                    }
743                }
744                ScenarioStep::SaveSession(name) => {
745                    agent.save_session(name).await?;
746                }
747                ScenarioStep::LoadSession(name) => {
748                    let _ = agent.load_session(name).await?;
749                }
750                ScenarioStep::SetContext { values } => {
751                    apply_context_value(&agent, values)?;
752                }
753                ScenarioStep::SetActor { actor } => {
754                    agent.set_actor_id(actor)?;
755                    agent.load_actor_memory().await?;
756                    agent.load_actor_relationship().await?;
757                }
758                ScenarioStep::CleanupExpired => {
759                    let _ = agent.cleanup_expired_sessions().await?;
760                }
761            }
762        }
763
764        Ok(AttemptResult {
765            attempt,
766            turns,
767            status,
768            duration_ms: start.elapsed().as_millis() as u64,
769        })
770    }
771
772    async fn build_agent(&self, params: BuildAgentParams<'_>) -> Result<RuntimeAgent> {
773        let BuildAgentParams {
774            agent_path,
775            base_dir,
776            attempt_context,
777            tool_log,
778            approval_log,
779            llm_log,
780            approval_handler,
781            budget,
782        } = params;
783        let content = std::fs::read_to_string(agent_path)?;
784        let mut spec = AgentSpec::from_yaml_strict(&content)?;
785        apply_eval_llm_settings(&mut spec, &self.suite.settings);
786        isolate_spec_storage(&mut spec, attempt_context);
787        apply_workspace_policy(
788            &mut spec,
789            self.suite.fixtures.workspace_policy.as_ref(),
790            attempt_context,
791        )?;
792        spec.validate()
793            .map_err(|error| EvalError::Config(error.to_string()))?;
794        let llm_fixture = attempt_context.interpolate_llm_fixture(&self.suite.fixtures.llm)?;
795        let provider_configs = budget_provider_configs(&spec);
796        let (mut llm_registry, _judge_llm) = build_llm_registry(&spec, &llm_fixture, base_dir)?;
797        if let Some(budget) = budget {
798            llm_registry =
799                llm_registry.map_providers(|alias, provider| {
800                    let config = provider_configs.get(alias).cloned().unwrap_or_else(|| {
801                        BudgetProviderConfig {
802                            provider: provider.provider_name().to_string(),
803                            model: alias.to_string(),
804                            max_output_tokens: 2_000,
805                        }
806                    });
807                    budget.wrap(provider, config)
808                });
809        }
810        let tool_registry = build_tool_registry(&self.suite.fixtures, tool_log.clone())?;
811        let agent_base_dir = agent_path.parent().unwrap_or_else(|| Path::new("."));
812        let mut builder = AgentBuilder::from_spec_with_base_dir(spec, agent_base_dir)
813            .llm_registry(llm_registry)
814            .tools(tool_registry)
815            .hooks(Arc::new(EvalRecordHooks {
816                tool_log,
817                approval_log,
818                llm_log,
819            }))
820            .auto_configure_features()
821            .map_err(|error| EvalError::Config(error.to_string()))?
822            .auto_configure_mcp()
823            .await
824            .map_err(|error| EvalError::Config(error.to_string()))?;
825
826        if let Some(approval_handler) = approval_handler {
827            builder = builder.approval_handler(approval_handler);
828        }
829
830        if let Some(observability) = self.observability_config(base_dir)? {
831            let manager = ai_agents_observability::ObservabilityManager::new(observability);
832            builder = builder.observability(manager);
833        }
834        builder = builder
835            .auto_configure_spawner()
836            .await
837            .map_err(|error| EvalError::Config(error.to_string()))?;
838        let agent = builder
839            .build()
840            .map_err(|error| EvalError::Config(error.to_string()))?;
841        agent.init_storage().await?;
842        Ok(agent)
843    }
844
845    fn observability_config(&self, base_dir: &Path) -> Result<Option<ObservabilityConfig>> {
846        let mut config = if let Some(config) = self.suite.observability.clone() {
847            config
848        } else if self.options.observability {
849            ObservabilityConfig {
850                enabled: true,
851                export: ExportConfig {
852                    formats: vec![ExportFormat::Json],
853                    path: self
854                        .options
855                        .output
856                        .join("observability")
857                        .display()
858                        .to_string(),
859                    write_report: true,
860                    ..Default::default()
861                },
862                ..Default::default()
863            }
864        } else {
865            return Ok(None);
866        };
867        if !config.enabled {
868            return Ok(None);
869        }
870        config = config
871            .with_pricing_file_loaded(Some(base_dir))
872            .map_err(|error| EvalError::Config(error.to_string()))?;
873        config
874            .validate()
875            .map_err(|error| EvalError::Config(error.to_string()))?;
876        Ok(Some(config))
877    }
878
879    fn budget_cost_estimator(
880        &self,
881        base_dir: &Path,
882        scenario: &Scenario,
883    ) -> Result<Option<CostEstimator>> {
884        if scenario.budget.max_cost_usd.is_none() {
885            return Ok(None);
886        }
887        let config = self.suite.observability.clone().ok_or_else(|| {
888            EvalError::Config(format!(
889                "scenario '{}' budget.max_cost_usd requires suite observability.cost pricing",
890                scenario.id
891            ))
892        })?;
893        let config = config
894            .with_pricing_file_loaded(Some(base_dir))
895            .map_err(|error| EvalError::Config(error.to_string()))?;
896        if !config.cost.enabled {
897            return Err(EvalError::Config(format!(
898                "scenario '{}' budget.max_cost_usd requires observability.cost.enabled: true",
899                scenario.id
900            )));
901        }
902        Ok(Some(CostEstimator::new(config.cost)))
903    }
904
905    async fn run_turn(&self, params: RunTurnParams<'_>) -> Result<TurnExecution> {
906        let RunTurnParams {
907            agent,
908            scenario,
909            turn,
910            index,
911            tool_log,
912            approval_log,
913            llm_log,
914        } = params;
915        apply_context_value(agent, &turn_runtime_context(turn))?;
916        if let Some(actor) = &turn.actor {
917            agent.set_actor_id(actor)?;
918        }
919        let before_relationship = relationship_snapshot(agent);
920        let tool_start = tool_log.len();
921        let approval_start = approval_log.len();
922        let llm_start = llm_log.len();
923        let start = Instant::now();
924        let timeout_ms = turn
925            .timeout_ms
926            .unwrap_or(self.suite.settings.timeout_per_turn_ms);
927        let mut operation = if turn.stream.unwrap_or(false) {
928            collect_stream_response(agent, &turn.input, timeout_ms).await
929        } else {
930            match timeout(Duration::from_millis(timeout_ms), agent.chat(&turn.input)).await {
931                Ok(Ok(response)) => TurnOperation {
932                    response_content: response.content,
933                    response_metadata: response.metadata,
934                    response_present: true,
935                    runtime_error: None,
936                },
937                Ok(Err(error)) => TurnOperation::error(error.to_string()),
938                Err(_) => TurnOperation::error(format!("turn timed out after {}ms", timeout_ms)),
939            }
940        };
941        if let Err(error) = agent.flush_background_tasks().await
942            && operation.runtime_error.is_none()
943        {
944            operation.runtime_error = Some(error.to_string());
945        }
946        let latency_ms = start.elapsed().as_millis() as u64;
947        let mut evidence = collect_turn_evidence(
948            agent,
949            operation.response_metadata.clone(),
950            tool_log,
951            tool_start,
952            before_relationship,
953        );
954        evidence.approvals = approval_log.records_since(approval_start);
955        evidence.llm_requests = llm_log.records_since(llm_start);
956        let judge = self.build_judge(agent);
957        let mut assertion_results = if let Some(assertion) = &turn.assertions {
958            match evaluate_assertion(
959                assertion,
960                AssertionEvalContext {
961                    evidence: &evidence,
962                    response: &operation.response_content,
963                    user_input: Some(&turn.input),
964                    scenario_id: Some(&scenario.id),
965                    language: scenario.language.as_deref(),
966                    judge_resolver: Some(&judge),
967                },
968            )
969            .await
970            {
971                AssertionOutcome::Passed(details) | AssertionOutcome::Failed(details) => details,
972                AssertionOutcome::Error(message) => return Err(EvalError::Assertion(message)),
973            }
974        } else {
975            Vec::new()
976        };
977        if !operation.response_present
978            && turn
979                .assertions
980                .as_ref()
981                .is_some_and(assertion_uses_response)
982        {
983            assertion_results.push(AssertionResultDetail {
984                assertion: "response_present".to_string(),
985                passed: false,
986                actual: json!(false),
987                expected: json!(true),
988                message: Some("response assertions require a runtime response".to_string()),
989            });
990        }
991
992        let expected_error = turn_expected_error(turn);
993        let unhandled_runtime_error = match (&expected_error, &operation.runtime_error) {
994            (Some(expected), Some(error)) if expected.matches(error) => {
995                assertion_results.push(AssertionResultDetail {
996                    assertion: "expect_error".to_string(),
997                    passed: true,
998                    actual: json!(error),
999                    expected: json!(expected.items()),
1000                    message: None,
1001                });
1002                None
1003            }
1004            (Some(expected), Some(error)) => {
1005                assertion_results.push(AssertionResultDetail {
1006                    assertion: "expect_error".to_string(),
1007                    passed: false,
1008                    actual: json!(error),
1009                    expected: json!(expected.items()),
1010                    message: Some("runtime error did not match any expected substring".to_string()),
1011                });
1012                Some(format!(
1013                    "runtime error did not match expect_error: {}",
1014                    error
1015                ))
1016            }
1017            (Some(expected), None) => {
1018                assertion_results.push(AssertionResultDetail {
1019                    assertion: "expect_error".to_string(),
1020                    passed: false,
1021                    actual: Value::Null,
1022                    expected: json!(expected.items()),
1023                    message: Some("expected a runtime error but the turn completed".to_string()),
1024                });
1025                None
1026            }
1027            (None, Some(error)) => Some(error.clone()),
1028            (None, None) => None,
1029        };
1030        if self.suite.settings.redact_outputs {
1031            redact_assertion_details(&mut assertion_results);
1032        }
1033        let observability_span_id = evidence
1034            .observability
1035            .as_ref()
1036            .and_then(|obs| obs.span_ids.last().cloned());
1037        let runtime_error = operation
1038            .runtime_error
1039            .as_deref()
1040            .map(|error| redact_text(error, self.suite.settings.redact_outputs, 0));
1041        let unhandled_runtime_error = unhandled_runtime_error
1042            .map(|error| redact_text(&error, self.suite.settings.redact_outputs, 0).value);
1043        Ok(TurnExecution {
1044            result: TurnResult {
1045                index,
1046                input: redact_text(&turn.input, self.suite.settings.redact_outputs, 0),
1047                response: if operation.response_present {
1048                    redact_text(
1049                        &operation.response_content,
1050                        self.suite.settings.redact_outputs,
1051                        0,
1052                    )
1053                } else {
1054                    crate::redaction::RedactedString::plain("")
1055                },
1056                response_present: operation.response_present,
1057                runtime_error,
1058                state: evidence.state.clone(),
1059                metadata: if self.suite.settings.redact_outputs {
1060                    None
1061                } else {
1062                    operation
1063                        .response_metadata
1064                        .and_then(|metadata| serde_json::to_value(metadata).ok())
1065                },
1066                evidence,
1067                assertion_results,
1068                latency_ms,
1069                observability_span_id,
1070            },
1071            unhandled_runtime_error,
1072        })
1073    }
1074
1075    fn build_judge(&self, agent: &RuntimeAgent) -> JudgeResolver {
1076        JudgeResolver::new(Arc::clone(agent.llm_registry()), JudgeConfig::default())
1077    }
1078}
1079
1080struct TurnExecution {
1081    result: TurnResult,
1082    unhandled_runtime_error: Option<String>,
1083}
1084
1085struct TurnOperation {
1086    response_content: String,
1087    response_metadata: Option<HashMap<String, Value>>,
1088    response_present: bool,
1089    runtime_error: Option<String>,
1090}
1091
1092impl TurnOperation {
1093    fn error(message: String) -> Self {
1094        Self {
1095            response_content: String::new(),
1096            response_metadata: None,
1097            response_present: false,
1098            runtime_error: Some(message),
1099        }
1100    }
1101}
1102
1103async fn collect_stream_response(
1104    agent: &RuntimeAgent,
1105    input: &str,
1106    timeout_ms: u64,
1107) -> TurnOperation {
1108    let deadline = TokioInstant::now() + Duration::from_millis(timeout_ms);
1109    let stream = match timeout_at(deadline, agent.chat_stream_events(input)).await {
1110        Ok(Ok(stream)) => stream,
1111        Ok(Err(error)) => return TurnOperation::error(error.to_string()),
1112        Err(_) => return TurnOperation::error(format!("turn timed out after {}ms", timeout_ms)),
1113    };
1114    consume_stream_response(stream, deadline, timeout_ms).await
1115}
1116
1117// Uses the final response as the successful turn result and retains chunk text only as diagnostics when the stream fails before finalization.
1118async fn consume_stream_response<S>(
1119    mut stream: S,
1120    deadline: TokioInstant,
1121    timeout_ms: u64,
1122) -> TurnOperation
1123where
1124    S: futures::Stream<Item = AgentStreamEvent> + Unpin,
1125{
1126    let mut partial_content = String::new();
1127    let mut partial_content_seen = false;
1128    let mut runtime_error = None;
1129    let mut final_response = None;
1130    loop {
1131        match timeout_at(deadline, stream.next()).await {
1132            Ok(Some(AgentStreamEvent::Chunk(StreamChunk::Content { text }))) => {
1133                partial_content_seen = true;
1134                partial_content.push_str(&text);
1135            }
1136            Ok(Some(AgentStreamEvent::Chunk(StreamChunk::Error { message }))) => {
1137                if runtime_error.is_none() {
1138                    runtime_error = Some(message);
1139                }
1140            }
1141            Ok(Some(AgentStreamEvent::Chunk(_))) => {}
1142            Ok(Some(AgentStreamEvent::Final(response))) => {
1143                final_response = Some(response);
1144                break;
1145            }
1146            Ok(Some(_)) => {}
1147            Ok(None) => break,
1148            Err(_) => {
1149                if runtime_error.is_none() {
1150                    runtime_error = Some(format!("turn timed out after {}ms", timeout_ms));
1151                }
1152                break;
1153            }
1154        }
1155    }
1156
1157    if let Some(response) = final_response {
1158        return TurnOperation {
1159            response_content: response.content,
1160            response_metadata: response.metadata,
1161            response_present: true,
1162            runtime_error,
1163        };
1164    }
1165    if runtime_error.is_none() {
1166        runtime_error = Some("stream ended before Final".to_string());
1167    }
1168    TurnOperation {
1169        response_content: partial_content,
1170        response_metadata: None,
1171        response_present: partial_content_seen,
1172        runtime_error,
1173    }
1174}
1175
1176fn assertion_uses_response(assertion: &Assertion) -> bool {
1177    assertion.response_contains.is_some()
1178        || assertion.response_contains_any.is_some()
1179        || assertion.response_not_contains.is_some()
1180        || assertion.response_not_empty.is_some()
1181        || assertion.response_semantic.is_some()
1182        || assertion.judge.is_some()
1183        || assertion
1184            .all
1185            .as_ref()
1186            .is_some_and(|children| children.iter().any(assertion_uses_response))
1187        || assertion
1188            .any
1189            .as_ref()
1190            .is_some_and(|children| children.iter().any(assertion_uses_response))
1191        || assertion
1192            .not
1193            .as_deref()
1194            .is_some_and(assertion_uses_response)
1195}
1196
1197fn apply_workspace_policy(
1198    spec: &mut AgentSpec,
1199    config: Option<&WorkspacePolicyFixtureConfig>,
1200    context: &AttemptFixtureContext,
1201) -> Result<()> {
1202    let Some(config) = config else {
1203        return Ok(());
1204    };
1205    if !context.workspace.is_absolute() {
1206        return Err(EvalError::Config(
1207            "eval attempt workspace must be absolute".to_string(),
1208        ));
1209    }
1210
1211    for tool_id in config.read_tools.iter().chain(&config.write_tools) {
1212        if !spec.tool_security.tools.contains_key(tool_id) {
1213            return Err(EvalError::Config(format!(
1214                "fixtures.workspace_policy names tool '{}' without an existing tool policy",
1215                tool_id
1216            )));
1217        }
1218    }
1219
1220    let workspace = context.workspace.display().to_string();
1221    for tool_id in &config.read_tools {
1222        spec.tool_security
1223            .tools
1224            .get_mut(tool_id)
1225            .expect("workspace policy tool was validated")
1226            .read_paths
1227            .push(workspace.clone());
1228    }
1229    for tool_id in &config.write_tools {
1230        spec.tool_security
1231            .tools
1232            .get_mut(tool_id)
1233            .expect("workspace policy tool was validated")
1234            .write_paths
1235            .push(workspace.clone());
1236    }
1237    Ok(())
1238}
1239
1240fn isolate_spec_storage(spec: &mut AgentSpec, context: &AttemptFixtureContext) {
1241    isolate_storage_config(
1242        &mut spec.storage,
1243        context,
1244        "parent-storage",
1245        "parent-storage.db",
1246    );
1247    if let Some(shared_storage) = spec
1248        .spawner
1249        .as_mut()
1250        .and_then(|spawner| spawner.shared_storage.as_mut())
1251    {
1252        isolate_storage_config(
1253            shared_storage,
1254            context,
1255            "spawner-shared-storage",
1256            "spawner-shared-storage.db",
1257        );
1258    }
1259}
1260
1261fn isolate_storage_config(
1262    storage: &mut StorageConfig,
1263    context: &AttemptFixtureContext,
1264    file_name: &str,
1265    sqlite_name: &str,
1266) {
1267    match storage {
1268        StorageConfig::File(config) => {
1269            config.path = context.workspace.join(file_name).display().to_string();
1270        }
1271        StorageConfig::Sqlite(config) => {
1272            config.path = context.workspace.join(sqlite_name).display().to_string();
1273        }
1274        StorageConfig::Redis(config) => {
1275            let prefix = config.prefix.as_deref().unwrap_or("agent:");
1276            config.prefix = Some(format!("{}eval:{}:", prefix, context.isolation_id));
1277        }
1278        StorageConfig::None => {}
1279    }
1280}
1281
1282fn apply_context_map(agent: &RuntimeAgent, values: HashMap<String, Value>) -> Result<()> {
1283    for (key, value) in values {
1284        agent.set_context(&key, value)?;
1285    }
1286    Ok(())
1287}
1288
1289fn apply_context_value(agent: &RuntimeAgent, value: &Value) -> Result<()> {
1290    let Value::Object(map) = value else {
1291        return Ok(());
1292    };
1293    for (key, value) in map {
1294        agent.set_context(key, value.clone())?;
1295    }
1296    Ok(())
1297}
1298
1299fn apply_base_context(
1300    agent: &RuntimeAgent,
1301    suite: &EvalSuite,
1302    base_dir: &Path,
1303    scenario: &Scenario,
1304    attempt_context: &AttemptFixtureContext,
1305) -> Result<()> {
1306    apply_context_map(agent, resolve_fixture_context(&suite.fixtures, base_dir)?)?;
1307    apply_context_map(agent, attempt_context.runtime_context())?;
1308    apply_context_value(agent, &scenario.context)?;
1309    if let Some(actor) = &scenario.actor {
1310        agent.set_actor_id(actor)?;
1311    }
1312    Ok(())
1313}
1314
1315fn reset_options(config: &ResetStepConfig) -> Option<crate::reset::ResetOptions> {
1316    match config {
1317        ResetStepConfig::Bool(false) => None,
1318        ResetStepConfig::Bool(true) => Some(crate::reset::ResetOptions::default()),
1319        ResetStepConfig::Options(options) => Some(options.clone()),
1320    }
1321}
1322
1323fn redact_assertion_details(details: &mut [crate::assertion::AssertionResultDetail]) {
1324    for detail in details {
1325        detail.actual = redact_value(std::mem::take(&mut detail.actual), true, 0);
1326        detail.expected = redact_value(std::mem::take(&mut detail.expected), true, 0);
1327    }
1328}
1329
1330fn error_result(
1331    scenario: &Scenario,
1332    error: EvalError,
1333    category: FailureCategory,
1334) -> ScenarioResult {
1335    ScenarioResult {
1336        id: scenario.id.clone(),
1337        name: scenario.name.clone(),
1338        tags: scenario.tags.clone(),
1339        language: scenario.language.clone(),
1340        status: ScenarioStatus::Error {
1341            message: error.to_string(),
1342        },
1343        failure_category: Some(category),
1344        flaky: false,
1345        attempts: Vec::new(),
1346        duration_ms: 0,
1347        retries_used: 0,
1348    }
1349}
1350
1351fn failure_category_for_attempt(attempt: &AttemptResult) -> FailureCategory {
1352    let judge_failed = attempt.turns.iter().any(|turn| {
1353        turn.assertion_results
1354            .iter()
1355            .any(|detail| !detail.passed && detail.assertion == "judge")
1356    });
1357    if judge_failed {
1358        FailureCategory::JudgeError
1359    } else {
1360        FailureCategory::AssertionFailed
1361    }
1362}
1363
1364fn final_observability_report(
1365    results: &[ScenarioResult],
1366) -> Option<ai_agents_observability::ObservabilityReport> {
1367    results
1368        .iter()
1369        .rev()
1370        .flat_map(|scenario| scenario.attempts.iter().rev())
1371        .flat_map(|attempt| attempt.turns.iter().rev())
1372        .find_map(|turn| {
1373            turn.evidence
1374                .observability
1375                .as_ref()
1376                .and_then(|obs| obs.report.clone())
1377        })
1378}
1379
1380fn apply_eval_llm_settings(spec: &mut AgentSpec, settings: &crate::suite::EvalSettings) {
1381    if let LLMConfigOrSelector::Config(config) = &mut spec.llm {
1382        apply_llm_config_settings(config, settings);
1383    }
1384    for config in spec.llms.values_mut() {
1385        apply_llm_config_settings(config, settings);
1386    }
1387}
1388
1389fn budget_provider_configs(spec: &AgentSpec) -> HashMap<String, BudgetProviderConfig> {
1390    if spec.llms.is_empty() {
1391        let config = spec.llm.as_config().cloned().unwrap_or_default();
1392        return HashMap::from([(
1393            "default".to_string(),
1394            BudgetProviderConfig {
1395                provider: config.provider,
1396                model: config.model,
1397                max_output_tokens: config.max_tokens,
1398            },
1399        )]);
1400    }
1401    spec.llms
1402        .iter()
1403        .map(|(alias, config)| {
1404            (
1405                alias.clone(),
1406                BudgetProviderConfig {
1407                    provider: config.provider.clone(),
1408                    model: config.model.clone(),
1409                    max_output_tokens: config.max_tokens,
1410                },
1411            )
1412        })
1413        .collect()
1414}
1415
1416fn apply_llm_config_settings(
1417    config: &mut ai_agents_runtime::spec::LLMConfig,
1418    settings: &crate::suite::EvalSettings,
1419) {
1420    if let Some(temperature) = settings.temperature {
1421        config.temperature = temperature;
1422    }
1423    if let Some(seed) = settings.seed {
1424        config.extra.insert("seed".to_string(), json!(seed));
1425    }
1426}
1427
1428/// Process-wide exclusion held for an eval attempt's environment access.
1429enum EnvExclusionGuard {
1430    Read {
1431        _guard: RwLockReadGuard<'static, ()>,
1432    },
1433    Write {
1434        _guard: RwLockWriteGuard<'static, ()>,
1435    },
1436}
1437
1438/// Restores process environment variables when an attempt ends.
1439struct EnvGuard {
1440    /// Previous env values restored on drop.
1441    previous: Vec<(String, Option<String>)>,
1442    /// Shared access for unchanged env or exclusive access for mutations.
1443    _guard: EnvExclusionGuard,
1444}
1445
1446impl EnvGuard {
1447    fn apply(values: &HashMap<String, String>) -> Result<Self> {
1448        static ENV_LOCK: OnceLock<RwLock<()>> = OnceLock::new();
1449        let lock = ENV_LOCK.get_or_init(|| RwLock::new(()));
1450        if values.is_empty() {
1451            let guard = lock.read().map_err(|_| {
1452                EvalError::Runtime("failed to lock eval environment guard".to_string())
1453            })?;
1454            return Ok(Self {
1455                previous: Vec::new(),
1456                _guard: EnvExclusionGuard::Read { _guard: guard },
1457            });
1458        }
1459        let guard = lock
1460            .write()
1461            .map_err(|_| EvalError::Runtime("failed to lock eval environment guard".to_string()))?;
1462        let mut previous = Vec::new();
1463        for (key, value) in values {
1464            previous.push((key.clone(), std::env::var(key).ok()));
1465            unsafe {
1466                std::env::set_var(key, value);
1467            }
1468        }
1469        Ok(Self {
1470            previous,
1471            _guard: EnvExclusionGuard::Write { _guard: guard },
1472        })
1473    }
1474}
1475
1476impl Drop for EnvGuard {
1477    fn drop(&mut self) {
1478        for (key, value) in self.previous.drain(..).rev() {
1479            unsafe {
1480                if let Some(value) = value {
1481                    std::env::set_var(key, value);
1482                } else {
1483                    std::env::remove_var(key);
1484                }
1485            }
1486        }
1487    }
1488}
1489
1490#[cfg(test)]
1491mod tests {
1492    use super::*;
1493
1494    fn attempt_workspace(id: &str) -> PathBuf {
1495        std::env::temp_dir().join(format!("ai-agents-eval-{id}"))
1496    }
1497
1498    #[test]
1499    fn strict_suite_loader_rejects_nested_observability_typos() {
1500        let error = parse_eval_suite_yaml(
1501            r#"
1502name: strict
1503agent: agent.yaml
1504observability:
1505  enabeld: true
1506scenarios:
1507  - id: scenario
1508    turns:
1509      - input: hello
1510"#,
1511        )
1512        .unwrap_err()
1513        .to_string();
1514
1515        assert!(error.contains("enabeld"), "{error}");
1516    }
1517
1518    #[test]
1519    fn storage_isolation_rewrites_parent_and_spawner_backends() {
1520        let workspace = attempt_workspace("attempt-a");
1521        let context = AttemptFixtureContext {
1522            isolation_id: "attempt-a".to_string(),
1523            workspace: workspace.clone(),
1524            mock_server_base_url: None,
1525        };
1526        let mut file_spec: AgentSpec = serde_yaml::from_str(
1527            r#"
1528name: FileAgent
1529system_prompt: test
1530storage: { type: file, path: ./parent }
1531spawner:
1532  shared_storage: { type: sqlite, path: ./shared.db, table: shared_sessions }
1533"#,
1534        )
1535        .unwrap();
1536        isolate_spec_storage(&mut file_spec, &context);
1537        assert_eq!(
1538            file_spec.storage.get_path().map(PathBuf::from),
1539            Some(workspace.join("parent-storage"))
1540        );
1541        let shared = file_spec
1542            .spawner
1543            .as_ref()
1544            .unwrap()
1545            .shared_storage
1546            .as_ref()
1547            .unwrap();
1548        assert_eq!(
1549            shared.get_path().map(PathBuf::from),
1550            Some(workspace.join("spawner-shared-storage.db"))
1551        );
1552        assert_eq!(shared.get_table(), Some("shared_sessions"));
1553
1554        let mut redis_spec: AgentSpec = serde_yaml::from_str(
1555            r#"
1556name: RedisAgent
1557system_prompt: test
1558storage: { type: redis, url: redis://localhost, prefix: "parent:" }
1559spawner:
1560  shared_storage: { type: redis, url: redis://localhost }
1561"#,
1562        )
1563        .unwrap();
1564        isolate_spec_storage(&mut redis_spec, &context);
1565        assert_eq!(redis_spec.storage.get_prefix(), "parent:eval:attempt-a:");
1566        assert_eq!(
1567            redis_spec
1568                .spawner
1569                .as_ref()
1570                .unwrap()
1571                .shared_storage
1572                .as_ref()
1573                .unwrap()
1574                .get_prefix(),
1575            "agent:eval:attempt-a:"
1576        );
1577
1578        let other_context = AttemptFixtureContext {
1579            isolation_id: "attempt-b".to_string(),
1580            workspace: attempt_workspace("attempt-b"),
1581            mock_server_base_url: None,
1582        };
1583        let mut other_redis: AgentSpec = serde_yaml::from_str(
1584            r#"
1585name: RedisAgent
1586system_prompt: test
1587storage: { type: redis, url: redis://localhost, prefix: "parent:" }
1588"#,
1589        )
1590        .unwrap();
1591        isolate_spec_storage(&mut other_redis, &other_context);
1592        assert_ne!(
1593            redis_spec.storage.get_prefix(),
1594            other_redis.storage.get_prefix()
1595        );
1596    }
1597
1598    #[test]
1599    fn workspace_policy_is_narrow_and_isolated_per_attempt() {
1600        let source: AgentSpec = serde_yaml::from_str(
1601            r#"
1602name: PolicyAgent
1603system_prompt: test
1604tool_security:
1605  enabled: true
1606  fail_closed: true
1607  tools:
1608    file_read:
1609      read_paths: [./source]
1610      blocked_paths: [./blocked]
1611    file_write:
1612      write_paths: [./output]
1613      blocked_paths: [./blocked]
1614    grep:
1615      read_paths: [./repository]
1616"#,
1617        )
1618        .unwrap();
1619        let config = WorkspacePolicyFixtureConfig {
1620            read_tools: vec!["file_read".to_string()],
1621            write_tools: vec!["file_write".to_string()],
1622        };
1623        let first_workspace = attempt_workspace("attempt-a");
1624        let second_workspace = attempt_workspace("attempt-b");
1625        let first_context = AttemptFixtureContext {
1626            isolation_id: "attempt-a".to_string(),
1627            workspace: first_workspace.clone(),
1628            mock_server_base_url: None,
1629        };
1630        let second_context = AttemptFixtureContext {
1631            isolation_id: "attempt-b".to_string(),
1632            workspace: second_workspace.clone(),
1633            mock_server_base_url: None,
1634        };
1635
1636        let mut first = source.clone();
1637        apply_workspace_policy(&mut first, Some(&config), &first_context).unwrap();
1638        let mut second = source.clone();
1639        apply_workspace_policy(&mut second, Some(&config), &second_context).unwrap();
1640
1641        assert!(first.tool_security.fail_closed);
1642        assert_eq!(
1643            first.tool_security.tools["file_read"].read_paths,
1644            vec![
1645                "./source".to_string(),
1646                first_workspace.display().to_string()
1647            ]
1648        );
1649        assert_eq!(
1650            first.tool_security.tools["file_write"].write_paths,
1651            vec![
1652                "./output".to_string(),
1653                first_workspace.display().to_string()
1654            ]
1655        );
1656        assert_eq!(
1657            first.tool_security.tools["file_read"].blocked_paths,
1658            vec!["./blocked"]
1659        );
1660        assert_eq!(
1661            first.tool_security.tools["file_write"].blocked_paths,
1662            vec!["./blocked"]
1663        );
1664        assert_eq!(
1665            first.tool_security.tools["grep"].read_paths,
1666            vec!["./repository"]
1667        );
1668        assert_eq!(
1669            second.tool_security.tools["file_read"].read_paths,
1670            vec![
1671                "./source".to_string(),
1672                second_workspace.display().to_string()
1673            ]
1674        );
1675        assert_eq!(
1676            source.tool_security.tools["file_read"].read_paths,
1677            vec!["./source"]
1678        );
1679        assert_eq!(
1680            source.tool_security.tools["file_write"].write_paths,
1681            vec!["./output"]
1682        );
1683    }
1684
1685    #[test]
1686    fn workspace_policy_rejects_unknown_tools_without_partial_mutation() {
1687        let mut spec: AgentSpec = serde_yaml::from_str(
1688            r#"
1689name: PolicyAgent
1690system_prompt: test
1691tool_security:
1692  enabled: true
1693  fail_closed: true
1694  tools:
1695    file_read:
1696      read_paths: [./source]
1697"#,
1698        )
1699        .unwrap();
1700        let original = spec.tool_security.tools["file_read"].read_paths.clone();
1701        let config = WorkspacePolicyFixtureConfig {
1702            read_tools: vec!["file_read".to_string(), "missing_tool".to_string()],
1703            write_tools: Vec::new(),
1704        };
1705        let context = AttemptFixtureContext {
1706            isolation_id: "attempt-a".to_string(),
1707            workspace: attempt_workspace("attempt-a"),
1708            mock_server_base_url: None,
1709        };
1710
1711        let error = apply_workspace_policy(&mut spec, Some(&config), &context).unwrap_err();
1712
1713        assert!(error.to_string().contains("missing_tool"));
1714        assert!(
1715            error
1716                .to_string()
1717                .contains("without an existing tool policy")
1718        );
1719        assert_eq!(spec.tool_security.tools["file_read"].read_paths, original);
1720        assert!(spec.tool_security.fail_closed);
1721    }
1722
1723    #[tokio::test]
1724    async fn generated_attempt_context_has_stable_reset_precedence() {
1725        let dir = std::env::temp_dir().join(format!(
1726            "ai_agents_eval_attempt_context_test_{}",
1727            uuid::Uuid::new_v4()
1728        ));
1729        std::fs::create_dir_all(&dir).unwrap();
1730        write_test_agent(&dir);
1731        let suite_path = dir.join("suite.yaml");
1732        std::fs::write(
1733            &suite_path,
1734            r#"
1735name: Attempt Context
1736agent: agent.yaml
1737fixtures:
1738  context:
1739    eval: { workspace: fixture-value }
1740    mock_server: { base_url: fixture-value }
1741    fixture_only: true
1742  llm:
1743    mode: mock
1744    responses: [ok]
1745scenarios:
1746  - id: context
1747    context: { scenario_only: true, precedence: scenario }
1748    turns:
1749      - input: test
1750        context: { precedence: turn }
1751"#,
1752        )
1753        .unwrap();
1754        let runner = EvalRunner::from_file(
1755            &suite_path,
1756            EvalRunnerOptions {
1757                output: dir.join("out"),
1758                ..Default::default()
1759            },
1760        )
1761        .unwrap();
1762        let attempt_context = AttemptFixtureContext {
1763            isolation_id: "stable-attempt".to_string(),
1764            workspace: dir
1765                .join("workspace")
1766                .canonicalize()
1767                .unwrap_or_else(|_| dir.join("workspace")),
1768            mock_server_base_url: Some("http://127.0.0.1:40000".to_string()),
1769        };
1770        std::fs::create_dir_all(&attempt_context.workspace).unwrap();
1771        let scenario = &runner.suite.scenarios[0];
1772        let tool_log = RecordingToolLog::new();
1773        let approval_log = RecordingApprovalLog::default();
1774        let llm_log = RecordingLlmLog::default();
1775        let first = runner
1776            .build_agent(BuildAgentParams {
1777                agent_path: &dir.join("agent.yaml"),
1778                base_dir: &dir,
1779                attempt_context: &attempt_context,
1780                tool_log: tool_log.clone(),
1781                approval_log: approval_log.clone(),
1782                llm_log: llm_log.clone(),
1783                approval_handler: None,
1784                budget: None,
1785            })
1786            .await
1787            .unwrap();
1788        apply_base_context(&first, &runner.suite, &dir, scenario, &attempt_context).unwrap();
1789        let first_context = first.get_context();
1790        assert_eq!(
1791            first_context["eval"]["workspace"],
1792            json!(attempt_context.workspace.display().to_string())
1793        );
1794        assert_eq!(
1795            first_context["mock_server"]["base_url"],
1796            "http://127.0.0.1:40000"
1797        );
1798        assert_eq!(first_context["precedence"], "scenario");
1799        apply_context_value(&first, &turn_runtime_context(&scenario.turns[0])).unwrap();
1800        assert_eq!(first.get_context()["precedence"], "turn");
1801
1802        let reset = runner
1803            .build_agent(BuildAgentParams {
1804                agent_path: &dir.join("agent.yaml"),
1805                base_dir: &dir,
1806                attempt_context: &attempt_context,
1807                tool_log,
1808                approval_log,
1809                llm_log,
1810                approval_handler: None,
1811                budget: None,
1812            })
1813            .await
1814            .unwrap();
1815        apply_base_context(&reset, &runner.suite, &dir, scenario, &attempt_context).unwrap();
1816        assert_eq!(reset.get_context()["eval"], first_context["eval"]);
1817        assert_eq!(
1818            reset.get_context()["mock_server"],
1819            first_context["mock_server"]
1820        );
1821        let _ = std::fs::remove_dir_all(dir);
1822    }
1823
1824    #[tokio::test]
1825    async fn streaming_final_response_is_authoritative() {
1826        let response = ai_agents_runtime::AgentResponse::new("authoritative")
1827            .with_metadata("source", json!("final"));
1828        let events = stream::iter(vec![
1829            AgentStreamEvent::Chunk(StreamChunk::Content {
1830                text: "partial content".to_string(),
1831            }),
1832            AgentStreamEvent::Chunk(StreamChunk::Done {}),
1833            AgentStreamEvent::Final(response),
1834        ]);
1835        let operation =
1836            consume_stream_response(events, TokioInstant::now() + Duration::from_secs(1), 1_000)
1837                .await;
1838
1839        assert_eq!(operation.response_content, "authoritative");
1840        assert_eq!(
1841            operation
1842                .response_metadata
1843                .as_ref()
1844                .and_then(|metadata| metadata.get("source")),
1845            Some(&json!("final"))
1846        );
1847        assert!(operation.response_present);
1848        assert_eq!(operation.runtime_error, None);
1849    }
1850
1851    #[tokio::test]
1852    async fn streaming_final_response_is_present_without_content_chunks() {
1853        let events = stream::iter(vec![AgentStreamEvent::Final(
1854            ai_agents_runtime::AgentResponse::new(""),
1855        )]);
1856        let operation =
1857            consume_stream_response(events, TokioInstant::now() + Duration::from_secs(1), 1_000)
1858                .await;
1859
1860        assert_eq!(operation.response_content, "");
1861        assert!(operation.response_present);
1862        assert_eq!(operation.runtime_error, None);
1863    }
1864
1865    #[tokio::test]
1866    async fn streaming_error_retains_partial_content_and_consumes_until_eof() {
1867        let events = stream::iter(vec![
1868            AgentStreamEvent::Chunk(StreamChunk::Content {
1869                text: "before ".to_string(),
1870            }),
1871            AgentStreamEvent::Chunk(StreamChunk::Error {
1872                message: "stream failed".to_string(),
1873            }),
1874            AgentStreamEvent::Chunk(StreamChunk::Content {
1875                text: "after".to_string(),
1876            }),
1877            AgentStreamEvent::Chunk(StreamChunk::Done {}),
1878        ]);
1879        let operation =
1880            consume_stream_response(events, TokioInstant::now() + Duration::from_secs(1), 1_000)
1881                .await;
1882
1883        assert_eq!(operation.response_content, "before after");
1884        assert!(operation.response_present);
1885        assert_eq!(operation.response_metadata, None);
1886        assert_eq!(operation.runtime_error.as_deref(), Some("stream failed"));
1887    }
1888
1889    #[tokio::test]
1890    async fn streaming_eof_without_final_is_incomplete() {
1891        let events = stream::iter(vec![AgentStreamEvent::Chunk(StreamChunk::Content {
1892            text: "partial".to_string(),
1893        })]);
1894        let operation =
1895            consume_stream_response(events, TokioInstant::now() + Duration::from_secs(1), 1_000)
1896                .await;
1897
1898        assert_eq!(operation.response_content, "partial");
1899        assert!(operation.response_present);
1900        assert_eq!(operation.response_metadata, None);
1901        assert_eq!(
1902            operation.runtime_error.as_deref(),
1903            Some("stream ended before Final")
1904        );
1905    }
1906
1907    #[test]
1908    fn dry_config_check_validates_real_suite_and_agent_without_authorization() {
1909        let dir = std::env::temp_dir().join(format!(
1910            "ai_agents_eval_dry_config_test_{}",
1911            uuid::Uuid::new_v4()
1912        ));
1913        std::fs::create_dir_all(&dir).unwrap();
1914        let suite_path = dir.join("suite.yaml");
1915        std::fs::write(
1916            &suite_path,
1917            r#"
1918name: Dry Config
1919agent: agent.yaml
1920fixtures:
1921  llm:
1922    mode: real
1923scenarios:
1924  - id: live
1925    turns:
1926      - input: hello
1927"#,
1928        )
1929        .unwrap();
1930        std::fs::write(
1931            dir.join("agent.yaml"),
1932            "name: TestAgent\nsystem_prompt: test\n",
1933        )
1934        .unwrap();
1935
1936        EvalRunner::validate_file(&suite_path, None).unwrap();
1937
1938        std::fs::write(
1939            dir.join("agent.yaml"),
1940            "name: TestAgent\nsystem_prompt: test\nmax_iteratons: 3\n",
1941        )
1942        .unwrap();
1943        let error = EvalRunner::validate_file(&suite_path, None).unwrap_err();
1944        assert!(error.to_string().contains("max_iteratons"));
1945        let _ = std::fs::remove_dir_all(dir);
1946    }
1947
1948    #[test]
1949    fn real_and_record_modes_require_explicit_authorization() {
1950        let dir = std::env::temp_dir().join(format!(
1951            "ai_agents_eval_authorization_test_{}",
1952            uuid::Uuid::new_v4()
1953        ));
1954        std::fs::create_dir_all(&dir).unwrap();
1955        let suite_path = dir.join("suite.yaml");
1956        std::fs::write(
1957            &suite_path,
1958            r#"
1959name: Authorization
1960agent: agent.yaml
1961fixtures:
1962  llm:
1963    mode: real
1964scenarios:
1965  - id: authorized
1966    turns:
1967      - input: hello
1968"#,
1969        )
1970        .unwrap();
1971
1972        let error = EvalRunner::from_file(&suite_path, EvalRunnerOptions::default())
1973            .err()
1974            .expect("real mode should require authorization");
1975        assert!(error.to_string().contains("--real-llm"));
1976        assert!(
1977            EvalRunner::from_file(
1978                &suite_path,
1979                EvalRunnerOptions {
1980                    llm_mode: Some(LlmFixtureMode::Real),
1981                    ..Default::default()
1982                },
1983            )
1984            .is_ok()
1985        );
1986
1987        let record_suite = std::fs::read_to_string(&suite_path)
1988            .unwrap()
1989            .replace("mode: real", "mode: record");
1990        std::fs::write(&suite_path, record_suite).unwrap();
1991        let error = EvalRunner::from_file(&suite_path, EvalRunnerOptions::default())
1992            .err()
1993            .expect("record mode should require authorization");
1994        assert!(error.to_string().contains("--record"));
1995        assert!(
1996            EvalRunner::from_file(
1997                &suite_path,
1998                EvalRunnerOptions {
1999                    llm_mode: Some(LlmFixtureMode::Record),
2000                    ..Default::default()
2001                },
2002            )
2003            .is_ok()
2004        );
2005        let _ = std::fs::remove_dir_all(dir);
2006    }
2007
2008    #[tokio::test]
2009    async fn zero_selected_scenarios_is_an_error() {
2010        let dir = std::env::temp_dir().join(format!(
2011            "ai_agents_eval_zero_selection_test_{}",
2012            uuid::Uuid::new_v4()
2013        ));
2014        std::fs::create_dir_all(&dir).unwrap();
2015        let suite_path = dir.join("suite.yaml");
2016        std::fs::write(
2017            &suite_path,
2018            r#"
2019name: Selection
2020agent: agent.yaml
2021fixtures:
2022  llm:
2023    mode: mock
2024    responses: [ok]
2025scenarios:
2026  - id: present
2027    turns:
2028      - input: hello
2029"#,
2030        )
2031        .unwrap();
2032        let runner = EvalRunner::from_file(
2033            &suite_path,
2034            EvalRunnerOptions {
2035                ids: vec!["missing".to_string()],
2036                ..Default::default()
2037            },
2038        )
2039        .unwrap();
2040
2041        let error = runner.run().await.unwrap_err();
2042
2043        assert!(error.to_string().contains("matched zero scenarios"));
2044        let _ = std::fs::remove_dir_all(dir);
2045    }
2046
2047    #[test]
2048    fn env_guard_allows_readers_and_excludes_writer() {
2049        use std::sync::{Barrier, mpsc};
2050        use std::thread;
2051
2052        let start = Arc::new(Barrier::new(3));
2053        let (acquired_tx, acquired_rx) = mpsc::channel();
2054        let mut releases = Vec::new();
2055        let mut readers = Vec::new();
2056        for index in 0..2 {
2057            let start = Arc::clone(&start);
2058            let acquired_tx = acquired_tx.clone();
2059            let (release_tx, release_rx) = mpsc::channel();
2060            releases.push(release_tx);
2061            readers.push(thread::spawn(move || {
2062                start.wait();
2063                let guard = EnvGuard::apply(&HashMap::new()).unwrap();
2064                acquired_tx.send(index).unwrap();
2065                release_rx.recv().unwrap();
2066                drop(guard);
2067            }));
2068        }
2069        start.wait();
2070        let first = acquired_rx.recv_timeout(Duration::from_secs(1));
2071        let second = acquired_rx.recv_timeout(Duration::from_secs(1));
2072        for release in releases {
2073            release.send(()).unwrap();
2074        }
2075        for reader in readers {
2076            reader.join().unwrap();
2077        }
2078        assert_ne!(first.unwrap(), second.unwrap());
2079
2080        let key = format!("AI_AGENTS_EVAL_ENV_TEST_{}", uuid::Uuid::new_v4());
2081        unsafe {
2082            std::env::remove_var(&key);
2083        }
2084        let reader = EnvGuard::apply(&HashMap::new()).unwrap();
2085        assert!(matches!(&reader._guard, EnvExclusionGuard::Read { .. }));
2086        let writer_start = Arc::new(Barrier::new(2));
2087        let writer_start_thread = Arc::clone(&writer_start);
2088        let (writer_tx, writer_rx) = mpsc::channel();
2089        let writer_key = key.clone();
2090        let writer = thread::spawn(move || {
2091            writer_start_thread.wait();
2092            let guard = EnvGuard::apply(&HashMap::from([(
2093                writer_key.clone(),
2094                "temporary".to_string(),
2095            )]))
2096            .unwrap();
2097            writer_tx.send(()).unwrap();
2098            assert_eq!(std::env::var(&writer_key).as_deref(), Ok("temporary"));
2099            drop(guard);
2100        });
2101        writer_start.wait();
2102        let writer_was_blocked = writer_rx.recv_timeout(Duration::from_millis(100)).is_err();
2103        drop(reader);
2104        if writer_was_blocked {
2105            writer_rx.recv_timeout(Duration::from_secs(1)).unwrap();
2106        }
2107        writer.join().unwrap();
2108        assert!(writer_was_blocked);
2109        assert!(std::env::var(&key).is_err());
2110    }
2111
2112    fn write_test_agent(dir: &Path) {
2113        std::fs::write(
2114            dir.join("agent.yaml"),
2115            r#"
2116name: TestAgent
2117system_prompt: "You are helpful."
2118llm:
2119  provider: openai
2120  model: gpt-4.1-nano
2121"#,
2122        )
2123        .unwrap();
2124    }
2125
2126    async fn run_test_suite(dir: &Path, name: &str, yaml: &str) -> EvalResult {
2127        let suite_path = dir.join(name);
2128        std::fs::write(&suite_path, yaml).unwrap();
2129        let options = EvalRunnerOptions {
2130            output: dir.join("out"),
2131            ..Default::default()
2132        };
2133        EvalRunner::from_file(&suite_path, options)
2134            .unwrap()
2135            .run()
2136            .await
2137            .unwrap()
2138    }
2139
2140    #[test]
2141    fn runtime_error_expectations_retain_turns_and_control_retries() {
2142        std::thread::Builder::new()
2143            .name("eval-runtime-error-test".to_string())
2144            .stack_size(16 * 1024 * 1024)
2145            .spawn(|| {
2146                let runtime = tokio::runtime::Runtime::new().unwrap();
2147                runtime.block_on(async {
2148                    let dir = std::env::temp_dir().join(format!(
2149                        "ai_agents_eval_runtime_error_test_{}",
2150                        uuid::Uuid::new_v4()
2151                    ));
2152                    std::fs::create_dir_all(&dir).unwrap();
2153                    write_test_agent(&dir);
2154                    let errors = run_test_suite(
2155                        &dir,
2156                        "errors.yaml",
2157                        r#"
2158name: Runtime Errors
2159agent: agent.yaml
2160settings:
2161  retries: 1
2162  retry_delay_ms: 0
2163  redact_outputs: false
2164fixtures:
2165  llm:
2166    mode: mock
2167    errors_by_alias:
2168      default: provider exploded
2169scenarios:
2170  - id: expected
2171    turns:
2172      - input: Hello
2173        expect_error: [timeout, provider exploded]
2174  - id: mismatched
2175    turns:
2176      - input: Hello
2177        expect_error: permission denied
2178  - id: unexpected
2179    turns:
2180      - input: Hello
2181"#,
2182                    )
2183                    .await;
2184
2185                    let expected = &errors.scenarios[0];
2186                    assert!(expected.status.is_passed());
2187                    assert_eq!(expected.attempts.len(), 1);
2188                    let expected_turn = &expected.attempts[0].turns[0];
2189                    assert!(!expected_turn.response_present);
2190                    assert!(expected_turn.runtime_error.is_some());
2191                    assert!(
2192                        expected_turn
2193                            .assertion_results
2194                            .iter()
2195                            .any(|detail| detail.assertion == "expect_error" && detail.passed)
2196                    );
2197
2198                    for scenario in &errors.scenarios[1..] {
2199                        assert!(scenario.status.is_error());
2200                        assert_eq!(scenario.attempts.len(), 2);
2201                        assert!(
2202                            scenario
2203                                .attempts
2204                                .iter()
2205                                .all(|attempt| attempt.turns.len() == 1)
2206                        );
2207                        assert!(
2208                            scenario
2209                                .attempts
2210                                .iter()
2211                                .all(|attempt| attempt.turns[0].runtime_error.is_some())
2212                        );
2213                    }
2214
2215                    let missing = run_test_suite(
2216                        &dir,
2217                        "missing.yaml",
2218                        r#"
2219name: Missing Runtime Error
2220agent: agent.yaml
2221settings:
2222  retry_delay_ms: 0
2223  redact_outputs: false
2224fixtures:
2225  llm:
2226    mode: mock
2227    responses: [ok]
2228scenarios:
2229  - id: missing
2230    turns:
2231      - input: Hello
2232        expect_error: timeout
2233"#,
2234                    )
2235                    .await;
2236                    let missing = &missing.scenarios[0];
2237                    assert!(missing.status.is_failed());
2238                    let turn = &missing.attempts[0].turns[0];
2239                    assert!(turn.response_present);
2240                    assert!(turn.runtime_error.is_none());
2241                    assert!(
2242                        turn.assertion_results
2243                            .iter()
2244                            .any(|detail| detail.assertion == "expect_error" && !detail.passed)
2245                    );
2246                    let _ = std::fs::remove_dir_all(dir);
2247                });
2248            })
2249            .unwrap()
2250            .join()
2251            .unwrap();
2252    }
2253
2254    #[test]
2255    fn scenario_budget_is_shared_across_retries_and_agent_resets() {
2256        std::thread::Builder::new()
2257            .name("eval-budget-lifecycle-test".to_string())
2258            .stack_size(16 * 1024 * 1024)
2259            .spawn(|| {
2260                let runtime = tokio::runtime::Runtime::new().unwrap();
2261                runtime.block_on(async {
2262                    let dir = std::env::temp_dir().join(format!(
2263                        "ai_agents_eval_budget_lifecycle_test_{}",
2264                        uuid::Uuid::new_v4()
2265                    ));
2266                    std::fs::create_dir_all(&dir).unwrap();
2267                    write_test_agent(&dir);
2268
2269                    let retried = run_test_suite(
2270                        &dir,
2271                        "budget-retry.yaml",
2272                        r#"
2273name: Retry Budget
2274agent: agent.yaml
2275settings:
2276  retries: 1
2277  retry_delay_ms: 0
2278  redact_outputs: false
2279fixtures:
2280  llm:
2281    mode: mock
2282    responses: [wrong]
2283scenarios:
2284  - id: retry
2285    budget:
2286      max_llm_calls: 1
2287    turns:
2288      - input: Hello
2289        assert:
2290          response_contains: right
2291"#,
2292                    )
2293                    .await;
2294                    let retried = &retried.scenarios[0];
2295                    assert!(retried.status.is_error());
2296                    assert_eq!(retried.attempts.len(), 2);
2297                    assert!(
2298                        retried.attempts[1].turns[0]
2299                            .runtime_error
2300                            .as_ref()
2301                            .is_some_and(|error| error.value.contains("max_llm_calls=1"))
2302                    );
2303
2304                    let reset = run_test_suite(
2305                        &dir,
2306                        "budget-reset.yaml",
2307                        r#"
2308name: Reset Budget
2309agent: agent.yaml
2310settings:
2311  retries: 0
2312  redact_outputs: false
2313fixtures:
2314  llm:
2315    mode: mock
2316    responses: [ok]
2317scenarios:
2318  - id: reset
2319    budget:
2320      max_llm_calls: 1
2321    steps:
2322      - !run
2323        turns:
2324          - input: First
2325      - !reset_agent true
2326      - !run
2327        turns:
2328          - input: Second
2329"#,
2330                    )
2331                    .await;
2332                    let reset = &reset.scenarios[0];
2333                    assert!(reset.status.is_error());
2334                    assert_eq!(reset.attempts.len(), 1);
2335                    assert_eq!(reset.attempts[0].turns.len(), 2);
2336                    assert!(
2337                        reset.attempts[0].turns[1]
2338                            .runtime_error
2339                            .as_ref()
2340                            .is_some_and(|error| error.value.contains("max_llm_calls=1"))
2341                    );
2342                    let _ = std::fs::remove_dir_all(dir);
2343                });
2344            })
2345            .unwrap()
2346            .join()
2347            .unwrap();
2348    }
2349
2350    #[test]
2351    fn captures_composed_llm_requests_per_turn_across_reset() {
2352        std::thread::Builder::new()
2353            .name("eval-llm-evidence-test".to_string())
2354            .stack_size(16 * 1024 * 1024)
2355            .spawn(|| {
2356                let runtime = tokio::runtime::Runtime::new().unwrap();
2357                runtime.block_on(async {
2358                    let dir = std::env::temp_dir().join(format!(
2359                        "ai_agents_eval_llm_evidence_test_{}",
2360                        uuid::Uuid::new_v4()
2361                    ));
2362                    std::fs::create_dir_all(&dir).unwrap();
2363                    std::fs::write(
2364                        dir.join("agent.yaml"),
2365                        r#"
2366name: EvidenceAgent
2367system_prompt: "Base instruction marker."
2368llm:
2369  provider: openai
2370  model: gpt-4.1-nano
2371persona:
2372  identity:
2373    name: Evidence Guide
2374    role: Prompt Inspector
2375reasoning:
2376  mode: cot
2377  output: tagged
2378"#,
2379                    )
2380                    .unwrap();
2381                    let result = run_test_suite(
2382                        &dir,
2383                        "llm-evidence.yaml",
2384                        r#"
2385name: LLM Evidence
2386agent: agent.yaml
2387settings:
2388  redact_outputs: false
2389fixtures:
2390  llm:
2391    mode: mock
2392    responses: [first answer, second answer]
2393scenarios:
2394  - id: composed
2395    steps:
2396      - !run
2397        turns:
2398          - input: first question
2399            assert:
2400              llm_request:
2401                system_contains:
2402                  - "You are Evidence Guide, Prompt Inspector."
2403                  - "Base instruction marker."
2404                  - "Think through this step by step"
2405                  - "<instruction>"
2406                user_contains: first question
2407                count: 1
2408                same_request: true
2409          - input: second question
2410            assert:
2411              llm_request:
2412                user_contains: [first question, second question]
2413                assistant_contains: first answer
2414                count: 1
2415                same_request: true
2416      - !reset_agent true
2417      - !run
2418        turns:
2419          - input: after reset
2420            assert:
2421              llm_request:
2422                system_contains: "Base instruction marker."
2423                user_contains: after reset
2424                count: 1
2425                same_request: true
2426"#,
2427                    )
2428                    .await;
2429
2430                    assert_eq!(result.passed, 1);
2431                    let turns = &result.scenarios[0].attempts[0].turns;
2432                    assert_eq!(turns.len(), 3);
2433                    assert!(
2434                        turns
2435                            .iter()
2436                            .all(|turn| turn.evidence.llm_requests.len() == 1)
2437                    );
2438                    let second_messages = &turns[1].evidence.llm_requests[0].messages;
2439                    assert!(second_messages.iter().any(|message| {
2440                        message.role == ai_agents_core::Role::Assistant
2441                            && message.content.contains("first answer")
2442                    }));
2443                    let reset_messages = &turns[2].evidence.llm_requests[0].messages;
2444                    assert!(
2445                        reset_messages
2446                            .iter()
2447                            .all(|message| !message.content.contains("first question"))
2448                    );
2449                    assert!(
2450                        reset_messages
2451                            .iter()
2452                            .all(|message| !message.content.contains("first answer"))
2453                    );
2454
2455                    let serialized = serde_json::to_string(&result).unwrap();
2456                    let serialized_value: Value = serde_json::from_str(&serialized).unwrap();
2457                    assert!(
2458                        serialized_value["scenarios"][0]["attempts"][0]["turns"][0]
2459                            .get("evidence")
2460                            .is_none()
2461                    );
2462                    assert!(!serialized.contains("Base instruction marker"));
2463                    assert!(!serialized.contains("Evidence Guide"));
2464                    assert!(!serialized.contains("Think through this step by step"));
2465                    let _ = std::fs::remove_dir_all(dir);
2466                });
2467            })
2468            .unwrap()
2469            .join()
2470            .unwrap();
2471    }
2472
2473    #[test]
2474    fn runner_executes_mocked_suite_and_redacts_outputs() {
2475        std::thread::Builder::new()
2476            .name("eval-runner-test".to_string())
2477            .stack_size(16 * 1024 * 1024)
2478            .spawn(|| {
2479                let runtime = tokio::runtime::Runtime::new().unwrap();
2480                runtime.block_on(async {
2481                    let dir = std::env::temp_dir().join(format!(
2482                        "ai_agents_eval_runner_test_{}",
2483                        uuid::Uuid::new_v4()
2484                    ));
2485                    std::fs::create_dir_all(&dir).unwrap();
2486                    write_test_agent(&dir);
2487                    let suite_path = dir.join("suite.yaml");
2488                    std::fs::write(
2489                        &suite_path,
2490                        r#"
2491name: Runner Suite
2492agent: agent.yaml
2493fixtures:
2494  llm:
2495    mode: mock
2496    responses:
2497      - "Hello from mock"
2498scenarios:
2499  - id: smoke
2500    turns:
2501      - input: Hello
2502        assert:
2503          response_contains: "Hello"
2504"#,
2505                    )
2506                    .unwrap();
2507                    let options = EvalRunnerOptions {
2508                        output: dir.join("out"),
2509                        ..Default::default()
2510                    };
2511                    let runner = EvalRunner::from_file(&suite_path, options).unwrap();
2512                    let result = runner.run().await.unwrap();
2513                    assert_eq!(result.passed, 1);
2514                    let turn = &result.scenarios[0].attempts[0].turns[0];
2515                    assert_eq!(turn.input.value, "[redacted]");
2516                    assert_eq!(turn.response.value, "[redacted]");
2517                    let json = serde_json::to_string(&result).unwrap();
2518                    assert!(!json.contains("Hello from mock"));
2519                    let _ = std::fs::remove_dir_all(dir);
2520                });
2521            })
2522            .unwrap()
2523            .join()
2524            .unwrap();
2525    }
2526}