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
39struct 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#[derive(Debug, Clone, Default)]
130pub struct EvalRunnerOptions {
131 pub agent: Option<PathBuf>,
133 pub scenarios: Option<PathBuf>,
135 pub output: PathBuf,
137 pub ids: Vec<String>,
139 pub tags: Vec<String>,
141 pub tag_mode_all: bool,
143 pub languages: Vec<String>,
145 pub retries: Option<u32>,
147 pub timeout_ms: Option<u64>,
149 pub parallel: Option<usize>,
151 pub fail_fast: bool,
153 pub observability: bool,
155 pub llm_mode: Option<LlmFixtureMode>,
157 pub cassette: Option<PathBuf>,
159}
160
161pub struct EvalRunner {
163 suite_path: PathBuf,
165 suite: EvalSuite,
167 options: EvalRunnerOptions,
169}
170
171struct 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
183struct 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
1119async 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
1433enum EnvExclusionGuard {
1435 Read {
1436 _guard: RwLockReadGuard<'static, ()>,
1437 },
1438 Write {
1439 _guard: RwLockWriteGuard<'static, ()>,
1440 },
1441}
1442
1443struct EnvGuard {
1445 previous: Vec<(String, Option<String>)>,
1447 _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}