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, 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(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
1117async fn consume_stream_response<S>(
1118    mut stream: S,
1119    deadline: TokioInstant,
1120    timeout_ms: u64,
1121) -> TurnOperation
1122where
1123    S: futures::Stream<Item = StreamChunk> + Unpin,
1124{
1125    let mut content = String::new();
1126    let mut content_seen = false;
1127    let mut runtime_error = None;
1128    let mut done = false;
1129    loop {
1130        match timeout_at(deadline, stream.next()).await {
1131            Ok(Some(StreamChunk::Content { text })) => {
1132                content_seen = true;
1133                content.push_str(&text);
1134            }
1135            Ok(Some(StreamChunk::Done {})) => {
1136                done = true;
1137                break;
1138            }
1139            Ok(Some(StreamChunk::Error { message })) => {
1140                if runtime_error.is_none() {
1141                    runtime_error = Some(message);
1142                }
1143            }
1144            Ok(Some(_)) => {}
1145            Ok(None) => break,
1146            Err(_) => {
1147                if runtime_error.is_none() {
1148                    runtime_error = Some(format!("turn timed out after {}ms", timeout_ms));
1149                }
1150                break;
1151            }
1152        }
1153    }
1154    if !done && runtime_error.is_none() {
1155        runtime_error = Some("stream ended before Done".to_string());
1156    }
1157    TurnOperation {
1158        response_content: content,
1159        response_metadata: None,
1160        response_present: content_seen || (done && runtime_error.is_none()),
1161        runtime_error,
1162    }
1163}
1164
1165fn assertion_uses_response(assertion: &Assertion) -> bool {
1166    assertion.response_contains.is_some()
1167        || assertion.response_contains_any.is_some()
1168        || assertion.response_not_contains.is_some()
1169        || assertion.response_not_empty.is_some()
1170        || assertion.response_semantic.is_some()
1171        || assertion.judge.is_some()
1172        || assertion
1173            .all
1174            .as_ref()
1175            .is_some_and(|children| children.iter().any(assertion_uses_response))
1176        || assertion
1177            .any
1178            .as_ref()
1179            .is_some_and(|children| children.iter().any(assertion_uses_response))
1180        || assertion
1181            .not
1182            .as_deref()
1183            .is_some_and(assertion_uses_response)
1184}
1185
1186fn apply_workspace_policy(
1187    spec: &mut AgentSpec,
1188    config: Option<&WorkspacePolicyFixtureConfig>,
1189    context: &AttemptFixtureContext,
1190) -> Result<()> {
1191    let Some(config) = config else {
1192        return Ok(());
1193    };
1194    if !context.workspace.is_absolute() {
1195        return Err(EvalError::Config(
1196            "eval attempt workspace must be absolute".to_string(),
1197        ));
1198    }
1199
1200    for tool_id in config.read_tools.iter().chain(&config.write_tools) {
1201        if !spec.tool_security.tools.contains_key(tool_id) {
1202            return Err(EvalError::Config(format!(
1203                "fixtures.workspace_policy names tool '{}' without an existing tool policy",
1204                tool_id
1205            )));
1206        }
1207    }
1208
1209    let workspace = context.workspace.display().to_string();
1210    for tool_id in &config.read_tools {
1211        spec.tool_security
1212            .tools
1213            .get_mut(tool_id)
1214            .expect("workspace policy tool was validated")
1215            .read_paths
1216            .push(workspace.clone());
1217    }
1218    for tool_id in &config.write_tools {
1219        spec.tool_security
1220            .tools
1221            .get_mut(tool_id)
1222            .expect("workspace policy tool was validated")
1223            .write_paths
1224            .push(workspace.clone());
1225    }
1226    Ok(())
1227}
1228
1229fn isolate_spec_storage(spec: &mut AgentSpec, context: &AttemptFixtureContext) {
1230    isolate_storage_config(
1231        &mut spec.storage,
1232        context,
1233        "parent-storage",
1234        "parent-storage.db",
1235    );
1236    if let Some(shared_storage) = spec
1237        .spawner
1238        .as_mut()
1239        .and_then(|spawner| spawner.shared_storage.as_mut())
1240    {
1241        isolate_storage_config(
1242            shared_storage,
1243            context,
1244            "spawner-shared-storage",
1245            "spawner-shared-storage.db",
1246        );
1247    }
1248}
1249
1250fn isolate_storage_config(
1251    storage: &mut StorageConfig,
1252    context: &AttemptFixtureContext,
1253    file_name: &str,
1254    sqlite_name: &str,
1255) {
1256    match storage {
1257        StorageConfig::File(config) => {
1258            config.path = context.workspace.join(file_name).display().to_string();
1259        }
1260        StorageConfig::Sqlite(config) => {
1261            config.path = context.workspace.join(sqlite_name).display().to_string();
1262        }
1263        StorageConfig::Redis(config) => {
1264            let prefix = config.prefix.as_deref().unwrap_or("agent:");
1265            config.prefix = Some(format!("{}eval:{}:", prefix, context.isolation_id));
1266        }
1267        StorageConfig::None => {}
1268    }
1269}
1270
1271fn apply_context_map(agent: &RuntimeAgent, values: HashMap<String, Value>) -> Result<()> {
1272    for (key, value) in values {
1273        agent.set_context(&key, value)?;
1274    }
1275    Ok(())
1276}
1277
1278fn apply_context_value(agent: &RuntimeAgent, value: &Value) -> Result<()> {
1279    let Value::Object(map) = value else {
1280        return Ok(());
1281    };
1282    for (key, value) in map {
1283        agent.set_context(key, value.clone())?;
1284    }
1285    Ok(())
1286}
1287
1288fn apply_base_context(
1289    agent: &RuntimeAgent,
1290    suite: &EvalSuite,
1291    base_dir: &Path,
1292    scenario: &Scenario,
1293    attempt_context: &AttemptFixtureContext,
1294) -> Result<()> {
1295    apply_context_map(agent, resolve_fixture_context(&suite.fixtures, base_dir)?)?;
1296    apply_context_map(agent, attempt_context.runtime_context())?;
1297    apply_context_value(agent, &scenario.context)?;
1298    if let Some(actor) = &scenario.actor {
1299        agent.set_actor_id(actor)?;
1300    }
1301    Ok(())
1302}
1303
1304fn reset_options(config: &ResetStepConfig) -> Option<crate::reset::ResetOptions> {
1305    match config {
1306        ResetStepConfig::Bool(false) => None,
1307        ResetStepConfig::Bool(true) => Some(crate::reset::ResetOptions::default()),
1308        ResetStepConfig::Options(options) => Some(options.clone()),
1309    }
1310}
1311
1312fn redact_assertion_details(details: &mut [crate::assertion::AssertionResultDetail]) {
1313    for detail in details {
1314        detail.actual = redact_value(std::mem::take(&mut detail.actual), true, 0);
1315        detail.expected = redact_value(std::mem::take(&mut detail.expected), true, 0);
1316    }
1317}
1318
1319fn error_result(
1320    scenario: &Scenario,
1321    error: EvalError,
1322    category: FailureCategory,
1323) -> ScenarioResult {
1324    ScenarioResult {
1325        id: scenario.id.clone(),
1326        name: scenario.name.clone(),
1327        tags: scenario.tags.clone(),
1328        language: scenario.language.clone(),
1329        status: ScenarioStatus::Error {
1330            message: error.to_string(),
1331        },
1332        failure_category: Some(category),
1333        flaky: false,
1334        attempts: Vec::new(),
1335        duration_ms: 0,
1336        retries_used: 0,
1337    }
1338}
1339
1340fn failure_category_for_attempt(attempt: &AttemptResult) -> FailureCategory {
1341    let judge_failed = attempt.turns.iter().any(|turn| {
1342        turn.assertion_results
1343            .iter()
1344            .any(|detail| !detail.passed && detail.assertion == "judge")
1345    });
1346    if judge_failed {
1347        FailureCategory::JudgeError
1348    } else {
1349        FailureCategory::AssertionFailed
1350    }
1351}
1352
1353fn final_observability_report(
1354    results: &[ScenarioResult],
1355) -> Option<ai_agents_observability::ObservabilityReport> {
1356    results
1357        .iter()
1358        .rev()
1359        .flat_map(|scenario| scenario.attempts.iter().rev())
1360        .flat_map(|attempt| attempt.turns.iter().rev())
1361        .find_map(|turn| {
1362            turn.evidence
1363                .observability
1364                .as_ref()
1365                .and_then(|obs| obs.report.clone())
1366        })
1367}
1368
1369fn apply_eval_llm_settings(spec: &mut AgentSpec, settings: &crate::suite::EvalSettings) {
1370    if let LLMConfigOrSelector::Config(config) = &mut spec.llm {
1371        apply_llm_config_settings(config, settings);
1372    }
1373    for config in spec.llms.values_mut() {
1374        apply_llm_config_settings(config, settings);
1375    }
1376}
1377
1378fn budget_provider_configs(spec: &AgentSpec) -> HashMap<String, BudgetProviderConfig> {
1379    if spec.llms.is_empty() {
1380        let config = spec.llm.as_config().cloned().unwrap_or_default();
1381        return HashMap::from([(
1382            "default".to_string(),
1383            BudgetProviderConfig {
1384                provider: config.provider,
1385                model: config.model,
1386                max_output_tokens: config.max_tokens,
1387            },
1388        )]);
1389    }
1390    spec.llms
1391        .iter()
1392        .map(|(alias, config)| {
1393            (
1394                alias.clone(),
1395                BudgetProviderConfig {
1396                    provider: config.provider.clone(),
1397                    model: config.model.clone(),
1398                    max_output_tokens: config.max_tokens,
1399                },
1400            )
1401        })
1402        .collect()
1403}
1404
1405fn apply_llm_config_settings(
1406    config: &mut ai_agents_runtime::spec::LLMConfig,
1407    settings: &crate::suite::EvalSettings,
1408) {
1409    if let Some(temperature) = settings.temperature {
1410        config.temperature = temperature;
1411    }
1412    if let Some(seed) = settings.seed {
1413        config.extra.insert("seed".to_string(), json!(seed));
1414    }
1415}
1416
1417/// Process-wide exclusion held for an eval attempt's environment access.
1418enum EnvExclusionGuard {
1419    Read {
1420        _guard: RwLockReadGuard<'static, ()>,
1421    },
1422    Write {
1423        _guard: RwLockWriteGuard<'static, ()>,
1424    },
1425}
1426
1427/// Restores process environment variables when an attempt ends.
1428struct EnvGuard {
1429    /// Previous env values restored on drop.
1430    previous: Vec<(String, Option<String>)>,
1431    /// Shared access for unchanged env or exclusive access for mutations.
1432    _guard: EnvExclusionGuard,
1433}
1434
1435impl EnvGuard {
1436    fn apply(values: &HashMap<String, String>) -> Result<Self> {
1437        static ENV_LOCK: OnceLock<RwLock<()>> = OnceLock::new();
1438        let lock = ENV_LOCK.get_or_init(|| RwLock::new(()));
1439        if values.is_empty() {
1440            let guard = lock.read().map_err(|_| {
1441                EvalError::Runtime("failed to lock eval environment guard".to_string())
1442            })?;
1443            return Ok(Self {
1444                previous: Vec::new(),
1445                _guard: EnvExclusionGuard::Read { _guard: guard },
1446            });
1447        }
1448        let guard = lock
1449            .write()
1450            .map_err(|_| EvalError::Runtime("failed to lock eval environment guard".to_string()))?;
1451        let mut previous = Vec::new();
1452        for (key, value) in values {
1453            previous.push((key.clone(), std::env::var(key).ok()));
1454            unsafe {
1455                std::env::set_var(key, value);
1456            }
1457        }
1458        Ok(Self {
1459            previous,
1460            _guard: EnvExclusionGuard::Write { _guard: guard },
1461        })
1462    }
1463}
1464
1465impl Drop for EnvGuard {
1466    fn drop(&mut self) {
1467        for (key, value) in self.previous.drain(..).rev() {
1468            unsafe {
1469                if let Some(value) = value {
1470                    std::env::set_var(key, value);
1471                } else {
1472                    std::env::remove_var(key);
1473                }
1474            }
1475        }
1476    }
1477}
1478
1479#[cfg(test)]
1480mod tests {
1481    use super::*;
1482
1483    #[test]
1484    fn strict_suite_loader_rejects_nested_observability_typos() {
1485        let error = parse_eval_suite_yaml(
1486            r#"
1487name: strict
1488agent: agent.yaml
1489observability:
1490  enabeld: true
1491scenarios:
1492  - id: scenario
1493    turns:
1494      - input: hello
1495"#,
1496        )
1497        .unwrap_err()
1498        .to_string();
1499
1500        assert!(error.contains("enabeld"), "{error}");
1501    }
1502
1503    #[test]
1504    fn storage_isolation_rewrites_parent_and_spawner_backends() {
1505        let context = AttemptFixtureContext {
1506            isolation_id: "attempt-a".to_string(),
1507            workspace: PathBuf::from("/tmp/eval-attempt-a"),
1508            mock_server_base_url: None,
1509        };
1510        let mut file_spec: AgentSpec = serde_yaml::from_str(
1511            r#"
1512name: FileAgent
1513system_prompt: test
1514storage: { type: file, path: ./parent }
1515spawner:
1516  shared_storage: { type: sqlite, path: ./shared.db, table: shared_sessions }
1517"#,
1518        )
1519        .unwrap();
1520        isolate_spec_storage(&mut file_spec, &context);
1521        assert_eq!(
1522            file_spec.storage.get_path(),
1523            Some("/tmp/eval-attempt-a/parent-storage")
1524        );
1525        let shared = file_spec
1526            .spawner
1527            .as_ref()
1528            .unwrap()
1529            .shared_storage
1530            .as_ref()
1531            .unwrap();
1532        assert_eq!(
1533            shared.get_path(),
1534            Some("/tmp/eval-attempt-a/spawner-shared-storage.db")
1535        );
1536        assert_eq!(shared.get_table(), Some("shared_sessions"));
1537
1538        let mut redis_spec: AgentSpec = serde_yaml::from_str(
1539            r#"
1540name: RedisAgent
1541system_prompt: test
1542storage: { type: redis, url: redis://localhost, prefix: "parent:" }
1543spawner:
1544  shared_storage: { type: redis, url: redis://localhost }
1545"#,
1546        )
1547        .unwrap();
1548        isolate_spec_storage(&mut redis_spec, &context);
1549        assert_eq!(redis_spec.storage.get_prefix(), "parent:eval:attempt-a:");
1550        assert_eq!(
1551            redis_spec
1552                .spawner
1553                .as_ref()
1554                .unwrap()
1555                .shared_storage
1556                .as_ref()
1557                .unwrap()
1558                .get_prefix(),
1559            "agent:eval:attempt-a:"
1560        );
1561
1562        let other_context = AttemptFixtureContext {
1563            isolation_id: "attempt-b".to_string(),
1564            workspace: PathBuf::from("/tmp/eval-attempt-b"),
1565            mock_server_base_url: None,
1566        };
1567        let mut other_redis: AgentSpec = serde_yaml::from_str(
1568            r#"
1569name: RedisAgent
1570system_prompt: test
1571storage: { type: redis, url: redis://localhost, prefix: "parent:" }
1572"#,
1573        )
1574        .unwrap();
1575        isolate_spec_storage(&mut other_redis, &other_context);
1576        assert_ne!(
1577            redis_spec.storage.get_prefix(),
1578            other_redis.storage.get_prefix()
1579        );
1580    }
1581
1582    #[test]
1583    fn workspace_policy_is_narrow_and_isolated_per_attempt() {
1584        let source: AgentSpec = serde_yaml::from_str(
1585            r#"
1586name: PolicyAgent
1587system_prompt: test
1588tool_security:
1589  enabled: true
1590  fail_closed: true
1591  tools:
1592    file_read:
1593      read_paths: [./source]
1594      blocked_paths: [./blocked]
1595    file_write:
1596      write_paths: [./output]
1597      blocked_paths: [./blocked]
1598    grep:
1599      read_paths: [./repository]
1600"#,
1601        )
1602        .unwrap();
1603        let config = WorkspacePolicyFixtureConfig {
1604            read_tools: vec!["file_read".to_string()],
1605            write_tools: vec!["file_write".to_string()],
1606        };
1607        let first_context = AttemptFixtureContext {
1608            isolation_id: "attempt-a".to_string(),
1609            workspace: PathBuf::from("/tmp/eval-attempt-a"),
1610            mock_server_base_url: None,
1611        };
1612        let second_context = AttemptFixtureContext {
1613            isolation_id: "attempt-b".to_string(),
1614            workspace: PathBuf::from("/tmp/eval-attempt-b"),
1615            mock_server_base_url: None,
1616        };
1617
1618        let mut first = source.clone();
1619        apply_workspace_policy(&mut first, Some(&config), &first_context).unwrap();
1620        let mut second = source.clone();
1621        apply_workspace_policy(&mut second, Some(&config), &second_context).unwrap();
1622
1623        assert!(first.tool_security.fail_closed);
1624        assert_eq!(
1625            first.tool_security.tools["file_read"].read_paths,
1626            vec!["./source", "/tmp/eval-attempt-a"]
1627        );
1628        assert_eq!(
1629            first.tool_security.tools["file_write"].write_paths,
1630            vec!["./output", "/tmp/eval-attempt-a"]
1631        );
1632        assert_eq!(
1633            first.tool_security.tools["file_read"].blocked_paths,
1634            vec!["./blocked"]
1635        );
1636        assert_eq!(
1637            first.tool_security.tools["file_write"].blocked_paths,
1638            vec!["./blocked"]
1639        );
1640        assert_eq!(
1641            first.tool_security.tools["grep"].read_paths,
1642            vec!["./repository"]
1643        );
1644        assert_eq!(
1645            second.tool_security.tools["file_read"].read_paths,
1646            vec!["./source", "/tmp/eval-attempt-b"]
1647        );
1648        assert_eq!(
1649            source.tool_security.tools["file_read"].read_paths,
1650            vec!["./source"]
1651        );
1652        assert_eq!(
1653            source.tool_security.tools["file_write"].write_paths,
1654            vec!["./output"]
1655        );
1656    }
1657
1658    #[test]
1659    fn workspace_policy_rejects_unknown_tools_without_partial_mutation() {
1660        let mut spec: AgentSpec = serde_yaml::from_str(
1661            r#"
1662name: PolicyAgent
1663system_prompt: test
1664tool_security:
1665  enabled: true
1666  fail_closed: true
1667  tools:
1668    file_read:
1669      read_paths: [./source]
1670"#,
1671        )
1672        .unwrap();
1673        let original = spec.tool_security.tools["file_read"].read_paths.clone();
1674        let config = WorkspacePolicyFixtureConfig {
1675            read_tools: vec!["file_read".to_string(), "missing_tool".to_string()],
1676            write_tools: Vec::new(),
1677        };
1678        let context = AttemptFixtureContext {
1679            isolation_id: "attempt-a".to_string(),
1680            workspace: PathBuf::from("/tmp/eval-attempt-a"),
1681            mock_server_base_url: None,
1682        };
1683
1684        let error = apply_workspace_policy(&mut spec, Some(&config), &context).unwrap_err();
1685
1686        assert!(error.to_string().contains("missing_tool"));
1687        assert!(
1688            error
1689                .to_string()
1690                .contains("without an existing tool policy")
1691        );
1692        assert_eq!(spec.tool_security.tools["file_read"].read_paths, original);
1693        assert!(spec.tool_security.fail_closed);
1694    }
1695
1696    #[tokio::test]
1697    async fn generated_attempt_context_has_stable_reset_precedence() {
1698        let dir = std::env::temp_dir().join(format!(
1699            "ai_agents_eval_attempt_context_test_{}",
1700            uuid::Uuid::new_v4()
1701        ));
1702        std::fs::create_dir_all(&dir).unwrap();
1703        write_test_agent(&dir);
1704        let suite_path = dir.join("suite.yaml");
1705        std::fs::write(
1706            &suite_path,
1707            r#"
1708name: Attempt Context
1709agent: agent.yaml
1710fixtures:
1711  context:
1712    eval: { workspace: fixture-value }
1713    mock_server: { base_url: fixture-value }
1714    fixture_only: true
1715  llm:
1716    mode: mock
1717    responses: [ok]
1718scenarios:
1719  - id: context
1720    context: { scenario_only: true, precedence: scenario }
1721    turns:
1722      - input: test
1723        context: { precedence: turn }
1724"#,
1725        )
1726        .unwrap();
1727        let runner = EvalRunner::from_file(
1728            &suite_path,
1729            EvalRunnerOptions {
1730                output: dir.join("out"),
1731                ..Default::default()
1732            },
1733        )
1734        .unwrap();
1735        let attempt_context = AttemptFixtureContext {
1736            isolation_id: "stable-attempt".to_string(),
1737            workspace: dir
1738                .join("workspace")
1739                .canonicalize()
1740                .unwrap_or_else(|_| dir.join("workspace")),
1741            mock_server_base_url: Some("http://127.0.0.1:40000".to_string()),
1742        };
1743        std::fs::create_dir_all(&attempt_context.workspace).unwrap();
1744        let scenario = &runner.suite.scenarios[0];
1745        let tool_log = RecordingToolLog::new();
1746        let approval_log = RecordingApprovalLog::default();
1747        let llm_log = RecordingLlmLog::default();
1748        let first = runner
1749            .build_agent(BuildAgentParams {
1750                agent_path: &dir.join("agent.yaml"),
1751                base_dir: &dir,
1752                attempt_context: &attempt_context,
1753                tool_log: tool_log.clone(),
1754                approval_log: approval_log.clone(),
1755                llm_log: llm_log.clone(),
1756                approval_handler: None,
1757                budget: None,
1758            })
1759            .await
1760            .unwrap();
1761        apply_base_context(&first, &runner.suite, &dir, scenario, &attempt_context).unwrap();
1762        let first_context = first.get_context();
1763        assert_eq!(
1764            first_context["eval"]["workspace"],
1765            json!(attempt_context.workspace.display().to_string())
1766        );
1767        assert_eq!(
1768            first_context["mock_server"]["base_url"],
1769            "http://127.0.0.1:40000"
1770        );
1771        assert_eq!(first_context["precedence"], "scenario");
1772        apply_context_value(&first, &turn_runtime_context(&scenario.turns[0])).unwrap();
1773        assert_eq!(first.get_context()["precedence"], "turn");
1774
1775        let reset = runner
1776            .build_agent(BuildAgentParams {
1777                agent_path: &dir.join("agent.yaml"),
1778                base_dir: &dir,
1779                attempt_context: &attempt_context,
1780                tool_log,
1781                approval_log,
1782                llm_log,
1783                approval_handler: None,
1784                budget: None,
1785            })
1786            .await
1787            .unwrap();
1788        apply_base_context(&reset, &runner.suite, &dir, scenario, &attempt_context).unwrap();
1789        assert_eq!(reset.get_context()["eval"], first_context["eval"]);
1790        assert_eq!(
1791            reset.get_context()["mock_server"],
1792            first_context["mock_server"]
1793        );
1794        let _ = std::fs::remove_dir_all(dir);
1795    }
1796
1797    #[tokio::test]
1798    async fn streaming_error_retains_partial_content_and_consumes_until_done() {
1799        let chunks = stream::iter(vec![
1800            StreamChunk::Content {
1801                text: "before ".to_string(),
1802            },
1803            StreamChunk::Error {
1804                message: "stream failed".to_string(),
1805            },
1806            StreamChunk::Content {
1807                text: "after".to_string(),
1808            },
1809            StreamChunk::Done {},
1810        ]);
1811        let operation =
1812            consume_stream_response(chunks, TokioInstant::now() + Duration::from_secs(1), 1_000)
1813                .await;
1814        assert_eq!(operation.response_content, "before after");
1815        assert!(operation.response_present);
1816        assert_eq!(operation.runtime_error.as_deref(), Some("stream failed"));
1817    }
1818
1819    #[test]
1820    fn dry_config_check_validates_real_suite_and_agent_without_authorization() {
1821        let dir = std::env::temp_dir().join(format!(
1822            "ai_agents_eval_dry_config_test_{}",
1823            uuid::Uuid::new_v4()
1824        ));
1825        std::fs::create_dir_all(&dir).unwrap();
1826        let suite_path = dir.join("suite.yaml");
1827        std::fs::write(
1828            &suite_path,
1829            r#"
1830name: Dry Config
1831agent: agent.yaml
1832fixtures:
1833  llm:
1834    mode: real
1835scenarios:
1836  - id: live
1837    turns:
1838      - input: hello
1839"#,
1840        )
1841        .unwrap();
1842        std::fs::write(
1843            dir.join("agent.yaml"),
1844            "name: TestAgent\nsystem_prompt: test\n",
1845        )
1846        .unwrap();
1847
1848        EvalRunner::validate_file(&suite_path, None).unwrap();
1849
1850        std::fs::write(
1851            dir.join("agent.yaml"),
1852            "name: TestAgent\nsystem_prompt: test\nmax_iteratons: 3\n",
1853        )
1854        .unwrap();
1855        let error = EvalRunner::validate_file(&suite_path, None).unwrap_err();
1856        assert!(error.to_string().contains("max_iteratons"));
1857        let _ = std::fs::remove_dir_all(dir);
1858    }
1859
1860    #[test]
1861    fn real_and_record_modes_require_explicit_authorization() {
1862        let dir = std::env::temp_dir().join(format!(
1863            "ai_agents_eval_authorization_test_{}",
1864            uuid::Uuid::new_v4()
1865        ));
1866        std::fs::create_dir_all(&dir).unwrap();
1867        let suite_path = dir.join("suite.yaml");
1868        std::fs::write(
1869            &suite_path,
1870            r#"
1871name: Authorization
1872agent: agent.yaml
1873fixtures:
1874  llm:
1875    mode: real
1876scenarios:
1877  - id: authorized
1878    turns:
1879      - input: hello
1880"#,
1881        )
1882        .unwrap();
1883
1884        let error = EvalRunner::from_file(&suite_path, EvalRunnerOptions::default())
1885            .err()
1886            .expect("real mode should require authorization");
1887        assert!(error.to_string().contains("--real-llm"));
1888        assert!(
1889            EvalRunner::from_file(
1890                &suite_path,
1891                EvalRunnerOptions {
1892                    llm_mode: Some(LlmFixtureMode::Real),
1893                    ..Default::default()
1894                },
1895            )
1896            .is_ok()
1897        );
1898
1899        let record_suite = std::fs::read_to_string(&suite_path)
1900            .unwrap()
1901            .replace("mode: real", "mode: record");
1902        std::fs::write(&suite_path, record_suite).unwrap();
1903        let error = EvalRunner::from_file(&suite_path, EvalRunnerOptions::default())
1904            .err()
1905            .expect("record mode should require authorization");
1906        assert!(error.to_string().contains("--record"));
1907        assert!(
1908            EvalRunner::from_file(
1909                &suite_path,
1910                EvalRunnerOptions {
1911                    llm_mode: Some(LlmFixtureMode::Record),
1912                    ..Default::default()
1913                },
1914            )
1915            .is_ok()
1916        );
1917        let _ = std::fs::remove_dir_all(dir);
1918    }
1919
1920    #[tokio::test]
1921    async fn zero_selected_scenarios_is_an_error() {
1922        let dir = std::env::temp_dir().join(format!(
1923            "ai_agents_eval_zero_selection_test_{}",
1924            uuid::Uuid::new_v4()
1925        ));
1926        std::fs::create_dir_all(&dir).unwrap();
1927        let suite_path = dir.join("suite.yaml");
1928        std::fs::write(
1929            &suite_path,
1930            r#"
1931name: Selection
1932agent: agent.yaml
1933fixtures:
1934  llm:
1935    mode: mock
1936    responses: [ok]
1937scenarios:
1938  - id: present
1939    turns:
1940      - input: hello
1941"#,
1942        )
1943        .unwrap();
1944        let runner = EvalRunner::from_file(
1945            &suite_path,
1946            EvalRunnerOptions {
1947                ids: vec!["missing".to_string()],
1948                ..Default::default()
1949            },
1950        )
1951        .unwrap();
1952
1953        let error = runner.run().await.unwrap_err();
1954
1955        assert!(error.to_string().contains("matched zero scenarios"));
1956        let _ = std::fs::remove_dir_all(dir);
1957    }
1958
1959    #[test]
1960    fn env_guard_allows_readers_and_excludes_writer() {
1961        use std::sync::{Barrier, mpsc};
1962        use std::thread;
1963
1964        let start = Arc::new(Barrier::new(3));
1965        let (acquired_tx, acquired_rx) = mpsc::channel();
1966        let mut releases = Vec::new();
1967        let mut readers = Vec::new();
1968        for index in 0..2 {
1969            let start = Arc::clone(&start);
1970            let acquired_tx = acquired_tx.clone();
1971            let (release_tx, release_rx) = mpsc::channel();
1972            releases.push(release_tx);
1973            readers.push(thread::spawn(move || {
1974                start.wait();
1975                let guard = EnvGuard::apply(&HashMap::new()).unwrap();
1976                acquired_tx.send(index).unwrap();
1977                release_rx.recv().unwrap();
1978                drop(guard);
1979            }));
1980        }
1981        start.wait();
1982        let first = acquired_rx.recv_timeout(Duration::from_secs(1));
1983        let second = acquired_rx.recv_timeout(Duration::from_secs(1));
1984        for release in releases {
1985            release.send(()).unwrap();
1986        }
1987        for reader in readers {
1988            reader.join().unwrap();
1989        }
1990        assert_ne!(first.unwrap(), second.unwrap());
1991
1992        let key = format!("AI_AGENTS_EVAL_ENV_TEST_{}", uuid::Uuid::new_v4());
1993        unsafe {
1994            std::env::remove_var(&key);
1995        }
1996        let reader = EnvGuard::apply(&HashMap::new()).unwrap();
1997        assert!(matches!(&reader._guard, EnvExclusionGuard::Read { .. }));
1998        let writer_start = Arc::new(Barrier::new(2));
1999        let writer_start_thread = Arc::clone(&writer_start);
2000        let (writer_tx, writer_rx) = mpsc::channel();
2001        let writer_key = key.clone();
2002        let writer = thread::spawn(move || {
2003            writer_start_thread.wait();
2004            let guard = EnvGuard::apply(&HashMap::from([(
2005                writer_key.clone(),
2006                "temporary".to_string(),
2007            )]))
2008            .unwrap();
2009            writer_tx.send(()).unwrap();
2010            assert_eq!(std::env::var(&writer_key).as_deref(), Ok("temporary"));
2011            drop(guard);
2012        });
2013        writer_start.wait();
2014        let writer_was_blocked = writer_rx.recv_timeout(Duration::from_millis(100)).is_err();
2015        drop(reader);
2016        if writer_was_blocked {
2017            writer_rx.recv_timeout(Duration::from_secs(1)).unwrap();
2018        }
2019        writer.join().unwrap();
2020        assert!(writer_was_blocked);
2021        assert!(std::env::var(&key).is_err());
2022    }
2023
2024    fn write_test_agent(dir: &Path) {
2025        std::fs::write(
2026            dir.join("agent.yaml"),
2027            r#"
2028name: TestAgent
2029system_prompt: "You are helpful."
2030llm:
2031  provider: openai
2032  model: gpt-4.1-nano
2033"#,
2034        )
2035        .unwrap();
2036    }
2037
2038    async fn run_test_suite(dir: &Path, name: &str, yaml: &str) -> EvalResult {
2039        let suite_path = dir.join(name);
2040        std::fs::write(&suite_path, yaml).unwrap();
2041        let options = EvalRunnerOptions {
2042            output: dir.join("out"),
2043            ..Default::default()
2044        };
2045        EvalRunner::from_file(&suite_path, options)
2046            .unwrap()
2047            .run()
2048            .await
2049            .unwrap()
2050    }
2051
2052    #[test]
2053    fn runtime_error_expectations_retain_turns_and_control_retries() {
2054        std::thread::Builder::new()
2055            .name("eval-runtime-error-test".to_string())
2056            .stack_size(16 * 1024 * 1024)
2057            .spawn(|| {
2058                let runtime = tokio::runtime::Runtime::new().unwrap();
2059                runtime.block_on(async {
2060                    let dir = std::env::temp_dir().join(format!(
2061                        "ai_agents_eval_runtime_error_test_{}",
2062                        uuid::Uuid::new_v4()
2063                    ));
2064                    std::fs::create_dir_all(&dir).unwrap();
2065                    write_test_agent(&dir);
2066                    let errors = run_test_suite(
2067                        &dir,
2068                        "errors.yaml",
2069                        r#"
2070name: Runtime Errors
2071agent: agent.yaml
2072settings:
2073  retries: 1
2074  retry_delay_ms: 0
2075  redact_outputs: false
2076fixtures:
2077  llm:
2078    mode: mock
2079    errors_by_alias:
2080      default: provider exploded
2081scenarios:
2082  - id: expected
2083    turns:
2084      - input: Hello
2085        expect_error: [timeout, provider exploded]
2086  - id: mismatched
2087    turns:
2088      - input: Hello
2089        expect_error: permission denied
2090  - id: unexpected
2091    turns:
2092      - input: Hello
2093"#,
2094                    )
2095                    .await;
2096
2097                    let expected = &errors.scenarios[0];
2098                    assert!(expected.status.is_passed());
2099                    assert_eq!(expected.attempts.len(), 1);
2100                    let expected_turn = &expected.attempts[0].turns[0];
2101                    assert!(!expected_turn.response_present);
2102                    assert!(expected_turn.runtime_error.is_some());
2103                    assert!(
2104                        expected_turn
2105                            .assertion_results
2106                            .iter()
2107                            .any(|detail| detail.assertion == "expect_error" && detail.passed)
2108                    );
2109
2110                    for scenario in &errors.scenarios[1..] {
2111                        assert!(scenario.status.is_error());
2112                        assert_eq!(scenario.attempts.len(), 2);
2113                        assert!(
2114                            scenario
2115                                .attempts
2116                                .iter()
2117                                .all(|attempt| attempt.turns.len() == 1)
2118                        );
2119                        assert!(
2120                            scenario
2121                                .attempts
2122                                .iter()
2123                                .all(|attempt| attempt.turns[0].runtime_error.is_some())
2124                        );
2125                    }
2126
2127                    let missing = run_test_suite(
2128                        &dir,
2129                        "missing.yaml",
2130                        r#"
2131name: Missing Runtime Error
2132agent: agent.yaml
2133settings:
2134  retry_delay_ms: 0
2135  redact_outputs: false
2136fixtures:
2137  llm:
2138    mode: mock
2139    responses: [ok]
2140scenarios:
2141  - id: missing
2142    turns:
2143      - input: Hello
2144        expect_error: timeout
2145"#,
2146                    )
2147                    .await;
2148                    let missing = &missing.scenarios[0];
2149                    assert!(missing.status.is_failed());
2150                    let turn = &missing.attempts[0].turns[0];
2151                    assert!(turn.response_present);
2152                    assert!(turn.runtime_error.is_none());
2153                    assert!(
2154                        turn.assertion_results
2155                            .iter()
2156                            .any(|detail| detail.assertion == "expect_error" && !detail.passed)
2157                    );
2158                    let _ = std::fs::remove_dir_all(dir);
2159                });
2160            })
2161            .unwrap()
2162            .join()
2163            .unwrap();
2164    }
2165
2166    #[test]
2167    fn scenario_budget_is_shared_across_retries_and_agent_resets() {
2168        std::thread::Builder::new()
2169            .name("eval-budget-lifecycle-test".to_string())
2170            .stack_size(16 * 1024 * 1024)
2171            .spawn(|| {
2172                let runtime = tokio::runtime::Runtime::new().unwrap();
2173                runtime.block_on(async {
2174                    let dir = std::env::temp_dir().join(format!(
2175                        "ai_agents_eval_budget_lifecycle_test_{}",
2176                        uuid::Uuid::new_v4()
2177                    ));
2178                    std::fs::create_dir_all(&dir).unwrap();
2179                    write_test_agent(&dir);
2180
2181                    let retried = run_test_suite(
2182                        &dir,
2183                        "budget-retry.yaml",
2184                        r#"
2185name: Retry Budget
2186agent: agent.yaml
2187settings:
2188  retries: 1
2189  retry_delay_ms: 0
2190  redact_outputs: false
2191fixtures:
2192  llm:
2193    mode: mock
2194    responses: [wrong]
2195scenarios:
2196  - id: retry
2197    budget:
2198      max_llm_calls: 1
2199    turns:
2200      - input: Hello
2201        assert:
2202          response_contains: right
2203"#,
2204                    )
2205                    .await;
2206                    let retried = &retried.scenarios[0];
2207                    assert!(retried.status.is_error());
2208                    assert_eq!(retried.attempts.len(), 2);
2209                    assert!(
2210                        retried.attempts[1].turns[0]
2211                            .runtime_error
2212                            .as_ref()
2213                            .is_some_and(|error| error.value.contains("max_llm_calls=1"))
2214                    );
2215
2216                    let reset = run_test_suite(
2217                        &dir,
2218                        "budget-reset.yaml",
2219                        r#"
2220name: Reset Budget
2221agent: agent.yaml
2222settings:
2223  retries: 0
2224  redact_outputs: false
2225fixtures:
2226  llm:
2227    mode: mock
2228    responses: [ok]
2229scenarios:
2230  - id: reset
2231    budget:
2232      max_llm_calls: 1
2233    steps:
2234      - !run
2235        turns:
2236          - input: First
2237      - !reset_agent true
2238      - !run
2239        turns:
2240          - input: Second
2241"#,
2242                    )
2243                    .await;
2244                    let reset = &reset.scenarios[0];
2245                    assert!(reset.status.is_error());
2246                    assert_eq!(reset.attempts.len(), 1);
2247                    assert_eq!(reset.attempts[0].turns.len(), 2);
2248                    assert!(
2249                        reset.attempts[0].turns[1]
2250                            .runtime_error
2251                            .as_ref()
2252                            .is_some_and(|error| error.value.contains("max_llm_calls=1"))
2253                    );
2254                    let _ = std::fs::remove_dir_all(dir);
2255                });
2256            })
2257            .unwrap()
2258            .join()
2259            .unwrap();
2260    }
2261
2262    #[test]
2263    fn captures_composed_llm_requests_per_turn_across_reset() {
2264        std::thread::Builder::new()
2265            .name("eval-llm-evidence-test".to_string())
2266            .stack_size(16 * 1024 * 1024)
2267            .spawn(|| {
2268                let runtime = tokio::runtime::Runtime::new().unwrap();
2269                runtime.block_on(async {
2270                    let dir = std::env::temp_dir().join(format!(
2271                        "ai_agents_eval_llm_evidence_test_{}",
2272                        uuid::Uuid::new_v4()
2273                    ));
2274                    std::fs::create_dir_all(&dir).unwrap();
2275                    std::fs::write(
2276                        dir.join("agent.yaml"),
2277                        r#"
2278name: EvidenceAgent
2279system_prompt: "Base instruction marker."
2280llm:
2281  provider: openai
2282  model: gpt-4.1-nano
2283persona:
2284  identity:
2285    name: Evidence Guide
2286    role: Prompt Inspector
2287reasoning:
2288  mode: cot
2289  output: tagged
2290"#,
2291                    )
2292                    .unwrap();
2293                    let result = run_test_suite(
2294                        &dir,
2295                        "llm-evidence.yaml",
2296                        r#"
2297name: LLM Evidence
2298agent: agent.yaml
2299settings:
2300  redact_outputs: false
2301fixtures:
2302  llm:
2303    mode: mock
2304    responses: [first answer, second answer]
2305scenarios:
2306  - id: composed
2307    steps:
2308      - !run
2309        turns:
2310          - input: first question
2311            assert:
2312              llm_request:
2313                system_contains:
2314                  - "You are Evidence Guide, Prompt Inspector."
2315                  - "Base instruction marker."
2316                  - "Think through this step by step"
2317                  - "<instruction>"
2318                user_contains: first question
2319                count: 1
2320                same_request: true
2321          - input: second question
2322            assert:
2323              llm_request:
2324                user_contains: [first question, second question]
2325                assistant_contains: first answer
2326                count: 1
2327                same_request: true
2328      - !reset_agent true
2329      - !run
2330        turns:
2331          - input: after reset
2332            assert:
2333              llm_request:
2334                system_contains: "Base instruction marker."
2335                user_contains: after reset
2336                count: 1
2337                same_request: true
2338"#,
2339                    )
2340                    .await;
2341
2342                    assert_eq!(result.passed, 1);
2343                    let turns = &result.scenarios[0].attempts[0].turns;
2344                    assert_eq!(turns.len(), 3);
2345                    assert!(
2346                        turns
2347                            .iter()
2348                            .all(|turn| turn.evidence.llm_requests.len() == 1)
2349                    );
2350                    let second_messages = &turns[1].evidence.llm_requests[0].messages;
2351                    assert!(second_messages.iter().any(|message| {
2352                        message.role == ai_agents_core::Role::Assistant
2353                            && message.content.contains("first answer")
2354                    }));
2355                    let reset_messages = &turns[2].evidence.llm_requests[0].messages;
2356                    assert!(
2357                        reset_messages
2358                            .iter()
2359                            .all(|message| !message.content.contains("first question"))
2360                    );
2361                    assert!(
2362                        reset_messages
2363                            .iter()
2364                            .all(|message| !message.content.contains("first answer"))
2365                    );
2366
2367                    let serialized = serde_json::to_string(&result).unwrap();
2368                    let serialized_value: Value = serde_json::from_str(&serialized).unwrap();
2369                    assert!(
2370                        serialized_value["scenarios"][0]["attempts"][0]["turns"][0]
2371                            .get("evidence")
2372                            .is_none()
2373                    );
2374                    assert!(!serialized.contains("Base instruction marker"));
2375                    assert!(!serialized.contains("Evidence Guide"));
2376                    assert!(!serialized.contains("Think through this step by step"));
2377                    let _ = std::fs::remove_dir_all(dir);
2378                });
2379            })
2380            .unwrap()
2381            .join()
2382            .unwrap();
2383    }
2384
2385    #[test]
2386    fn runner_executes_mocked_suite_and_redacts_outputs() {
2387        std::thread::Builder::new()
2388            .name("eval-runner-test".to_string())
2389            .stack_size(16 * 1024 * 1024)
2390            .spawn(|| {
2391                let runtime = tokio::runtime::Runtime::new().unwrap();
2392                runtime.block_on(async {
2393                    let dir = std::env::temp_dir().join(format!(
2394                        "ai_agents_eval_runner_test_{}",
2395                        uuid::Uuid::new_v4()
2396                    ));
2397                    std::fs::create_dir_all(&dir).unwrap();
2398                    write_test_agent(&dir);
2399                    let suite_path = dir.join("suite.yaml");
2400                    std::fs::write(
2401                        &suite_path,
2402                        r#"
2403name: Runner Suite
2404agent: agent.yaml
2405fixtures:
2406  llm:
2407    mode: mock
2408    responses:
2409      - "Hello from mock"
2410scenarios:
2411  - id: smoke
2412    turns:
2413      - input: Hello
2414        assert:
2415          response_contains: "Hello"
2416"#,
2417                    )
2418                    .unwrap();
2419                    let options = EvalRunnerOptions {
2420                        output: dir.join("out"),
2421                        ..Default::default()
2422                    };
2423                    let runner = EvalRunner::from_file(&suite_path, options).unwrap();
2424                    let result = runner.run().await.unwrap();
2425                    assert_eq!(result.passed, 1);
2426                    let turn = &result.scenarios[0].attempts[0].turns[0];
2427                    assert_eq!(turn.input.value, "[redacted]");
2428                    assert_eq!(turn.response.value, "[redacted]");
2429                    let json = serde_json::to_string(&result).unwrap();
2430                    assert!(!json.contains("Hello from mock"));
2431                    let _ = std::fs::remove_dir_all(dir);
2432                });
2433            })
2434            .unwrap()
2435            .join()
2436            .unwrap();
2437    }
2438}