use std::path::PathBuf;
use std::time::Instant;
use crate::case::EvalCase;
use crate::expect::Expectation;
use crate::harness::{Agent, Harness, RunArtifacts, ToolCall};
use crate::persist::{self, Persist};
use crate::report::{CaseOutcome, EvalReport};
use crate::scorer::{BuiltinScorer, Scorer};
use crate::upload::{self, Upload};
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct RunMeta {
pub temperature: f32,
pub backend: String,
pub system_prompt: String,
pub persist: Option<Persist>,
pub upload: Option<Upload>,
}
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(),
persist: None,
upload: None,
}
}
pub fn persist_to(mut self, results_dir: impl Into<PathBuf>, model: impl Into<String>) -> Self {
self.persist = Some(Persist::new(results_dir.into(), model.into()));
self
}
pub fn backend_kind(mut self, kind: impl Into<String>) -> Self {
if let Some(p) = self.persist.as_mut() {
p.backend = kind.into();
}
self
}
pub fn cases_dir(mut self, dir: impl Into<String>) -> Self {
if let Some(p) = self.persist.as_mut() {
p.cases_dir = dir.into();
}
self
}
pub fn upload_to(mut self, project_id: impl Into<String>, api_key: impl Into<String>) -> Self {
self.upload = Some(Upload::new(project_id.into(), api_key.into()));
self
}
pub fn upload_from_env(mut self, project_id: impl Into<String>) -> Self {
match std::env::var("EVALFORGE_API_KEY") {
Ok(key) if !key.trim().is_empty() => {
self.upload = Some(Upload::new(project_id.into(), key));
}
_ => {
tracing::warn!("EVALFORGE_API_KEY not set (or empty); upload disabled");
eprintln!("warning: EVALFORGE_API_KEY not set (or empty); upload disabled");
}
}
self
}
pub fn upload_model(mut self, model: impl Into<String>) -> Self {
if let Some(u) = self.upload.as_mut() {
u.model = model.into();
}
self
}
pub fn upload_cases_dir(mut self, dir: impl Into<String>) -> Self {
if let Some(u) = self.upload.as_mut() {
u.cases_dir = dir.into();
}
self
}
}
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);
let report = EvalReport::new(outcomes, meta.temperature, meta.backend, meta.system_prompt);
let do_persist = meta.persist.is_some();
let do_upload = meta.upload.is_some();
if do_persist || do_upload {
let model;
let backend_kind;
let cases_dir;
if let Some(p) = &meta.persist {
model = p.model.clone();
backend_kind = p.backend.clone();
cases_dir = p.cases_dir.clone();
} else {
let u = meta
.upload
.as_ref()
.expect("do_persist || do_upload with no persist implies upload is Some");
model = u.model.clone();
backend_kind = u.backend.clone();
cases_dir = u.cases_dir.clone();
}
let record = persist::build_record(model, backend_kind, cases_dir, &report);
if let Some(persist) = &meta.persist {
match persist::write_record_and_report(&persist.results_dir, &record) {
Ok(path) => eprintln!("saved run + report: {}", path.display()),
Err(e) => {
tracing::warn!("auto-persist failed: {e}");
eprintln!("warning: failed to persist run / generate report: {e}");
}
}
}
if let Some(upload) = &meta.upload {
match upload::upload_record(upload, &record) {
Ok(r) => eprintln!(
"uploaded run to evalforge: id={} deduped={}",
r.run_id, r.deduped
),
Err(e) => {
tracing::warn!("upload failed: {e}");
eprintln!("warning: failed to upload run to evalforge: {e}");
}
}
}
}
report
}
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 persist_to_writes_run_json_and_report_html() {
let dir = tempfile::tempdir().expect("tempdir");
let cases = vec![case("pass", "pass")];
let meta = RunMeta::new(0.0, "local: m", "sys")
.persist_to(dir.path(), "my-model")
.backend_kind("local")
.cases_dir("eval/cases");
let _ = run_eval_with_meta(&H, &Sc, &cases, meta);
let names: Vec<String> = std::fs::read_dir(dir.path())
.expect("read results dir")
.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
assert!(
names
.iter()
.any(|n| n.starts_with("my-model_") && n.ends_with(".json")),
"per-run JSON written with slugged model name; got {names:?}"
);
assert!(
names.iter().any(|n| n == "report.html"),
"report.html regenerated; got {names:?}"
);
}
#[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"))
);
}
}