use std::time::Instant;
use crate::case::EvalCase;
use crate::expect::Expectation;
use crate::harness::{Agent, Harness, RunArtifacts, ToolCall};
use crate::report::{CaseOutcome, EvalReport};
use crate::scorer::{BuiltinScorer, Scorer};
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct RunMeta {
pub temperature: f32,
pub backend: String,
pub system_prompt: String,
}
impl RunMeta {
pub fn new(
temperature: f32,
backend: impl Into<String>,
system_prompt: impl Into<String>,
) -> Self {
Self {
temperature,
backend: backend.into(),
system_prompt: system_prompt.into(),
}
}
}
pub fn run_eval<H, S>(
harness: &H,
scorer: &S,
cases: &[EvalCase<H::Setup, S::Expect>],
) -> EvalReport
where
H: Harness,
S: Scorer<World = H::World>,
{
run_eval_with_meta(harness, scorer, cases, RunMeta::default())
}
pub fn run_eval_with_meta<H, S>(
harness: &H,
scorer: &S,
cases: &[EvalCase<H::Setup, S::Expect>],
meta: RunMeta,
) -> EvalReport
where
H: Harness,
S: Scorer<World = H::World>,
{
let total = cases.len();
eprintln!("── eval run ──────────────────────────────────────────");
if !meta.backend.is_empty() {
eprintln!("backend: {}", meta.backend);
}
eprintln!("cases: {total}");
eprintln!("temperature: {}", meta.temperature);
eprintln!("──────────────────────────────────────────────────────");
let prev_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let outcomes: Vec<CaseOutcome> = cases
.iter()
.enumerate()
.map(|(idx, case)| {
let i = idx + 1;
eprintln!("[{i}/{total}] {} …", case.name);
let outcome = run_case(harness, scorer, case);
let status = if outcome.passed { "PASS" } else { "FAIL" };
let tokens = outcome
.tokens
.map_or_else(|| "?".to_owned(), |t| t.to_string());
let detail = match &outcome.error {
Some(err) => format!(", {}", truncate_one_line(err)),
None => String::new(),
};
eprintln!(
"[{i}/{total}] {} … {status} ({:.1}s, {tokens} tok{detail})",
case.name,
outcome.latency.as_secs_f64()
);
outcome
})
.collect();
std::panic::set_hook(prev_hook);
EvalReport::new(outcomes, meta.temperature, meta.backend, meta.system_prompt)
}
fn run_case<H, S>(harness: &H, scorer: &S, case: &EvalCase<H::Setup, S::Expect>) -> CaseOutcome
where
H: Harness,
S: Scorer<World = H::World>,
{
let started = Instant::now();
let scored = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let mut world = harness.setup(&case.setup);
let run = harness.run(&case.instruction, &mut world);
let latency = started.elapsed();
let (artifacts, run_error) = match run {
Ok(mut artifacts) => {
let err = artifacts.error.take();
(artifacts, err)
}
Err(e) => (RunArtifacts::default(), Some(e.to_string())),
};
let predicates: Vec<(String, bool)> = case
.expect
.iter()
.map(|exp| scorer.score(exp, &artifacts, &world))
.collect();
(artifacts, run_error, latency, predicates)
}));
match scored {
Ok((artifacts, run_error, latency, predicates)) => {
let passed = run_error.is_none() && predicates.iter().all(|(_, p)| *p);
let tool_calls: Vec<String> =
artifacts.tool_calls.iter().map(ToolCall::display).collect();
CaseOutcome::new(
case.name.clone(),
passed,
predicates,
latency,
artifacts.tokens,
tool_calls,
artifacts.final_text,
run_error,
artifacts.transcript,
)
}
Err(payload) => {
let msg = payload
.downcast_ref::<&str>()
.map(|s| (*s).to_owned())
.or_else(|| payload.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "unknown panic".to_owned());
let predicates = (0..case.expect.len())
.map(|i| (format!("predicate #{i}"), false))
.collect();
CaseOutcome::new(
case.name.clone(),
false,
predicates,
started.elapsed(),
None,
Vec::new(),
None,
Some(format!("panic: {msg}")),
Vec::new(),
)
}
}
}
#[derive(Debug)]
pub struct AgentHarness<'a, A: Agent> {
agent: &'a A,
}
impl<'a, A: Agent> AgentHarness<'a, A> {
pub fn new(agent: &'a A) -> Self {
Self { agent }
}
}
impl<A: Agent> Harness for AgentHarness<'_, A> {
type World = ();
type Setup = ();
fn setup(&self, _setup: &()) {}
fn run(&self, instruction: &str, _world: &mut ()) -> anyhow::Result<RunArtifacts> {
self.agent
.run(instruction)
.map_err(|e| anyhow::anyhow!(e.to_string()))
}
}
pub fn run_suite(agent: &impl Agent, cases: &[EvalCase<(), Expectation>]) -> EvalReport {
run_suite_with_meta(agent, cases, RunMeta::default())
}
pub fn run_suite_with_meta(
agent: &impl Agent,
cases: &[EvalCase<(), Expectation>],
meta: RunMeta,
) -> EvalReport {
let harness = AgentHarness::new(agent);
run_eval_with_meta(&harness, &BuiltinScorer, cases, meta)
}
fn truncate_one_line(msg: &str) -> String {
const MAX: usize = 120;
let first_line = msg.lines().next().unwrap_or("");
if first_line.chars().count() <= MAX {
first_line.to_owned()
} else {
let truncated: String = first_line.chars().take(MAX).collect();
format!("{truncated}…")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Default)]
struct W {
ok: bool,
}
#[derive(Clone, Copy, Default)]
struct How;
struct H;
impl Harness for H {
type World = W;
type Setup = How;
fn setup(&self, _setup: &How) -> W {
W::default()
}
fn run(&self, instruction: &str, world: &mut W) -> anyhow::Result<RunArtifacts> {
match instruction {
"pass" => {
world.ok = true;
Ok(RunArtifacts {
tokens: Some(7),
..RunArtifacts::default()
})
}
"soft" => {
world.ok = true; Ok(RunArtifacts {
error: Some("soft boom".to_owned()),
..RunArtifacts::default()
})
}
"hard" => anyhow::bail!("hard boom"),
"panic" => panic!("kaboom"),
other => panic!("unexpected instruction {other}"),
}
}
}
struct Sc;
impl Scorer for Sc {
type World = W;
type Expect = ();
fn score(&self, _expect: &(), _artifacts: &RunArtifacts, world: &W) -> (String, bool) {
("world.ok".to_owned(), world.ok)
}
}
fn case(name: &str, instruction: &str) -> EvalCase<How, ()> {
EvalCase {
name: name.to_owned(),
instruction: instruction.to_owned(),
setup: How,
expect: vec![()],
}
}
#[test]
fn pass_soft_hard_and_panic_are_isolated() {
let cases = vec![
case("pass", "pass"),
case("soft", "soft"),
case("hard", "hard"),
case("panic", "panic"),
];
let report = run_eval(&H, &Sc, &cases);
assert_eq!(report.total(), 4);
assert_eq!(report.passed(), 1, "only the clean run passes");
let pass = &report.outcomes[0];
assert!(pass.passed);
assert_eq!(pass.tokens, Some(7));
assert!(pass.error.is_none());
assert_eq!(pass.predicates, vec![("world.ok".to_owned(), true)]);
let soft = &report.outcomes[1];
assert!(!soft.passed);
assert_eq!(soft.error.as_deref(), Some("soft boom"));
assert!(soft.predicates[0].1, "predicate itself held");
let hard = &report.outcomes[2];
assert!(!hard.passed);
assert_eq!(hard.error.as_deref(), Some("hard boom"));
assert!(!hard.predicates[0].1);
let panicked = &report.outcomes[3];
assert!(!panicked.passed);
assert!(
panicked
.error
.as_deref()
.is_some_and(|e| e.contains("kaboom")),
"panic message captured: {:?}",
panicked.error
);
assert_eq!(panicked.predicates.len(), 1);
assert!(!panicked.predicates[0].1);
}
#[test]
fn run_suite_scores_builtin_expectations_over_agent_artifacts() {
use crate::expect::Expectation;
use crate::harness::ToolCall;
use serde_json::json;
struct FakeAgent;
impl Agent for FakeAgent {
fn run(&self, instruction: &str) -> Result<RunArtifacts, crate::error::EvalError> {
if instruction == "boom" {
return Err(crate::error::EvalError::agent("backend down"));
}
if instruction.contains("add") {
Ok(RunArtifacts {
tool_calls: vec![ToolCall::new(
"calculator",
json!({"op": "add", "a": 2, "b": 2}),
)],
final_text: Some("The answer is 4".to_owned()),
..RunArtifacts::default()
})
} else {
Ok(RunArtifacts {
final_text: Some(instruction.to_owned()),
..RunArtifacts::default()
})
}
}
}
fn case(
name: &str,
instruction: &str,
expect: Vec<Expectation>,
) -> EvalCase<(), Expectation> {
EvalCase {
name: name.to_owned(),
instruction: instruction.to_owned(),
setup: (),
expect,
}
}
let cases = vec![
case(
"adds",
"please add 2 and 2",
vec![
Expectation::CalledToolWith {
tool: "calculator".into(),
args: json!({"op": "add"}),
},
Expectation::FinalNumberEquals {
value: 4.0,
tolerance: 0.0,
},
],
),
case(
"no-tools",
"hello there",
vec![
Expectation::NoToolCalls,
Expectation::FinalTextContains {
text: "hello".into(),
case_insensitive: false,
},
],
),
case("fails-run", "boom", vec![Expectation::NoError]),
];
let report = run_suite(&FakeAgent, &cases);
assert_eq!(report.total(), 3);
assert_eq!(
report.passed(),
2,
"the two well-behaved cases pass; the run-error case fails"
);
let adds = &report.outcomes[0];
assert!(adds.passed);
assert_eq!(adds.tool_calls.len(), 1);
assert!(
adds.tool_calls[0].starts_with("calculator("),
"display string derived from the structured ToolCall: {:?}",
adds.tool_calls
);
let boom = &report.outcomes[2];
assert!(!boom.passed);
assert!(
boom.error
.as_deref()
.is_some_and(|e| e.contains("backend down"))
);
}
}