Skip to main content

ai_agents_eval/
runner.rs

1use std::collections::{HashMap, HashSet};
2use std::path::{Path, PathBuf};
3use std::sync::{Arc, Mutex, OnceLock, RwLock, RwLockReadGuard, RwLockWriteGuard};
4use std::time::Instant;
5
6use ai_agents_hooks::AgentHooks;
7use ai_agents_observability::config::{ExportConfig, ExportFormat};
8use ai_agents_observability::{CostEstimator, ObservabilityConfig};
9use ai_agents_runtime::spec::{AgentSpec, LLMConfigOrSelector, StorageConfig};
10use ai_agents_runtime::{Agent, AgentBuilder, AgentStreamEvent, RuntimeAgent, StreamChunk};
11use async_trait::async_trait;
12use futures::{StreamExt, stream};
13use serde_json::{Value, json};
14use tokio::time::{Duration, Instant as TokioInstant, timeout, timeout_at};
15
16use crate::assertion::{
17    Assertion, AssertionEvalContext, AssertionOutcome, AssertionResultDetail, evaluate_assertion,
18};
19use crate::budget::{BudgetProviderConfig, ScenarioBudgetTracker};
20use crate::compatibility::suite_from_jsonl;
21use crate::evidence::{
22    ApprovalEvidence, LlmRequestEvidence, collect_turn_evidence_since, 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 observability_cursor = agent.observability().map(|manager| manager.event_cursor());
924        let start = Instant::now();
925        let timeout_ms = turn
926            .timeout_ms
927            .unwrap_or(self.suite.settings.timeout_per_turn_ms);
928        let mut operation = if turn.stream.unwrap_or(false) {
929            collect_stream_response(agent, &turn.input, timeout_ms).await
930        } else {
931            match timeout(Duration::from_millis(timeout_ms), agent.chat(&turn.input)).await {
932                Ok(Ok(response)) => TurnOperation {
933                    response_content: response.content,
934                    response_metadata: response.metadata,
935                    response_present: true,
936                    runtime_error: None,
937                },
938                Ok(Err(error)) => TurnOperation::error(error.to_string()),
939                Err(_) => TurnOperation::error(format!("turn timed out after {}ms", timeout_ms)),
940            }
941        };
942        if let Err(error) = agent.flush_background_tasks().await
943            && operation.runtime_error.is_none()
944        {
945            operation.runtime_error = Some(error.to_string());
946        }
947        let latency_ms = start.elapsed().as_millis() as u64;
948        let mut evidence = collect_turn_evidence_since(
949            agent,
950            operation.response_metadata.clone(),
951            tool_log,
952            tool_start,
953            before_relationship,
954            observability_cursor,
955        );
956        evidence.approvals = approval_log.records_since(approval_start);
957        evidence.llm_requests = llm_log.records_since(llm_start);
958        let judge = self.build_judge(agent);
959        let mut assertion_results = if let Some(assertion) = &turn.assertions {
960            match evaluate_assertion(
961                assertion,
962                AssertionEvalContext {
963                    evidence: &evidence,
964                    response: &operation.response_content,
965                    user_input: Some(&turn.input),
966                    scenario_id: Some(&scenario.id),
967                    language: scenario.language.as_deref(),
968                    judge_resolver: Some(&judge),
969                },
970            )
971            .await
972            {
973                AssertionOutcome::Passed(details) | AssertionOutcome::Failed(details) => details,
974                AssertionOutcome::Error(message) => return Err(EvalError::Assertion(message)),
975            }
976        } else {
977            Vec::new()
978        };
979        if !operation.response_present
980            && turn
981                .assertions
982                .as_ref()
983                .is_some_and(assertion_uses_response)
984        {
985            assertion_results.push(AssertionResultDetail {
986                assertion: "response_present".to_string(),
987                passed: false,
988                actual: json!(false),
989                expected: json!(true),
990                message: Some("response assertions require a runtime response".to_string()),
991            });
992        }
993
994        let expected_error = turn_expected_error(turn);
995        let unhandled_runtime_error = match (&expected_error, &operation.runtime_error) {
996            (Some(expected), Some(error)) if expected.matches(error) => {
997                assertion_results.push(AssertionResultDetail {
998                    assertion: "expect_error".to_string(),
999                    passed: true,
1000                    actual: json!(error),
1001                    expected: json!(expected.items()),
1002                    message: None,
1003                });
1004                None
1005            }
1006            (Some(expected), Some(error)) => {
1007                assertion_results.push(AssertionResultDetail {
1008                    assertion: "expect_error".to_string(),
1009                    passed: false,
1010                    actual: json!(error),
1011                    expected: json!(expected.items()),
1012                    message: Some("runtime error did not match any expected substring".to_string()),
1013                });
1014                Some(format!(
1015                    "runtime error did not match expect_error: {}",
1016                    error
1017                ))
1018            }
1019            (Some(expected), None) => {
1020                assertion_results.push(AssertionResultDetail {
1021                    assertion: "expect_error".to_string(),
1022                    passed: false,
1023                    actual: Value::Null,
1024                    expected: json!(expected.items()),
1025                    message: Some("expected a runtime error but the turn completed".to_string()),
1026                });
1027                None
1028            }
1029            (None, Some(error)) => Some(error.clone()),
1030            (None, None) => None,
1031        };
1032        if self.suite.settings.redact_outputs {
1033            redact_assertion_details(&mut assertion_results);
1034        }
1035        let observability_span_id = evidence
1036            .observability
1037            .as_ref()
1038            .and_then(|obs| obs.span_ids.last().cloned());
1039        let runtime_error = operation
1040            .runtime_error
1041            .as_deref()
1042            .map(|error| redact_text(error, self.suite.settings.redact_outputs, 0));
1043        let unhandled_runtime_error = unhandled_runtime_error
1044            .map(|error| redact_text(&error, self.suite.settings.redact_outputs, 0).value);
1045        Ok(TurnExecution {
1046            result: TurnResult {
1047                index,
1048                input: redact_text(&turn.input, self.suite.settings.redact_outputs, 0),
1049                response: if operation.response_present {
1050                    redact_text(
1051                        &operation.response_content,
1052                        self.suite.settings.redact_outputs,
1053                        0,
1054                    )
1055                } else {
1056                    crate::redaction::RedactedString::plain("")
1057                },
1058                response_present: operation.response_present,
1059                runtime_error,
1060                state: evidence.state.clone(),
1061                metadata: if self.suite.settings.redact_outputs {
1062                    None
1063                } else {
1064                    operation
1065                        .response_metadata
1066                        .and_then(|metadata| serde_json::to_value(metadata).ok())
1067                },
1068                evidence,
1069                assertion_results,
1070                latency_ms,
1071                observability_span_id,
1072            },
1073            unhandled_runtime_error,
1074        })
1075    }
1076
1077    fn build_judge(&self, agent: &RuntimeAgent) -> JudgeResolver {
1078        JudgeResolver::new(Arc::clone(agent.llm_registry()), JudgeConfig::default())
1079    }
1080}
1081
1082struct TurnExecution {
1083    result: TurnResult,
1084    unhandled_runtime_error: Option<String>,
1085}
1086
1087struct TurnOperation {
1088    response_content: String,
1089    response_metadata: Option<HashMap<String, Value>>,
1090    response_present: bool,
1091    runtime_error: Option<String>,
1092}
1093
1094impl TurnOperation {
1095    fn error(message: String) -> Self {
1096        Self {
1097            response_content: String::new(),
1098            response_metadata: None,
1099            response_present: false,
1100            runtime_error: Some(message),
1101        }
1102    }
1103}
1104
1105async fn collect_stream_response(
1106    agent: &RuntimeAgent,
1107    input: &str,
1108    timeout_ms: u64,
1109) -> TurnOperation {
1110    let deadline = TokioInstant::now() + Duration::from_millis(timeout_ms);
1111    let stream = match timeout_at(deadline, agent.chat_stream_events(input)).await {
1112        Ok(Ok(stream)) => stream,
1113        Ok(Err(error)) => return TurnOperation::error(error.to_string()),
1114        Err(_) => return TurnOperation::error(format!("turn timed out after {}ms", timeout_ms)),
1115    };
1116    consume_stream_response(stream, deadline, timeout_ms).await
1117}
1118
1119// Uses Final as the successful result and treats the first Error as terminal; expected HITL rejection is delivered as Final.
1120async fn consume_stream_response<S>(
1121    mut stream: S,
1122    deadline: TokioInstant,
1123    timeout_ms: u64,
1124) -> TurnOperation
1125where
1126    S: futures::Stream<Item = AgentStreamEvent> + Unpin,
1127{
1128    let mut partial_content = String::new();
1129    let mut partial_content_seen = false;
1130    let mut runtime_error = None;
1131    let mut final_response = None;
1132    loop {
1133        match timeout_at(deadline, stream.next()).await {
1134            Ok(Some(AgentStreamEvent::Chunk(StreamChunk::Content { text }))) => {
1135                partial_content_seen = true;
1136                partial_content.push_str(&text);
1137            }
1138            Ok(Some(AgentStreamEvent::Chunk(StreamChunk::Error { message }))) => {
1139                return TurnOperation {
1140                    response_content: partial_content,
1141                    response_metadata: None,
1142                    response_present: partial_content_seen,
1143                    runtime_error: Some(message),
1144                };
1145            }
1146            Ok(Some(AgentStreamEvent::Chunk(_))) => {}
1147            Ok(Some(AgentStreamEvent::Final(response))) => {
1148                final_response = Some(response);
1149                break;
1150            }
1151            Ok(Some(_)) => {}
1152            Ok(None) => break,
1153            Err(_) => {
1154                if runtime_error.is_none() {
1155                    runtime_error = Some(format!("turn timed out after {}ms", timeout_ms));
1156                }
1157                break;
1158            }
1159        }
1160    }
1161
1162    if let Some(response) = final_response {
1163        return TurnOperation {
1164            response_content: response.content,
1165            response_metadata: response.metadata,
1166            response_present: true,
1167            runtime_error,
1168        };
1169    }
1170    if runtime_error.is_none() {
1171        runtime_error = Some("stream ended before Final".to_string());
1172    }
1173    TurnOperation {
1174        response_content: partial_content,
1175        response_metadata: None,
1176        response_present: partial_content_seen,
1177        runtime_error,
1178    }
1179}
1180
1181fn assertion_uses_response(assertion: &Assertion) -> bool {
1182    assertion.response_contains.is_some()
1183        || assertion.response_contains_any.is_some()
1184        || assertion.response_not_contains.is_some()
1185        || assertion.response_not_empty.is_some()
1186        || assertion.response_semantic.is_some()
1187        || assertion.judge.is_some()
1188        || assertion
1189            .all
1190            .as_ref()
1191            .is_some_and(|children| children.iter().any(assertion_uses_response))
1192        || assertion
1193            .any
1194            .as_ref()
1195            .is_some_and(|children| children.iter().any(assertion_uses_response))
1196        || assertion
1197            .not
1198            .as_deref()
1199            .is_some_and(assertion_uses_response)
1200}
1201
1202fn apply_workspace_policy(
1203    spec: &mut AgentSpec,
1204    config: Option<&WorkspacePolicyFixtureConfig>,
1205    context: &AttemptFixtureContext,
1206) -> Result<()> {
1207    let Some(config) = config else {
1208        return Ok(());
1209    };
1210    if !context.workspace.is_absolute() {
1211        return Err(EvalError::Config(
1212            "eval attempt workspace must be absolute".to_string(),
1213        ));
1214    }
1215
1216    for tool_id in config.read_tools.iter().chain(&config.write_tools) {
1217        if !spec.tool_security.tools.contains_key(tool_id) {
1218            return Err(EvalError::Config(format!(
1219                "fixtures.workspace_policy names tool '{}' without an existing tool policy",
1220                tool_id
1221            )));
1222        }
1223    }
1224
1225    let workspace = context.workspace.display().to_string();
1226    for tool_id in &config.read_tools {
1227        spec.tool_security
1228            .tools
1229            .get_mut(tool_id)
1230            .expect("workspace policy tool was validated")
1231            .read_paths
1232            .push(workspace.clone());
1233    }
1234    for tool_id in &config.write_tools {
1235        spec.tool_security
1236            .tools
1237            .get_mut(tool_id)
1238            .expect("workspace policy tool was validated")
1239            .write_paths
1240            .push(workspace.clone());
1241    }
1242    Ok(())
1243}
1244
1245fn isolate_spec_storage(spec: &mut AgentSpec, context: &AttemptFixtureContext) {
1246    isolate_storage_config(
1247        &mut spec.storage,
1248        context,
1249        "parent-storage",
1250        "parent-storage.db",
1251    );
1252    if let Some(shared_storage) = spec
1253        .spawner
1254        .as_mut()
1255        .and_then(|spawner| spawner.shared_storage.as_mut())
1256    {
1257        isolate_storage_config(
1258            shared_storage,
1259            context,
1260            "spawner-shared-storage",
1261            "spawner-shared-storage.db",
1262        );
1263    }
1264}
1265
1266fn isolate_storage_config(
1267    storage: &mut StorageConfig,
1268    context: &AttemptFixtureContext,
1269    file_name: &str,
1270    sqlite_name: &str,
1271) {
1272    match storage {
1273        StorageConfig::File(config) => {
1274            config.path = context.workspace.join(file_name).display().to_string();
1275        }
1276        StorageConfig::Sqlite(config) => {
1277            config.path = context.workspace.join(sqlite_name).display().to_string();
1278        }
1279        StorageConfig::Redis(config) => {
1280            let prefix = config.prefix.as_deref().unwrap_or("agent:");
1281            config.prefix = Some(format!("{}eval:{}:", prefix, context.isolation_id));
1282        }
1283        StorageConfig::None => {}
1284    }
1285}
1286
1287fn apply_context_map(agent: &RuntimeAgent, values: HashMap<String, Value>) -> Result<()> {
1288    for (key, value) in values {
1289        agent.set_context(&key, value)?;
1290    }
1291    Ok(())
1292}
1293
1294fn apply_context_value(agent: &RuntimeAgent, value: &Value) -> Result<()> {
1295    let Value::Object(map) = value else {
1296        return Ok(());
1297    };
1298    for (key, value) in map {
1299        agent.set_context(key, value.clone())?;
1300    }
1301    Ok(())
1302}
1303
1304fn apply_base_context(
1305    agent: &RuntimeAgent,
1306    suite: &EvalSuite,
1307    base_dir: &Path,
1308    scenario: &Scenario,
1309    attempt_context: &AttemptFixtureContext,
1310) -> Result<()> {
1311    apply_context_map(agent, resolve_fixture_context(&suite.fixtures, base_dir)?)?;
1312    apply_context_map(agent, attempt_context.runtime_context())?;
1313    apply_context_value(agent, &scenario.context)?;
1314    if let Some(actor) = &scenario.actor {
1315        agent.set_actor_id(actor)?;
1316    }
1317    Ok(())
1318}
1319
1320fn reset_options(config: &ResetStepConfig) -> Option<crate::reset::ResetOptions> {
1321    match config {
1322        ResetStepConfig::Bool(false) => None,
1323        ResetStepConfig::Bool(true) => Some(crate::reset::ResetOptions::default()),
1324        ResetStepConfig::Options(options) => Some(options.clone()),
1325    }
1326}
1327
1328fn redact_assertion_details(details: &mut [crate::assertion::AssertionResultDetail]) {
1329    for detail in details {
1330        detail.actual = redact_value(std::mem::take(&mut detail.actual), true, 0);
1331        detail.expected = redact_value(std::mem::take(&mut detail.expected), true, 0);
1332    }
1333}
1334
1335fn error_result(
1336    scenario: &Scenario,
1337    error: EvalError,
1338    category: FailureCategory,
1339) -> ScenarioResult {
1340    ScenarioResult {
1341        id: scenario.id.clone(),
1342        name: scenario.name.clone(),
1343        tags: scenario.tags.clone(),
1344        language: scenario.language.clone(),
1345        status: ScenarioStatus::Error {
1346            message: error.to_string(),
1347        },
1348        failure_category: Some(category),
1349        flaky: false,
1350        attempts: Vec::new(),
1351        duration_ms: 0,
1352        retries_used: 0,
1353    }
1354}
1355
1356fn failure_category_for_attempt(attempt: &AttemptResult) -> FailureCategory {
1357    let judge_failed = attempt.turns.iter().any(|turn| {
1358        turn.assertion_results
1359            .iter()
1360            .any(|detail| !detail.passed && detail.assertion == "judge")
1361    });
1362    if judge_failed {
1363        FailureCategory::JudgeError
1364    } else {
1365        FailureCategory::AssertionFailed
1366    }
1367}
1368
1369fn final_observability_report(
1370    results: &[ScenarioResult],
1371) -> Option<ai_agents_observability::ObservabilityReport> {
1372    results
1373        .iter()
1374        .rev()
1375        .flat_map(|scenario| scenario.attempts.iter().rev())
1376        .flat_map(|attempt| attempt.turns.iter().rev())
1377        .find_map(|turn| {
1378            turn.evidence
1379                .observability
1380                .as_ref()
1381                .and_then(|obs| obs.report.clone())
1382        })
1383}
1384
1385fn apply_eval_llm_settings(spec: &mut AgentSpec, settings: &crate::suite::EvalSettings) {
1386    if let LLMConfigOrSelector::Config(config) = &mut spec.llm {
1387        apply_llm_config_settings(config, settings);
1388    }
1389    for config in spec.llms.values_mut() {
1390        apply_llm_config_settings(config, settings);
1391    }
1392}
1393
1394fn budget_provider_configs(spec: &AgentSpec) -> HashMap<String, BudgetProviderConfig> {
1395    if spec.llms.is_empty() {
1396        let config = spec.llm.as_config().cloned().unwrap_or_default();
1397        return HashMap::from([(
1398            "default".to_string(),
1399            BudgetProviderConfig {
1400                provider: config.provider,
1401                model: config.model,
1402                max_output_tokens: config.max_tokens,
1403            },
1404        )]);
1405    }
1406    spec.llms
1407        .iter()
1408        .map(|(alias, config)| {
1409            (
1410                alias.clone(),
1411                BudgetProviderConfig {
1412                    provider: config.provider.clone(),
1413                    model: config.model.clone(),
1414                    max_output_tokens: config.max_tokens,
1415                },
1416            )
1417        })
1418        .collect()
1419}
1420
1421fn apply_llm_config_settings(
1422    config: &mut ai_agents_runtime::spec::LLMConfig,
1423    settings: &crate::suite::EvalSettings,
1424) {
1425    if let Some(temperature) = settings.temperature {
1426        config.temperature = temperature;
1427    }
1428    if let Some(seed) = settings.seed {
1429        config.extra.insert("seed".to_string(), json!(seed));
1430    }
1431}
1432
1433/// Process-wide exclusion held for an eval attempt's environment access.
1434enum EnvExclusionGuard {
1435    Read {
1436        _guard: RwLockReadGuard<'static, ()>,
1437    },
1438    Write {
1439        _guard: RwLockWriteGuard<'static, ()>,
1440    },
1441}
1442
1443/// Restores process environment variables when an attempt ends.
1444struct EnvGuard {
1445    /// Previous env values restored on drop.
1446    previous: Vec<(String, Option<String>)>,
1447    /// Shared access for unchanged env or exclusive access for mutations.
1448    _guard: EnvExclusionGuard,
1449}
1450
1451impl EnvGuard {
1452    fn apply(values: &HashMap<String, String>) -> Result<Self> {
1453        static ENV_LOCK: OnceLock<RwLock<()>> = OnceLock::new();
1454        let lock = ENV_LOCK.get_or_init(|| RwLock::new(()));
1455        if values.is_empty() {
1456            let guard = lock.read().map_err(|_| {
1457                EvalError::Runtime("failed to lock eval environment guard".to_string())
1458            })?;
1459            return Ok(Self {
1460                previous: Vec::new(),
1461                _guard: EnvExclusionGuard::Read { _guard: guard },
1462            });
1463        }
1464        let guard = lock
1465            .write()
1466            .map_err(|_| EvalError::Runtime("failed to lock eval environment guard".to_string()))?;
1467        let mut previous = Vec::new();
1468        for (key, value) in values {
1469            previous.push((key.clone(), std::env::var(key).ok()));
1470            unsafe {
1471                std::env::set_var(key, value);
1472            }
1473        }
1474        Ok(Self {
1475            previous,
1476            _guard: EnvExclusionGuard::Write { _guard: guard },
1477        })
1478    }
1479}
1480
1481impl Drop for EnvGuard {
1482    fn drop(&mut self) {
1483        for (key, value) in self.previous.drain(..).rev() {
1484            unsafe {
1485                if let Some(value) = value {
1486                    std::env::set_var(key, value);
1487                } else {
1488                    std::env::remove_var(key);
1489                }
1490            }
1491        }
1492    }
1493}
1494
1495#[cfg(test)]
1496mod tests {
1497    use super::*;
1498
1499    fn attempt_workspace(id: &str) -> PathBuf {
1500        std::env::temp_dir().join(format!("ai-agents-eval-{id}"))
1501    }
1502
1503    #[test]
1504    fn strict_suite_loader_rejects_nested_observability_typos() {
1505        let error = parse_eval_suite_yaml(
1506            r#"
1507name: strict
1508agent: agent.yaml
1509observability:
1510  enabeld: true
1511scenarios:
1512  - id: scenario
1513    turns:
1514      - input: hello
1515"#,
1516        )
1517        .unwrap_err()
1518        .to_string();
1519
1520        assert!(error.contains("enabeld"), "{error}");
1521    }
1522
1523    #[test]
1524    fn storage_isolation_rewrites_parent_and_spawner_backends() {
1525        let workspace = attempt_workspace("attempt-a");
1526        let context = AttemptFixtureContext {
1527            isolation_id: "attempt-a".to_string(),
1528            workspace: workspace.clone(),
1529            mock_server_base_url: None,
1530        };
1531        let mut file_spec: AgentSpec = serde_yaml::from_str(
1532            r#"
1533name: FileAgent
1534system_prompt: test
1535storage: { type: file, path: ./parent }
1536spawner:
1537  shared_storage: { type: sqlite, path: ./shared.db, table: shared_sessions }
1538"#,
1539        )
1540        .unwrap();
1541        isolate_spec_storage(&mut file_spec, &context);
1542        assert_eq!(
1543            file_spec.storage.get_path().map(PathBuf::from),
1544            Some(workspace.join("parent-storage"))
1545        );
1546        let shared = file_spec
1547            .spawner
1548            .as_ref()
1549            .unwrap()
1550            .shared_storage
1551            .as_ref()
1552            .unwrap();
1553        assert_eq!(
1554            shared.get_path().map(PathBuf::from),
1555            Some(workspace.join("spawner-shared-storage.db"))
1556        );
1557        assert_eq!(shared.get_table(), Some("shared_sessions"));
1558
1559        let mut redis_spec: AgentSpec = serde_yaml::from_str(
1560            r#"
1561name: RedisAgent
1562system_prompt: test
1563storage: { type: redis, url: redis://localhost, prefix: "parent:" }
1564spawner:
1565  shared_storage: { type: redis, url: redis://localhost }
1566"#,
1567        )
1568        .unwrap();
1569        isolate_spec_storage(&mut redis_spec, &context);
1570        assert_eq!(redis_spec.storage.get_prefix(), "parent:eval:attempt-a:");
1571        assert_eq!(
1572            redis_spec
1573                .spawner
1574                .as_ref()
1575                .unwrap()
1576                .shared_storage
1577                .as_ref()
1578                .unwrap()
1579                .get_prefix(),
1580            "agent:eval:attempt-a:"
1581        );
1582
1583        let other_context = AttemptFixtureContext {
1584            isolation_id: "attempt-b".to_string(),
1585            workspace: attempt_workspace("attempt-b"),
1586            mock_server_base_url: None,
1587        };
1588        let mut other_redis: AgentSpec = serde_yaml::from_str(
1589            r#"
1590name: RedisAgent
1591system_prompt: test
1592storage: { type: redis, url: redis://localhost, prefix: "parent:" }
1593"#,
1594        )
1595        .unwrap();
1596        isolate_spec_storage(&mut other_redis, &other_context);
1597        assert_ne!(
1598            redis_spec.storage.get_prefix(),
1599            other_redis.storage.get_prefix()
1600        );
1601    }
1602
1603    #[test]
1604    fn workspace_policy_is_narrow_and_isolated_per_attempt() {
1605        let source: AgentSpec = serde_yaml::from_str(
1606            r#"
1607name: PolicyAgent
1608system_prompt: test
1609tool_security:
1610  enabled: true
1611  fail_closed: true
1612  tools:
1613    file_read:
1614      read_paths: [./source]
1615      blocked_paths: [./blocked]
1616    file_write:
1617      write_paths: [./output]
1618      blocked_paths: [./blocked]
1619    grep:
1620      read_paths: [./repository]
1621"#,
1622        )
1623        .unwrap();
1624        let config = WorkspacePolicyFixtureConfig {
1625            read_tools: vec!["file_read".to_string()],
1626            write_tools: vec!["file_write".to_string()],
1627        };
1628        let first_workspace = attempt_workspace("attempt-a");
1629        let second_workspace = attempt_workspace("attempt-b");
1630        let first_context = AttemptFixtureContext {
1631            isolation_id: "attempt-a".to_string(),
1632            workspace: first_workspace.clone(),
1633            mock_server_base_url: None,
1634        };
1635        let second_context = AttemptFixtureContext {
1636            isolation_id: "attempt-b".to_string(),
1637            workspace: second_workspace.clone(),
1638            mock_server_base_url: None,
1639        };
1640
1641        let mut first = source.clone();
1642        apply_workspace_policy(&mut first, Some(&config), &first_context).unwrap();
1643        let mut second = source.clone();
1644        apply_workspace_policy(&mut second, Some(&config), &second_context).unwrap();
1645
1646        assert!(first.tool_security.fail_closed);
1647        assert_eq!(
1648            first.tool_security.tools["file_read"].read_paths,
1649            vec![
1650                "./source".to_string(),
1651                first_workspace.display().to_string()
1652            ]
1653        );
1654        assert_eq!(
1655            first.tool_security.tools["file_write"].write_paths,
1656            vec![
1657                "./output".to_string(),
1658                first_workspace.display().to_string()
1659            ]
1660        );
1661        assert_eq!(
1662            first.tool_security.tools["file_read"].blocked_paths,
1663            vec!["./blocked"]
1664        );
1665        assert_eq!(
1666            first.tool_security.tools["file_write"].blocked_paths,
1667            vec!["./blocked"]
1668        );
1669        assert_eq!(
1670            first.tool_security.tools["grep"].read_paths,
1671            vec!["./repository"]
1672        );
1673        assert_eq!(
1674            second.tool_security.tools["file_read"].read_paths,
1675            vec![
1676                "./source".to_string(),
1677                second_workspace.display().to_string()
1678            ]
1679        );
1680        assert_eq!(
1681            source.tool_security.tools["file_read"].read_paths,
1682            vec!["./source"]
1683        );
1684        assert_eq!(
1685            source.tool_security.tools["file_write"].write_paths,
1686            vec!["./output"]
1687        );
1688    }
1689
1690    #[test]
1691    fn workspace_policy_rejects_unknown_tools_without_partial_mutation() {
1692        let mut spec: AgentSpec = serde_yaml::from_str(
1693            r#"
1694name: PolicyAgent
1695system_prompt: test
1696tool_security:
1697  enabled: true
1698  fail_closed: true
1699  tools:
1700    file_read:
1701      read_paths: [./source]
1702"#,
1703        )
1704        .unwrap();
1705        let original = spec.tool_security.tools["file_read"].read_paths.clone();
1706        let config = WorkspacePolicyFixtureConfig {
1707            read_tools: vec!["file_read".to_string(), "missing_tool".to_string()],
1708            write_tools: Vec::new(),
1709        };
1710        let context = AttemptFixtureContext {
1711            isolation_id: "attempt-a".to_string(),
1712            workspace: attempt_workspace("attempt-a"),
1713            mock_server_base_url: None,
1714        };
1715
1716        let error = apply_workspace_policy(&mut spec, Some(&config), &context).unwrap_err();
1717
1718        assert!(error.to_string().contains("missing_tool"));
1719        assert!(
1720            error
1721                .to_string()
1722                .contains("without an existing tool policy")
1723        );
1724        assert_eq!(spec.tool_security.tools["file_read"].read_paths, original);
1725        assert!(spec.tool_security.fail_closed);
1726    }
1727
1728    #[tokio::test]
1729    async fn generated_attempt_context_has_stable_reset_precedence() {
1730        let dir = std::env::temp_dir().join(format!(
1731            "ai_agents_eval_attempt_context_test_{}",
1732            uuid::Uuid::new_v4()
1733        ));
1734        std::fs::create_dir_all(&dir).unwrap();
1735        write_test_agent(&dir);
1736        let suite_path = dir.join("suite.yaml");
1737        std::fs::write(
1738            &suite_path,
1739            r#"
1740name: Attempt Context
1741agent: agent.yaml
1742fixtures:
1743  context:
1744    eval: { workspace: fixture-value }
1745    mock_server: { base_url: fixture-value }
1746    fixture_only: true
1747  llm:
1748    mode: mock
1749    responses: [ok]
1750scenarios:
1751  - id: context
1752    context: { scenario_only: true, precedence: scenario }
1753    turns:
1754      - input: test
1755        context: { precedence: turn }
1756"#,
1757        )
1758        .unwrap();
1759        let runner = EvalRunner::from_file(
1760            &suite_path,
1761            EvalRunnerOptions {
1762                output: dir.join("out"),
1763                ..Default::default()
1764            },
1765        )
1766        .unwrap();
1767        let attempt_context = AttemptFixtureContext {
1768            isolation_id: "stable-attempt".to_string(),
1769            workspace: dir
1770                .join("workspace")
1771                .canonicalize()
1772                .unwrap_or_else(|_| dir.join("workspace")),
1773            mock_server_base_url: Some("http://127.0.0.1:40000".to_string()),
1774        };
1775        std::fs::create_dir_all(&attempt_context.workspace).unwrap();
1776        let scenario = &runner.suite.scenarios[0];
1777        let tool_log = RecordingToolLog::new();
1778        let approval_log = RecordingApprovalLog::default();
1779        let llm_log = RecordingLlmLog::default();
1780        let first = runner
1781            .build_agent(BuildAgentParams {
1782                agent_path: &dir.join("agent.yaml"),
1783                base_dir: &dir,
1784                attempt_context: &attempt_context,
1785                tool_log: tool_log.clone(),
1786                approval_log: approval_log.clone(),
1787                llm_log: llm_log.clone(),
1788                approval_handler: None,
1789                budget: None,
1790            })
1791            .await
1792            .unwrap();
1793        apply_base_context(&first, &runner.suite, &dir, scenario, &attempt_context).unwrap();
1794        let first_context = first.get_context();
1795        assert_eq!(
1796            first_context["eval"]["workspace"],
1797            json!(attempt_context.workspace.display().to_string())
1798        );
1799        assert_eq!(
1800            first_context["mock_server"]["base_url"],
1801            "http://127.0.0.1:40000"
1802        );
1803        assert_eq!(first_context["precedence"], "scenario");
1804        apply_context_value(&first, &turn_runtime_context(&scenario.turns[0])).unwrap();
1805        assert_eq!(first.get_context()["precedence"], "turn");
1806
1807        let reset = runner
1808            .build_agent(BuildAgentParams {
1809                agent_path: &dir.join("agent.yaml"),
1810                base_dir: &dir,
1811                attempt_context: &attempt_context,
1812                tool_log,
1813                approval_log,
1814                llm_log,
1815                approval_handler: None,
1816                budget: None,
1817            })
1818            .await
1819            .unwrap();
1820        apply_base_context(&reset, &runner.suite, &dir, scenario, &attempt_context).unwrap();
1821        assert_eq!(reset.get_context()["eval"], first_context["eval"]);
1822        assert_eq!(
1823            reset.get_context()["mock_server"],
1824            first_context["mock_server"]
1825        );
1826        let _ = std::fs::remove_dir_all(dir);
1827    }
1828
1829    #[tokio::test]
1830    async fn streaming_final_response_is_authoritative() {
1831        let response = ai_agents_runtime::AgentResponse::new("authoritative")
1832            .with_metadata("source", json!("final"));
1833        let events = stream::iter(vec![
1834            AgentStreamEvent::Chunk(StreamChunk::Content {
1835                text: "partial content".to_string(),
1836            }),
1837            AgentStreamEvent::Chunk(StreamChunk::Done {}),
1838            AgentStreamEvent::Final(response),
1839        ]);
1840        let operation =
1841            consume_stream_response(events, TokioInstant::now() + Duration::from_secs(1), 1_000)
1842                .await;
1843
1844        assert_eq!(operation.response_content, "authoritative");
1845        assert_eq!(
1846            operation
1847                .response_metadata
1848                .as_ref()
1849                .and_then(|metadata| metadata.get("source")),
1850            Some(&json!("final"))
1851        );
1852        assert!(operation.response_present);
1853        assert_eq!(operation.runtime_error, None);
1854    }
1855
1856    #[tokio::test]
1857    async fn streaming_final_response_is_present_without_content_chunks() {
1858        let events = stream::iter(vec![AgentStreamEvent::Final(
1859            ai_agents_runtime::AgentResponse::new(""),
1860        )]);
1861        let operation =
1862            consume_stream_response(events, TokioInstant::now() + Duration::from_secs(1), 1_000)
1863                .await;
1864
1865        assert_eq!(operation.response_content, "");
1866        assert!(operation.response_present);
1867        assert_eq!(operation.runtime_error, None);
1868    }
1869
1870    #[tokio::test]
1871    async fn streaming_error_is_terminal_and_retains_prior_partial_content() {
1872        use std::sync::atomic::{AtomicUsize, Ordering};
1873
1874        let polled = Arc::new(AtomicUsize::new(0));
1875        let polled_events = Arc::clone(&polled);
1876        let events = stream::iter(vec![
1877            AgentStreamEvent::Chunk(StreamChunk::Content {
1878                text: "before ".to_string(),
1879            }),
1880            AgentStreamEvent::Chunk(StreamChunk::Error {
1881                message: "stream failed".to_string(),
1882            }),
1883            AgentStreamEvent::Chunk(StreamChunk::Content {
1884                text: "after".to_string(),
1885            }),
1886            AgentStreamEvent::Chunk(StreamChunk::Done {}),
1887        ])
1888        .inspect(move |_| {
1889            polled_events.fetch_add(1, Ordering::Relaxed);
1890        });
1891        let operation =
1892            consume_stream_response(events, TokioInstant::now() + Duration::from_secs(1), 1_000)
1893                .await;
1894
1895        assert_eq!(operation.response_content, "before ");
1896        assert!(operation.response_present);
1897        assert_eq!(operation.response_metadata, None);
1898        assert_eq!(operation.runtime_error.as_deref(), Some("stream failed"));
1899        assert_eq!(polled.load(Ordering::Relaxed), 2);
1900    }
1901
1902    #[tokio::test]
1903    async fn streaming_eof_without_final_is_incomplete() {
1904        let events = stream::iter(vec![AgentStreamEvent::Chunk(StreamChunk::Content {
1905            text: "partial".to_string(),
1906        })]);
1907        let operation =
1908            consume_stream_response(events, TokioInstant::now() + Duration::from_secs(1), 1_000)
1909                .await;
1910
1911        assert_eq!(operation.response_content, "partial");
1912        assert!(operation.response_present);
1913        assert_eq!(operation.response_metadata, None);
1914        assert_eq!(
1915            operation.runtime_error.as_deref(),
1916            Some("stream ended before Final")
1917        );
1918    }
1919
1920    #[test]
1921    fn dry_config_check_validates_real_suite_and_agent_without_authorization() {
1922        let dir = std::env::temp_dir().join(format!(
1923            "ai_agents_eval_dry_config_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: Dry Config
1932agent: agent.yaml
1933fixtures:
1934  llm:
1935    mode: real
1936scenarios:
1937  - id: live
1938    turns:
1939      - input: hello
1940"#,
1941        )
1942        .unwrap();
1943        std::fs::write(
1944            dir.join("agent.yaml"),
1945            "name: TestAgent\nsystem_prompt: test\n",
1946        )
1947        .unwrap();
1948
1949        EvalRunner::validate_file(&suite_path, None).unwrap();
1950
1951        std::fs::write(
1952            dir.join("agent.yaml"),
1953            "name: TestAgent\nsystem_prompt: test\nmax_iteratons: 3\n",
1954        )
1955        .unwrap();
1956        let error = EvalRunner::validate_file(&suite_path, None).unwrap_err();
1957        assert!(error.to_string().contains("max_iteratons"));
1958        let _ = std::fs::remove_dir_all(dir);
1959    }
1960
1961    #[test]
1962    fn real_and_record_modes_require_explicit_authorization() {
1963        let dir = std::env::temp_dir().join(format!(
1964            "ai_agents_eval_authorization_test_{}",
1965            uuid::Uuid::new_v4()
1966        ));
1967        std::fs::create_dir_all(&dir).unwrap();
1968        let suite_path = dir.join("suite.yaml");
1969        std::fs::write(
1970            &suite_path,
1971            r#"
1972name: Authorization
1973agent: agent.yaml
1974fixtures:
1975  llm:
1976    mode: real
1977scenarios:
1978  - id: authorized
1979    turns:
1980      - input: hello
1981"#,
1982        )
1983        .unwrap();
1984
1985        let error = EvalRunner::from_file(&suite_path, EvalRunnerOptions::default())
1986            .err()
1987            .expect("real mode should require authorization");
1988        assert!(error.to_string().contains("--real-llm"));
1989        assert!(
1990            EvalRunner::from_file(
1991                &suite_path,
1992                EvalRunnerOptions {
1993                    llm_mode: Some(LlmFixtureMode::Real),
1994                    ..Default::default()
1995                },
1996            )
1997            .is_ok()
1998        );
1999
2000        let record_suite = std::fs::read_to_string(&suite_path)
2001            .unwrap()
2002            .replace("mode: real", "mode: record");
2003        std::fs::write(&suite_path, record_suite).unwrap();
2004        let error = EvalRunner::from_file(&suite_path, EvalRunnerOptions::default())
2005            .err()
2006            .expect("record mode should require authorization");
2007        assert!(error.to_string().contains("--record"));
2008        assert!(
2009            EvalRunner::from_file(
2010                &suite_path,
2011                EvalRunnerOptions {
2012                    llm_mode: Some(LlmFixtureMode::Record),
2013                    ..Default::default()
2014                },
2015            )
2016            .is_ok()
2017        );
2018        let _ = std::fs::remove_dir_all(dir);
2019    }
2020
2021    #[tokio::test]
2022    async fn zero_selected_scenarios_is_an_error() {
2023        let dir = std::env::temp_dir().join(format!(
2024            "ai_agents_eval_zero_selection_test_{}",
2025            uuid::Uuid::new_v4()
2026        ));
2027        std::fs::create_dir_all(&dir).unwrap();
2028        let suite_path = dir.join("suite.yaml");
2029        std::fs::write(
2030            &suite_path,
2031            r#"
2032name: Selection
2033agent: agent.yaml
2034fixtures:
2035  llm:
2036    mode: mock
2037    responses: [ok]
2038scenarios:
2039  - id: present
2040    turns:
2041      - input: hello
2042"#,
2043        )
2044        .unwrap();
2045        let runner = EvalRunner::from_file(
2046            &suite_path,
2047            EvalRunnerOptions {
2048                ids: vec!["missing".to_string()],
2049                ..Default::default()
2050            },
2051        )
2052        .unwrap();
2053
2054        let error = runner.run().await.unwrap_err();
2055
2056        assert!(error.to_string().contains("matched zero scenarios"));
2057        let _ = std::fs::remove_dir_all(dir);
2058    }
2059
2060    #[test]
2061    fn env_guard_allows_readers_and_excludes_writer() {
2062        use std::sync::{Barrier, mpsc};
2063        use std::thread;
2064
2065        let start = Arc::new(Barrier::new(3));
2066        let (acquired_tx, acquired_rx) = mpsc::channel();
2067        let mut releases = Vec::new();
2068        let mut readers = Vec::new();
2069        for index in 0..2 {
2070            let start = Arc::clone(&start);
2071            let acquired_tx = acquired_tx.clone();
2072            let (release_tx, release_rx) = mpsc::channel();
2073            releases.push(release_tx);
2074            readers.push(thread::spawn(move || {
2075                start.wait();
2076                let guard = EnvGuard::apply(&HashMap::new()).unwrap();
2077                acquired_tx.send(index).unwrap();
2078                release_rx.recv().unwrap();
2079                drop(guard);
2080            }));
2081        }
2082        start.wait();
2083        let first = acquired_rx.recv_timeout(Duration::from_secs(1));
2084        let second = acquired_rx.recv_timeout(Duration::from_secs(1));
2085        for release in releases {
2086            release.send(()).unwrap();
2087        }
2088        for reader in readers {
2089            reader.join().unwrap();
2090        }
2091        assert_ne!(first.unwrap(), second.unwrap());
2092
2093        let key = format!("AI_AGENTS_EVAL_ENV_TEST_{}", uuid::Uuid::new_v4());
2094        unsafe {
2095            std::env::remove_var(&key);
2096        }
2097        let reader = EnvGuard::apply(&HashMap::new()).unwrap();
2098        assert!(matches!(&reader._guard, EnvExclusionGuard::Read { .. }));
2099        let writer_start = Arc::new(Barrier::new(2));
2100        let writer_start_thread = Arc::clone(&writer_start);
2101        let (writer_tx, writer_rx) = mpsc::channel();
2102        let writer_key = key.clone();
2103        let writer = thread::spawn(move || {
2104            writer_start_thread.wait();
2105            let guard = EnvGuard::apply(&HashMap::from([(
2106                writer_key.clone(),
2107                "temporary".to_string(),
2108            )]))
2109            .unwrap();
2110            writer_tx.send(()).unwrap();
2111            assert_eq!(std::env::var(&writer_key).as_deref(), Ok("temporary"));
2112            drop(guard);
2113        });
2114        writer_start.wait();
2115        let writer_was_blocked = writer_rx.recv_timeout(Duration::from_millis(100)).is_err();
2116        drop(reader);
2117        if writer_was_blocked {
2118            writer_rx.recv_timeout(Duration::from_secs(1)).unwrap();
2119        }
2120        writer.join().unwrap();
2121        assert!(writer_was_blocked);
2122        assert!(std::env::var(&key).is_err());
2123    }
2124
2125    fn write_test_agent(dir: &Path) {
2126        std::fs::write(
2127            dir.join("agent.yaml"),
2128            r#"
2129name: TestAgent
2130system_prompt: "You are helpful."
2131llm:
2132  provider: openai
2133  model: gpt-4.1-nano
2134"#,
2135        )
2136        .unwrap();
2137    }
2138
2139    async fn run_test_suite(dir: &Path, name: &str, yaml: &str) -> EvalResult {
2140        let suite_path = dir.join(name);
2141        std::fs::write(&suite_path, yaml).unwrap();
2142        let options = EvalRunnerOptions {
2143            output: dir.join("out"),
2144            ..Default::default()
2145        };
2146        EvalRunner::from_file(&suite_path, options)
2147            .unwrap()
2148            .run()
2149            .await
2150            .unwrap()
2151    }
2152
2153    #[test]
2154    fn runtime_error_expectations_retain_turns_and_control_retries() {
2155        std::thread::Builder::new()
2156            .name("eval-runtime-error-test".to_string())
2157            .stack_size(16 * 1024 * 1024)
2158            .spawn(|| {
2159                let runtime = tokio::runtime::Runtime::new().unwrap();
2160                runtime.block_on(async {
2161                    let dir = std::env::temp_dir().join(format!(
2162                        "ai_agents_eval_runtime_error_test_{}",
2163                        uuid::Uuid::new_v4()
2164                    ));
2165                    std::fs::create_dir_all(&dir).unwrap();
2166                    write_test_agent(&dir);
2167                    let errors = run_test_suite(
2168                        &dir,
2169                        "errors.yaml",
2170                        r#"
2171name: Runtime Errors
2172agent: agent.yaml
2173settings:
2174  retries: 1
2175  retry_delay_ms: 0
2176  redact_outputs: false
2177fixtures:
2178  llm:
2179    mode: mock
2180    errors_by_alias:
2181      default: provider exploded
2182scenarios:
2183  - id: expected
2184    turns:
2185      - input: Hello
2186        expect_error: [timeout, provider exploded]
2187  - id: mismatched
2188    turns:
2189      - input: Hello
2190        expect_error: permission denied
2191  - id: unexpected
2192    turns:
2193      - input: Hello
2194"#,
2195                    )
2196                    .await;
2197
2198                    let expected = &errors.scenarios[0];
2199                    assert!(expected.status.is_passed());
2200                    assert_eq!(expected.attempts.len(), 1);
2201                    let expected_turn = &expected.attempts[0].turns[0];
2202                    assert!(!expected_turn.response_present);
2203                    assert!(expected_turn.runtime_error.is_some());
2204                    assert!(
2205                        expected_turn
2206                            .assertion_results
2207                            .iter()
2208                            .any(|detail| detail.assertion == "expect_error" && detail.passed)
2209                    );
2210
2211                    for scenario in &errors.scenarios[1..] {
2212                        assert!(scenario.status.is_error());
2213                        assert_eq!(scenario.attempts.len(), 2);
2214                        assert!(
2215                            scenario
2216                                .attempts
2217                                .iter()
2218                                .all(|attempt| attempt.turns.len() == 1)
2219                        );
2220                        assert!(
2221                            scenario
2222                                .attempts
2223                                .iter()
2224                                .all(|attempt| attempt.turns[0].runtime_error.is_some())
2225                        );
2226                    }
2227
2228                    let missing = run_test_suite(
2229                        &dir,
2230                        "missing.yaml",
2231                        r#"
2232name: Missing Runtime Error
2233agent: agent.yaml
2234settings:
2235  retry_delay_ms: 0
2236  redact_outputs: false
2237fixtures:
2238  llm:
2239    mode: mock
2240    responses: [ok]
2241scenarios:
2242  - id: missing
2243    turns:
2244      - input: Hello
2245        expect_error: timeout
2246"#,
2247                    )
2248                    .await;
2249                    let missing = &missing.scenarios[0];
2250                    assert!(missing.status.is_failed());
2251                    let turn = &missing.attempts[0].turns[0];
2252                    assert!(turn.response_present);
2253                    assert!(turn.runtime_error.is_none());
2254                    assert!(
2255                        turn.assertion_results
2256                            .iter()
2257                            .any(|detail| detail.assertion == "expect_error" && !detail.passed)
2258                    );
2259                    let _ = std::fs::remove_dir_all(dir);
2260                });
2261            })
2262            .unwrap()
2263            .join()
2264            .unwrap();
2265    }
2266
2267    #[test]
2268    fn scenario_budget_is_shared_across_retries_and_agent_resets() {
2269        std::thread::Builder::new()
2270            .name("eval-budget-lifecycle-test".to_string())
2271            .stack_size(16 * 1024 * 1024)
2272            .spawn(|| {
2273                let runtime = tokio::runtime::Runtime::new().unwrap();
2274                runtime.block_on(async {
2275                    let dir = std::env::temp_dir().join(format!(
2276                        "ai_agents_eval_budget_lifecycle_test_{}",
2277                        uuid::Uuid::new_v4()
2278                    ));
2279                    std::fs::create_dir_all(&dir).unwrap();
2280                    write_test_agent(&dir);
2281
2282                    let retried = run_test_suite(
2283                        &dir,
2284                        "budget-retry.yaml",
2285                        r#"
2286name: Retry Budget
2287agent: agent.yaml
2288settings:
2289  retries: 1
2290  retry_delay_ms: 0
2291  redact_outputs: false
2292fixtures:
2293  llm:
2294    mode: mock
2295    responses: [wrong]
2296scenarios:
2297  - id: retry
2298    budget:
2299      max_llm_calls: 1
2300    turns:
2301      - input: Hello
2302        assert:
2303          response_contains: right
2304"#,
2305                    )
2306                    .await;
2307                    let retried = &retried.scenarios[0];
2308                    assert!(retried.status.is_error());
2309                    assert_eq!(retried.attempts.len(), 2);
2310                    assert!(
2311                        retried.attempts[1].turns[0]
2312                            .runtime_error
2313                            .as_ref()
2314                            .is_some_and(|error| error.value.contains("max_llm_calls=1"))
2315                    );
2316
2317                    let reset = run_test_suite(
2318                        &dir,
2319                        "budget-reset.yaml",
2320                        r#"
2321name: Reset Budget
2322agent: agent.yaml
2323settings:
2324  retries: 0
2325  redact_outputs: false
2326fixtures:
2327  llm:
2328    mode: mock
2329    responses: [ok]
2330scenarios:
2331  - id: reset
2332    budget:
2333      max_llm_calls: 1
2334    steps:
2335      - !run
2336        turns:
2337          - input: First
2338      - !reset_agent true
2339      - !run
2340        turns:
2341          - input: Second
2342"#,
2343                    )
2344                    .await;
2345                    let reset = &reset.scenarios[0];
2346                    assert!(reset.status.is_error());
2347                    assert_eq!(reset.attempts.len(), 1);
2348                    assert_eq!(reset.attempts[0].turns.len(), 2);
2349                    assert!(
2350                        reset.attempts[0].turns[1]
2351                            .runtime_error
2352                            .as_ref()
2353                            .is_some_and(|error| error.value.contains("max_llm_calls=1"))
2354                    );
2355                    let _ = std::fs::remove_dir_all(dir);
2356                });
2357            })
2358            .unwrap()
2359            .join()
2360            .unwrap();
2361    }
2362
2363    #[test]
2364    fn captures_composed_llm_requests_per_turn_across_reset() {
2365        std::thread::Builder::new()
2366            .name("eval-llm-evidence-test".to_string())
2367            .stack_size(16 * 1024 * 1024)
2368            .spawn(|| {
2369                let runtime = tokio::runtime::Runtime::new().unwrap();
2370                runtime.block_on(async {
2371                    let dir = std::env::temp_dir().join(format!(
2372                        "ai_agents_eval_llm_evidence_test_{}",
2373                        uuid::Uuid::new_v4()
2374                    ));
2375                    std::fs::create_dir_all(&dir).unwrap();
2376                    std::fs::write(
2377                        dir.join("agent.yaml"),
2378                        r#"
2379name: EvidenceAgent
2380system_prompt: "Base instruction marker."
2381llm:
2382  provider: openai
2383  model: gpt-4.1-nano
2384persona:
2385  identity:
2386    name: Evidence Guide
2387    role: Prompt Inspector
2388reasoning:
2389  mode: cot
2390  output: tagged
2391"#,
2392                    )
2393                    .unwrap();
2394                    let result = run_test_suite(
2395                        &dir,
2396                        "llm-evidence.yaml",
2397                        r#"
2398name: LLM Evidence
2399agent: agent.yaml
2400settings:
2401  redact_outputs: false
2402fixtures:
2403  llm:
2404    mode: mock
2405    responses: [first answer, second answer]
2406scenarios:
2407  - id: composed
2408    steps:
2409      - !run
2410        turns:
2411          - input: first question
2412            assert:
2413              llm_request:
2414                system_contains:
2415                  - "You are Evidence Guide, Prompt Inspector."
2416                  - "Base instruction marker."
2417                  - "Think through this step by step"
2418                  - "<instruction>"
2419                user_contains: first question
2420                count: 1
2421                same_request: true
2422          - input: second question
2423            assert:
2424              llm_request:
2425                user_contains: [first question, second question]
2426                assistant_contains: first answer
2427                count: 1
2428                same_request: true
2429      - !reset_agent true
2430      - !run
2431        turns:
2432          - input: after reset
2433            assert:
2434              llm_request:
2435                system_contains: "Base instruction marker."
2436                user_contains: after reset
2437                count: 1
2438                same_request: true
2439"#,
2440                    )
2441                    .await;
2442
2443                    assert_eq!(result.passed, 1);
2444                    let turns = &result.scenarios[0].attempts[0].turns;
2445                    assert_eq!(turns.len(), 3);
2446                    assert!(
2447                        turns
2448                            .iter()
2449                            .all(|turn| turn.evidence.llm_requests.len() == 1)
2450                    );
2451                    let second_messages = &turns[1].evidence.llm_requests[0].messages;
2452                    assert!(second_messages.iter().any(|message| {
2453                        message.role == ai_agents_core::Role::Assistant
2454                            && message.content.contains("first answer")
2455                    }));
2456                    let reset_messages = &turns[2].evidence.llm_requests[0].messages;
2457                    assert!(
2458                        reset_messages
2459                            .iter()
2460                            .all(|message| !message.content.contains("first question"))
2461                    );
2462                    assert!(
2463                        reset_messages
2464                            .iter()
2465                            .all(|message| !message.content.contains("first answer"))
2466                    );
2467
2468                    let serialized = serde_json::to_string(&result).unwrap();
2469                    let serialized_value: Value = serde_json::from_str(&serialized).unwrap();
2470                    assert!(
2471                        serialized_value["scenarios"][0]["attempts"][0]["turns"][0]
2472                            .get("evidence")
2473                            .is_none()
2474                    );
2475                    assert!(!serialized.contains("Base instruction marker"));
2476                    assert!(!serialized.contains("Evidence Guide"));
2477                    assert!(!serialized.contains("Think through this step by step"));
2478                    let _ = std::fs::remove_dir_all(dir);
2479                });
2480            })
2481            .unwrap()
2482            .join()
2483            .unwrap();
2484    }
2485
2486    #[test]
2487    fn observability_evidence_is_scoped_to_each_turn() {
2488        std::thread::Builder::new()
2489            .name("eval-turn-observability-test".to_string())
2490            .stack_size(16 * 1024 * 1024)
2491            .spawn(|| {
2492                let runtime = tokio::runtime::Runtime::new().unwrap();
2493                runtime.block_on(async {
2494                    let dir = std::env::temp_dir().join(format!(
2495                        "ai_agents_eval_turn_observability_test_{}",
2496                        uuid::Uuid::new_v4()
2497                    ));
2498                    std::fs::create_dir_all(&dir).unwrap();
2499                    write_test_agent(&dir);
2500                    let result = run_test_suite(
2501                        &dir,
2502                        "turn-observability.yaml",
2503                        r#"
2504name: Turn Observability
2505agent: agent.yaml
2506settings:
2507  redact_outputs: false
2508observability:
2509  enabled: true
2510  aggregation:
2511    dimensions: [purpose]
2512  export:
2513    write_report: false
2514fixtures:
2515  llm:
2516    mode: mock
2517    responses: [first answer, second answer]
2518scenarios:
2519  - id: separate-turns
2520    turns:
2521      - input: First
2522      - input: Second
2523"#,
2524                    )
2525                    .await;
2526
2527                    assert_eq!(result.passed, 1);
2528                    let turns = &result.scenarios[0].attempts[0].turns;
2529                    assert_eq!(turns.len(), 2);
2530                    let first = turns[0].evidence.observability.as_ref().unwrap();
2531                    let second = turns[1].evidence.observability.as_ref().unwrap();
2532                    let first_report = first.report.as_ref().unwrap();
2533                    let second_report = second.report.as_ref().unwrap();
2534                    assert!(first_report.summary.total_events > 0);
2535                    assert_eq!(
2536                        second_report.summary.total_events,
2537                        first_report.summary.total_events
2538                    );
2539                    assert_eq!(
2540                        first_report.summary.total_events,
2541                        first.span_ids.len() as u64
2542                    );
2543                    assert_eq!(
2544                        second_report.summary.total_events,
2545                        second.span_ids.len() as u64
2546                    );
2547                    assert_ne!(first.trace_id, second.trace_id);
2548                    let first_spans: HashSet<_> = first.span_ids.iter().collect();
2549                    assert!(
2550                        second
2551                            .span_ids
2552                            .iter()
2553                            .all(|span_id| !first_spans.contains(span_id))
2554                    );
2555                    let _ = std::fs::remove_dir_all(dir);
2556                });
2557            })
2558            .unwrap()
2559            .join()
2560            .unwrap();
2561    }
2562
2563    #[test]
2564    fn runner_executes_mocked_suite_and_redacts_outputs() {
2565        std::thread::Builder::new()
2566            .name("eval-runner-test".to_string())
2567            .stack_size(16 * 1024 * 1024)
2568            .spawn(|| {
2569                let runtime = tokio::runtime::Runtime::new().unwrap();
2570                runtime.block_on(async {
2571                    let dir = std::env::temp_dir().join(format!(
2572                        "ai_agents_eval_runner_test_{}",
2573                        uuid::Uuid::new_v4()
2574                    ));
2575                    std::fs::create_dir_all(&dir).unwrap();
2576                    write_test_agent(&dir);
2577                    let suite_path = dir.join("suite.yaml");
2578                    std::fs::write(
2579                        &suite_path,
2580                        r#"
2581name: Runner Suite
2582agent: agent.yaml
2583fixtures:
2584  llm:
2585    mode: mock
2586    responses:
2587      - "Hello from mock"
2588scenarios:
2589  - id: smoke
2590    turns:
2591      - input: Hello
2592        assert:
2593          response_contains: "Hello"
2594"#,
2595                    )
2596                    .unwrap();
2597                    let options = EvalRunnerOptions {
2598                        output: dir.join("out"),
2599                        ..Default::default()
2600                    };
2601                    let runner = EvalRunner::from_file(&suite_path, options).unwrap();
2602                    let result = runner.run().await.unwrap();
2603                    assert_eq!(result.passed, 1);
2604                    let turn = &result.scenarios[0].attempts[0].turns[0];
2605                    assert_eq!(turn.input.value, "[redacted]");
2606                    assert_eq!(turn.response.value, "[redacted]");
2607                    let json = serde_json::to_string(&result).unwrap();
2608                    assert!(!json.contains("Hello from mock"));
2609                    let _ = std::fs::remove_dir_all(dir);
2610                });
2611            })
2612            .unwrap()
2613            .join()
2614            .unwrap();
2615    }
2616}