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