1mod live_verify;
34
35use std::collections::{BTreeMap, BTreeSet};
36use std::ffi::OsString;
37use std::fs;
38use std::io::Write as _;
39use std::path::{Path, PathBuf};
40
41use harn_vm::clock::{Clock, RealClock};
42use harn_vm::orchestration::EvalPackCase;
43use serde::Serialize;
44use serde_json::Value as JsonValue;
45
46use crate::cli::EvalCodingAgentArgs;
47use crate::commands::eval_coding_agent_preset::{
48 resolve_step_judge_json, resolve_structural_validator_json,
49};
50use crate::commands::eval_model_selector::{
51 resolve_selector, selector_is_local, selector_label, ModelSelector,
52};
53use crate::commands::local::runtime::{local_provider_ids, snapshot_provider};
54use crate::commands::local_readiness;
55use crate::commands::tool_mode_parity::{
56 self, ToolModeParityFixtureInput, ToolModeParityPairSummary, TOOL_MODE_PARITY_DIRECTORY,
57 TOOL_MODE_PARITY_FIXTURE_SUITE, TOOL_MODE_PARITY_OVERLAY_FILENAME,
58};
59use crate::dispatch;
60use crate::env_guard::ScopedEnvVar;
61#[cfg(test)]
62use live_verify::{coding_agent_live_verify_cases, tool_format_override_warning_line};
63use live_verify::{
64 fixture_description, fixture_id, fixture_name, fixture_tool_sequence, resolve_fixtures,
65 run_matrix_entry,
66};
67
68const CODING_AGENT_SUMMARY_ENV: &str = "HARN_EVAL_CODING_AGENT_SUMMARY_JSON";
73const CODING_AGENT_EVAL_PACK_ID: &str = "coding-agent";
74
75const CODING_AGENT_MODE_ENV: &str = "HARN_EVAL_CODING_AGENT_MODE";
81const TOOL_FORMAT_OVERRIDE_WARNING_PREFIX: &str = "warning: tool_format override:";
82
83static DISPATCH_RENDER_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
94
95const CODING_AGENT_SUITE_HARN: &str = include_str!("../../assets/evals/coding_agent_suite.harn");
96#[derive(Debug, Clone, Serialize)]
97struct LoadedEnvKey {
98 key: String,
99 source: String,
100}
101
102#[derive(Debug)]
103struct EnvOverlay {
104 previous: Vec<(OsString, Option<OsString>)>,
105}
106
107impl Drop for EnvOverlay {
108 fn drop(&mut self) {
109 for (key, previous) in self.previous.iter().rev() {
110 if let Some(value) = previous {
111 std::env::set_var(key, value);
112 } else {
113 std::env::remove_var(key);
114 }
115 }
116 }
117}
118
119#[derive(Debug, Clone, Serialize)]
120struct RunReport {
121 run_id: String,
122 fixture_id: String,
123 fixture_name: String,
124 fixture_tool_sequence: String,
125 selector: ModelSelector,
126 tool_format: String,
127 status: String,
128 passed: bool,
129 skipped: bool,
130 #[serde(skip_serializing_if = "Option::is_none")]
131 skipped_reason: Option<String>,
132 output_dir: String,
133 transcript_events_path: String,
134 workspace_root: Option<String>,
135 elapsed_ms: u64,
136 duration_ms: u64,
137 iterations: i64,
138 input_tokens: i64,
139 output_tokens: i64,
140 cost_usd: f64,
141 pricing_known: bool,
142 tool_calls: usize,
143 rejected_tool_calls: usize,
144 tool_sequence: Vec<String>,
145 successful_tools: Vec<String>,
146 transcript_event_count: usize,
147 verification_success: bool,
148 harn_exit_code: i32,
149 #[serde(skip_serializing_if = "Option::is_none")]
150 error: Option<String>,
151 #[serde(skip_serializing_if = "Option::is_none")]
152 stderr_excerpt: Option<String>,
153 local_cleanup: Option<LocalCleanupReport>,
154}
155
156#[derive(Debug, Clone, Serialize)]
157struct LocalCleanupReport {
158 provider: String,
159 model: String,
160 initially_loaded: bool,
161 action: String,
162 #[serde(skip_serializing_if = "Option::is_none")]
163 detail: Option<String>,
164}
165
166#[derive(Debug, Clone, Serialize)]
167struct FormatComparison {
168 fixture_id: String,
169 selector: ModelSelector,
170 native_run_id: Option<String>,
171 text_run_id: Option<String>,
172 native_evidence_path: Option<String>,
173 text_evidence_path: Option<String>,
174 native_status: Option<String>,
175 text_status: Option<String>,
176 native_passed: Option<bool>,
177 text_passed: Option<bool>,
178 native_tool_call_count: Option<usize>,
179 text_tool_call_count: Option<usize>,
180 native_rejected_tool_call_count: Option<usize>,
181 text_rejected_tool_call_count: Option<usize>,
182 verifier_match: Option<bool>,
183 tool_sequence_match: Option<bool>,
184 rejected_tool_call_delta_text_minus_native: Option<i64>,
185 token_delta_text_minus_native: Option<i64>,
186 iteration_delta_text_minus_native: Option<i64>,
187 equivalent: Option<bool>,
188 divergence_reasons: Vec<String>,
189 evidence_paths: Vec<String>,
190}
191
192#[derive(Debug, Clone, Serialize)]
193struct FollowupSuggestion {
194 title: String,
195 body: String,
196 labels: Vec<String>,
197 run_ids: Vec<String>,
198}
199
200#[derive(Debug, Clone, Serialize)]
201struct FixtureReport {
202 id: String,
203 name: String,
204 tool_sequence: String,
205 description: String,
206}
207
208#[derive(Debug, Clone, Serialize)]
209struct RollupReport {
210 key: String,
211 total_runs: usize,
212 passed_runs: usize,
213 failed_runs: usize,
214 skipped_runs: usize,
215 total_cost_usd: f64,
216}
217
218#[derive(Debug, Clone, Serialize)]
219struct EvalRollups {
220 by_fixture: Vec<RollupReport>,
221 by_provider: Vec<RollupReport>,
222 by_model: Vec<RollupReport>,
223 by_tool_format: Vec<RollupReport>,
224 by_tool_sequence: Vec<RollupReport>,
225}
226
227#[derive(Debug, Clone, Serialize)]
228struct EvalSummary {
229 schema_version: u32,
230 fixture_ids: Vec<String>,
231 fixtures: Vec<FixtureReport>,
232 output_dir: String,
233 models: Vec<ModelSelector>,
234 tool_formats: Vec<String>,
235 env_keys_loaded: Vec<LoadedEnvKey>,
236 total_runs: usize,
237 passed_runs: usize,
238 failed_runs: usize,
239 skipped_runs: usize,
240 diverged_comparisons: usize,
241 total_cost_usd: f64,
242 rollups: EvalRollups,
243 runs: Vec<RunReport>,
244 comparisons: Vec<FormatComparison>,
245 parity_by_pair: Vec<ToolModeParityPairSummary>,
246 followups: Vec<FollowupSuggestion>,
247 #[serde(skip_serializing_if = "Option::is_none")]
251 step_judge_preset: Option<String>,
252 #[serde(skip_serializing_if = "String::is_empty")]
255 run_label: String,
256 #[serde(skip_serializing_if = "Option::is_none")]
262 baseline_comparison: Option<BaselineComparison>,
263}
264
265#[derive(Debug, Clone, Serialize, Default)]
266struct BaselineComparison {
267 baseline_label: String,
269 baseline_path: String,
271 regressions: Vec<FixtureStatusDelta>,
272 recoveries: Vec<FixtureStatusDelta>,
273 unchanged_passes: Vec<String>,
275 unchanged_failures: Vec<String>,
277 missing_in_baseline: Vec<String>,
280 missing_in_cell: Vec<String>,
281 regressions_count: usize,
282 recoveries_count: usize,
283 net_lift_pp: f64,
287}
288
289#[derive(Debug, Clone, Serialize)]
290struct FixtureStatusDelta {
291 fixture_id: String,
292 baseline_status: String,
293 cell_status: String,
294}
295
296pub async fn run(args: EvalCodingAgentArgs) -> i32 {
297 let output_dir = args.output.clone().unwrap_or_else(default_output_dir);
298 if let Err(error) = fs::create_dir_all(&output_dir) {
299 eprintln!("error: failed to create {}: {error}", output_dir.display());
300 return 1;
301 }
302
303 let (_env_guard, env_keys_loaded) = match load_env_files(&args.env_files) {
304 Ok(loaded) => loaded,
305 Err(error) => {
306 eprintln!("error: {error}");
307 return 1;
308 }
309 };
310
311 let fixtures = match resolve_fixtures(&args.fixtures, &args.python) {
312 Ok(fixtures) => fixtures,
313 Err(error) => {
314 eprintln!("error: {error}");
315 return 2;
316 }
317 };
318 let models = match resolve_models(&args).await {
319 Ok(models) => models,
320 Err(error) => {
321 eprintln!("error: {error}");
322 return 1;
323 }
324 };
325 let tool_formats = match normalize_tool_formats(&args.tool_formats) {
326 Ok(formats) => formats,
327 Err(error) => {
328 eprintln!("error: {error}");
329 return 2;
330 }
331 };
332 let matrix = build_matrix(
333 &fixtures,
334 &models,
335 &tool_formats,
336 args.max_runs,
337 args.replicates,
338 );
339 if matrix.is_empty() {
340 eprintln!("error: no coding-agent benchmark runs selected");
341 return 2;
342 }
343
344 let mut reports = Vec::new();
345 let mut had_error = false;
346 for (fixture, selector, tool_format, replicate) in matrix {
347 let report = run_matrix_entry(
348 &args,
349 &output_dir,
350 fixture,
351 selector,
352 tool_format,
353 replicate,
354 )
355 .await;
356 if !report.passed && !report.skipped {
357 had_error = true;
358 }
359 if report.skipped && args.fail_on_unauthorized {
360 had_error = true;
361 }
362 let detail = report
363 .error
364 .as_deref()
365 .or(report.stderr_excerpt.as_deref())
366 .filter(|detail| !detail.trim().is_empty());
367 match detail {
368 Some(detail) => eprintln!(
369 "{} {} {}: {} ({detail})",
370 report.fixture_id,
371 selector_label(&report.selector),
372 report.tool_format,
373 report.status
374 ),
375 None => eprintln!(
376 "{} {} {}: {}",
377 report.fixture_id,
378 selector_label(&report.selector),
379 report.tool_format,
380 report.status
381 ),
382 }
383 reports.push(report);
384 }
385
386 let baseline_comparison = match &args.baseline_comparison_against {
387 Some(path) => match load_baseline_comparison(path, &reports) {
388 Ok(comparison) => Some(comparison),
389 Err(error) => {
390 eprintln!("error: --baseline-comparison-against: {error}");
391 return 1;
392 }
393 },
394 None => None,
395 };
396 let summary = build_summary(
397 &output_dir,
398 fixtures,
399 models,
400 tool_formats,
401 env_keys_loaded,
402 reports,
403 args.step_judge
404 .clone()
405 .filter(|s| !s.is_empty() && s != "none"),
406 args.run_label.clone(),
407 baseline_comparison,
408 );
409 if let Err(error) = write_json_artifacts(&output_dir, &summary) {
415 eprintln!("error: failed to write benchmark outputs: {error}");
416 return 1;
417 }
418
419 if let Err(code) = write_markdown_artifacts_dispatch(&output_dir, &summary).await {
420 return code;
421 }
422 announce_output_paths(&output_dir);
423 if args.json {
424 if let Err(code) = print_json_dispatch(&summary).await {
425 return code;
426 }
427 } else if let Err(code) = print_summary_dispatch(&summary).await {
428 return code;
429 }
430
431 i32::from(had_error)
432}
433
434fn script_argv(
435 args: &EvalCodingAgentArgs,
436 fixture: &EvalPackCase,
437 selector: &ModelSelector,
438 tool_format: &str,
439 run_dir: &Path,
440) -> Vec<String> {
441 let mut argv = vec![
442 "--fixture".to_string(),
443 fixture_id(fixture).to_string(),
444 "--task".to_string(),
445 fixture.task.clone().unwrap_or_default(),
446 "--output-dir".to_string(),
447 run_dir.display().to_string(),
448 "--provider".to_string(),
449 selector.provider.clone(),
450 "--model".to_string(),
451 selector.model.clone(),
452 "--tool-format".to_string(),
453 tool_format.to_string(),
454 "--max-iterations".to_string(),
455 args.max_iterations.to_string(),
456 "--python".to_string(),
457 args.python.clone(),
458 ];
459 if selector.provider == "mock" {
460 argv.push("--seed-mock".to_string());
461 }
462 if let Some(json) = resolve_step_judge_json(args, selector) {
463 argv.push("--step-judge-json".to_string());
464 argv.push(json);
465 }
466 if let Some(reason) = args
467 .override_reason
468 .as_deref()
469 .map(str::trim)
470 .filter(|reason| !reason.is_empty())
471 {
472 argv.push("--override-reason".to_string());
473 argv.push(reason.to_string());
474 }
475 if let Some(json) = resolve_structural_validator_json(args) {
476 argv.push("--structural-validator-json".to_string());
477 argv.push(json);
478 }
479 argv
480}
481
482async fn resolve_models(args: &EvalCodingAgentArgs) -> Result<Vec<ModelSelector>, String> {
483 let mut seen = BTreeSet::new();
484 let mut out = Vec::new();
485 for raw in normalize_model_selector_args(&args.models) {
486 let trimmed = raw.trim();
487 if trimmed.is_empty() {
488 continue;
489 }
490 let selector = resolve_selector(trimmed);
491 if seen.insert(selector_label(&selector)) {
492 out.push(selector);
493 }
494 }
495 if args.include_local {
496 for selector in discover_local_models(args).await {
497 if seen.insert(selector_label(&selector)) {
498 out.push(selector);
499 }
500 }
501 }
502 Ok(out)
503}
504
505fn normalize_model_selector_args(raw_models: &[String]) -> Vec<String> {
506 let mut out = Vec::new();
507 let mut index = 0;
508 while index < raw_models.len() {
509 let current = raw_models[index].trim();
510 if current.starts_with("provider=") && index + 1 < raw_models.len() {
511 let next = raw_models[index + 1].trim();
512 if next.starts_with("model=") {
513 out.push(format!("{current},{next}"));
514 index += 2;
515 continue;
516 }
517 }
518 out.push(current.to_string());
519 index += 1;
520 }
521 out
522}
523
524async fn discover_local_models(args: &EvalCodingAgentArgs) -> Vec<ModelSelector> {
525 let providers = if args.local_providers.is_empty() {
526 local_provider_ids(None)
527 } else {
528 args.local_providers.clone()
529 };
530 let mut selectors = Vec::new();
531 let mut seen = BTreeSet::new();
532 for provider in providers {
533 if selectors.len() >= args.max_local_models {
534 break;
535 }
536 let Ok(snapshot) = snapshot_provider(&provider, Path::new(".")).await else {
537 continue;
538 };
539 if !snapshot.reachable {
540 continue;
541 }
542 let mut models = snapshot
543 .loaded_models
544 .iter()
545 .map(|model| model.name.clone())
546 .collect::<Vec<_>>();
547 models.extend(snapshot.served_models);
548 for model in models {
549 if selectors.len() >= args.max_local_models {
550 break;
551 }
552 let selector = ModelSelector {
553 selector: format!("{provider}:{model}"),
554 provider: provider.clone(),
555 model,
556 };
557 if seen.insert(selector_label(&selector)) {
558 selectors.push(selector);
559 }
560 }
561 }
562 selectors
563}
564
565fn normalize_tool_formats(raw_formats: &[String]) -> Result<Vec<String>, String> {
566 let mut seen = BTreeSet::new();
567 let mut out = Vec::new();
568 for raw in raw_formats {
569 let format = raw.trim().to_ascii_lowercase();
570 if format.is_empty() {
571 continue;
572 }
573 if !matches!(format.as_str(), "native" | "text" | "json") {
574 return Err(format!(
575 "unsupported --tool-format `{format}`; expected `native`, `text`, or `json`"
576 ));
577 }
578 if seen.insert(format.clone()) {
579 out.push(format);
580 }
581 }
582 Ok(out)
583}
584
585fn build_matrix(
586 fixtures: &[EvalPackCase],
587 models: &[ModelSelector],
588 tool_formats: &[String],
589 max_runs: Option<usize>,
590 replicates: usize,
591) -> Vec<(EvalPackCase, ModelSelector, String, usize)> {
592 if max_runs == Some(0) || replicates == 0 {
593 return Vec::new();
594 }
595 let mut matrix = Vec::new();
596 for fixture in fixtures {
597 for selector in models {
598 for tool_format in tool_formats {
599 for replicate in 1..=replicates {
600 matrix.push((
601 fixture.clone(),
602 selector.clone(),
603 tool_format.clone(),
604 replicate,
605 ));
606 if max_runs.is_some_and(|limit| matrix.len() >= limit) {
607 return matrix;
608 }
609 }
610 }
611 }
612 }
613 matrix
614}
615
616#[allow(clippy::too_many_arguments)]
617fn build_summary(
618 output_dir: &Path,
619 fixtures: Vec<EvalPackCase>,
620 models: Vec<ModelSelector>,
621 tool_formats: Vec<String>,
622 env_keys_loaded: Vec<LoadedEnvKey>,
623 runs: Vec<RunReport>,
624 step_judge_preset: Option<String>,
625 run_label: String,
626 baseline_comparison: Option<BaselineComparison>,
627) -> EvalSummary {
628 let passed_runs = runs.iter().filter(|run| run.passed).count();
629 let skipped_runs = runs.iter().filter(|run| run.skipped).count();
630 let failed_runs = runs
631 .iter()
632 .filter(|run| !run.passed && !run.skipped)
633 .count();
634 let total_cost_usd = runs.iter().map(|run| run.cost_usd).sum();
635 let rollups = build_rollups(&runs);
636 let comparisons = compare_formats(&runs);
637 let parity_by_pair = build_parity_by_pair(&comparisons);
638 let diverged_comparisons = comparisons
639 .iter()
640 .filter(|comparison| !comparison.divergence_reasons.is_empty())
641 .count();
642 let followups = suggest_followups(&runs, &comparisons);
643 EvalSummary {
644 schema_version: 3,
645 fixture_ids: fixtures
646 .iter()
647 .map(|fixture| fixture_id(fixture).to_string())
648 .collect(),
649 fixtures: fixtures
650 .iter()
651 .map(|fixture| FixtureReport {
652 id: fixture_id(fixture).to_string(),
653 name: fixture_name(fixture),
654 tool_sequence: fixture_tool_sequence(fixture),
655 description: fixture_description(fixture),
656 })
657 .collect(),
658 output_dir: output_dir.display().to_string(),
659 models,
660 tool_formats,
661 env_keys_loaded,
662 total_runs: runs.len(),
663 passed_runs,
664 failed_runs,
665 skipped_runs,
666 diverged_comparisons,
667 total_cost_usd,
668 rollups,
669 runs,
670 comparisons,
671 parity_by_pair,
672 followups,
673 step_judge_preset,
674 run_label,
675 baseline_comparison,
676 }
677}
678
679fn load_baseline_comparison(path: &Path, runs: &[RunReport]) -> Result<BaselineComparison, String> {
680 let resolved = if path.is_dir() {
681 path.join("summary.json")
682 } else {
683 path.to_path_buf()
684 };
685 let raw = fs::read_to_string(&resolved)
686 .map_err(|e| format!("failed to read {}: {e}", resolved.display()))?;
687 let baseline: serde_json::Value = serde_json::from_str(&raw)
688 .map_err(|e| format!("failed to parse {} as JSON: {e}", resolved.display()))?;
689 let baseline_runs = baseline
690 .get("runs")
691 .and_then(|v| v.as_array())
692 .ok_or_else(|| format!("{} has no `runs` array", resolved.display()))?;
693 let mut baseline_status: BTreeMap<String, &str> = BTreeMap::new();
697 for run in baseline_runs {
698 let fixture_id = match run.get("fixture_id").and_then(|v| v.as_str()) {
699 Some(id) => id.to_string(),
700 None => continue,
701 };
702 let passed = run.get("passed").and_then(|v| v.as_bool()).unwrap_or(false);
703 let skipped = run
704 .get("skipped")
705 .and_then(|v| v.as_bool())
706 .unwrap_or(false);
707 let status = if skipped {
708 "skipped"
709 } else if passed {
710 "passed"
711 } else {
712 "failed"
713 };
714 baseline_status
715 .entry(fixture_id)
716 .and_modify(|existing| {
717 if *existing != "passed" && status == "passed" {
718 *existing = status;
719 }
720 })
721 .or_insert(status);
722 }
723 let mut cell_status: BTreeMap<String, &str> = BTreeMap::new();
724 for run in runs {
725 let status = if run.skipped {
726 "skipped"
727 } else if run.passed {
728 "passed"
729 } else {
730 "failed"
731 };
732 cell_status
733 .entry(run.fixture_id.clone())
734 .and_modify(|existing| {
735 if *existing != "passed" && status == "passed" {
736 *existing = status;
737 }
738 })
739 .or_insert(status);
740 }
741 let mut regressions = Vec::new();
742 let mut recoveries = Vec::new();
743 let mut unchanged_passes = Vec::new();
744 let mut unchanged_failures = Vec::new();
745 let mut missing_in_baseline = Vec::new();
746 let mut missing_in_cell = Vec::new();
747 for (fixture, cell) in &cell_status {
748 match baseline_status.get(fixture) {
749 None => missing_in_baseline.push(fixture.clone()),
750 Some(base) => match (*base, *cell) {
751 ("passed", "passed") => unchanged_passes.push(fixture.clone()),
752 ("passed", _) => regressions.push(FixtureStatusDelta {
753 fixture_id: fixture.clone(),
754 baseline_status: (*base).to_string(),
755 cell_status: (*cell).to_string(),
756 }),
757 (_, "passed") => recoveries.push(FixtureStatusDelta {
758 fixture_id: fixture.clone(),
759 baseline_status: (*base).to_string(),
760 cell_status: (*cell).to_string(),
761 }),
762 _ => unchanged_failures.push(fixture.clone()),
763 },
764 }
765 }
766 for fixture in baseline_status.keys() {
767 if !cell_status.contains_key(fixture) {
768 missing_in_cell.push(fixture.clone());
769 }
770 }
771 let baseline_label = baseline
772 .get("run_label")
773 .and_then(|v| v.as_str())
774 .filter(|s| !s.is_empty())
775 .or_else(|| baseline.get("output_dir").and_then(|v| v.as_str()))
776 .unwrap_or("")
777 .to_string();
778 let regressions_count = regressions.len();
779 let recoveries_count = recoveries.len();
780 let total_compared =
781 regressions_count + recoveries_count + unchanged_passes.len() + unchanged_failures.len();
782 let net_lift_pp = if total_compared == 0 {
783 0.0
784 } else {
785 let raw =
786 (recoveries_count as f64 - regressions_count as f64) / total_compared as f64 * 100.0;
787 (raw * 10.0).round() / 10.0
788 };
789 Ok(BaselineComparison {
790 baseline_label,
791 baseline_path: resolved.display().to_string(),
792 regressions,
793 recoveries,
794 unchanged_passes,
795 unchanged_failures,
796 missing_in_baseline,
797 missing_in_cell,
798 regressions_count,
799 recoveries_count,
800 net_lift_pp,
801 })
802}
803
804fn build_rollups(runs: &[RunReport]) -> EvalRollups {
805 EvalRollups {
806 by_fixture: rollup_by(runs, |run| run.fixture_id.clone()),
807 by_provider: rollup_by(runs, |run| run.selector.provider.clone()),
808 by_model: rollup_by(runs, |run| run.selector.model.clone()),
809 by_tool_format: rollup_by(runs, |run| run.tool_format.clone()),
810 by_tool_sequence: rollup_by(runs, |run| run.fixture_tool_sequence.clone()),
811 }
812}
813
814fn rollup_by<F>(runs: &[RunReport], key_for: F) -> Vec<RollupReport>
815where
816 F: Fn(&RunReport) -> String,
817{
818 let mut grouped: BTreeMap<String, RollupReport> = BTreeMap::new();
819 for run in runs {
820 let key = key_for(run);
821 let entry = grouped.entry(key.clone()).or_insert_with(|| RollupReport {
822 key,
823 total_runs: 0,
824 passed_runs: 0,
825 failed_runs: 0,
826 skipped_runs: 0,
827 total_cost_usd: 0.0,
828 });
829 entry.total_runs += 1;
830 if run.passed {
831 entry.passed_runs += 1;
832 } else if run.skipped {
833 entry.skipped_runs += 1;
834 } else {
835 entry.failed_runs += 1;
836 }
837 entry.total_cost_usd += run.cost_usd;
838 }
839 grouped.into_values().collect()
840}
841
842fn compare_formats(runs: &[RunReport]) -> Vec<FormatComparison> {
843 let mut grouped: BTreeMap<String, Vec<&RunReport>> = BTreeMap::new();
844 for run in runs {
845 grouped
846 .entry(format!(
847 "{}\0{}",
848 run.fixture_id,
849 selector_label(&run.selector)
850 ))
851 .or_default()
852 .push(run);
853 }
854 let mut out = Vec::new();
855 for group in grouped.values() {
856 let native_runs = group
857 .iter()
858 .filter(|run| run.tool_format == "native")
859 .copied()
860 .collect::<Vec<_>>();
861 let text_runs = group
862 .iter()
863 .filter(|run| run.tool_format == "text")
864 .copied()
865 .collect::<Vec<_>>();
866 for index in 0..native_runs.len().max(text_runs.len()) {
869 if let Some(comparison) = compare_format_pair(
870 native_runs.get(index).copied(),
871 text_runs.get(index).copied(),
872 ) {
873 out.push(comparison);
874 }
875 }
876 }
877 out
878}
879
880fn compare_format_pair(
881 native: Option<&RunReport>,
882 text: Option<&RunReport>,
883) -> Option<FormatComparison> {
884 let first = native.or(text)?;
885 let pair = native.zip(text);
886 let mut divergence_reasons = Vec::new();
887 if let Some((native, text)) = pair {
888 if native.status != text.status {
889 divergence_reasons.push(format!(
890 "status differs: native={} text={}",
891 native.status, text.status
892 ));
893 }
894 if native.passed != text.passed {
895 divergence_reasons.push(format!(
896 "pass result differs: native={} text={}",
897 native.passed, text.passed
898 ));
899 }
900 if native.verification_success != text.verification_success {
901 divergence_reasons.push(format!(
902 "verifier result differs: native={} text={}",
903 native.verification_success, text.verification_success
904 ));
905 }
906 if native.tool_sequence != text.tool_sequence {
907 divergence_reasons.push(format!(
908 "tool sequence differs: native=[{}] text=[{}]",
909 native.tool_sequence.join(", "),
910 text.tool_sequence.join(", ")
911 ));
912 }
913 if native.rejected_tool_calls != text.rejected_tool_calls {
914 divergence_reasons.push(format!(
915 "rejected tool-call recovery differs: native={} text={}",
916 native.rejected_tool_calls, text.rejected_tool_calls
917 ));
918 }
919 }
920 let evidence_paths = [native, text]
921 .into_iter()
922 .flatten()
923 .map(|run| run.transcript_events_path.clone())
924 .collect::<Vec<_>>();
925 Some(FormatComparison {
926 fixture_id: first.fixture_id.clone(),
927 selector: first.selector.clone(),
928 native_run_id: native.map(|run| run.run_id.clone()),
929 text_run_id: text.map(|run| run.run_id.clone()),
930 native_evidence_path: native.map(|run| run.transcript_events_path.clone()),
931 text_evidence_path: text.map(|run| run.transcript_events_path.clone()),
932 native_status: native.map(|run| run.status.clone()),
933 text_status: text.map(|run| run.status.clone()),
934 native_passed: native.map(|run| run.passed),
935 text_passed: text.map(|run| run.passed),
936 native_tool_call_count: native.map(|run| run.tool_calls),
937 text_tool_call_count: text.map(|run| run.tool_calls),
938 native_rejected_tool_call_count: native.map(|run| run.rejected_tool_calls),
939 text_rejected_tool_call_count: text.map(|run| run.rejected_tool_calls),
940 verifier_match: pair
941 .map(|(native, text)| native.verification_success == text.verification_success),
942 tool_sequence_match: pair.map(|(native, text)| native.tool_sequence == text.tool_sequence),
943 rejected_tool_call_delta_text_minus_native: pair.map(|(native, text)| {
944 text.rejected_tool_calls as i64 - native.rejected_tool_calls as i64
945 }),
946 token_delta_text_minus_native: pair.map(|(native, text)| {
947 (text.input_tokens + text.output_tokens) - (native.input_tokens + native.output_tokens)
948 }),
949 iteration_delta_text_minus_native: pair
950 .map(|(native, text)| text.iterations - native.iterations),
951 equivalent: pair.map(|(native, text)| {
952 native.status == text.status
953 && native.passed == text.passed
954 && native.skipped == text.skipped
955 && native.verification_success == text.verification_success
956 && native.tool_sequence == text.tool_sequence
957 && native.rejected_tool_calls == text.rejected_tool_calls
958 }),
959 divergence_reasons,
960 evidence_paths,
961 })
962}
963
964fn build_parity_by_pair(comparisons: &[FormatComparison]) -> Vec<ToolModeParityPairSummary> {
965 let fixture_inputs = comparisons
966 .iter()
967 .filter_map(parity_fixture_input)
968 .collect::<Vec<_>>();
969 let fixture_reports = tool_mode_parity::build_fixture_reports(&fixture_inputs);
970 tool_mode_parity::build_pair_summaries(&fixture_reports)
971}
972
973fn parity_fixture_input(comparison: &FormatComparison) -> Option<ToolModeParityFixtureInput> {
974 let native_verdict = comparison.native_status.clone()?;
975 let text_verdict = comparison.text_status.clone()?;
976 if native_verdict == "skipped" || text_verdict == "skipped" {
977 return None;
978 }
979 Some(ToolModeParityFixtureInput {
980 provider: comparison.selector.provider.clone(),
981 model: comparison.selector.model.clone(),
982 fixture_id: comparison.fixture_id.clone(),
983 native_verdict,
984 text_verdict,
985 native_passed: comparison.native_passed?,
986 text_passed: comparison.text_passed?,
987 agreement: comparison.equivalent?,
988 verifier_agreement: comparison.verifier_match?,
989 native_tool_call_count: comparison.native_tool_call_count?,
990 text_tool_call_count: comparison.text_tool_call_count?,
991 native_rejected_tool_call_count: comparison.native_rejected_tool_call_count?,
992 text_rejected_tool_call_count: comparison.text_rejected_tool_call_count?,
993 native_evidence_path: comparison.native_evidence_path.clone()?,
994 text_evidence_path: comparison.text_evidence_path.clone()?,
995 })
996}
997
998fn suggest_followups(
999 runs: &[RunReport],
1000 comparisons: &[FormatComparison],
1001) -> Vec<FollowupSuggestion> {
1002 let mut out = Vec::new();
1003 let failed = runs
1004 .iter()
1005 .filter(|run| !run.passed && !run.skipped)
1006 .map(|run| run.run_id.clone())
1007 .collect::<Vec<_>>();
1008 if !failed.is_empty() {
1009 out.push(FollowupSuggestion {
1010 title: "Normalize coding-agent fixture failures across provider presets".to_string(),
1011 body: "One or more fixture/provider/tool-format runs failed. Inspect the run directories and decide whether the gap belongs in provider adapters, preset prompting, transcript handling, or host-tool ergonomics.".to_string(),
1012 labels: vec!["eval".to_string(), "providers".to_string()],
1013 run_ids: failed,
1014 });
1015 }
1016
1017 let rejected = runs
1018 .iter()
1019 .filter(|run| run.rejected_tool_calls > 0)
1020 .map(|run| run.run_id.clone())
1021 .collect::<Vec<_>>();
1022 if !rejected.is_empty() {
1023 out.push(FollowupSuggestion {
1024 title: "Abstract rejected tool-call recovery in agent transcripts".to_string(),
1025 body: "Some runs recovered after rejected tool calls. Add runtime support or preset guidance so harness authors can distinguish recoverable provider/tool-shape noise from user-relevant transcript events.".to_string(),
1026 labels: vec!["agents".to_string(), "transcripts".to_string()],
1027 run_ids: rejected,
1028 });
1029 }
1030
1031 let mismatched = comparisons
1032 .iter()
1033 .filter(|comparison| !comparison.divergence_reasons.is_empty())
1034 .map(|comparison| {
1035 format!(
1036 "{}:{} ({})",
1037 comparison.fixture_id,
1038 selector_label(&comparison.selector),
1039 comparison.divergence_reasons.join("; ")
1040 )
1041 })
1042 .collect::<Vec<_>>();
1043 if !mismatched.is_empty() {
1044 let run_ids = comparisons
1045 .iter()
1046 .filter(|comparison| !comparison.divergence_reasons.is_empty())
1047 .flat_map(|comparison| {
1048 [
1049 comparison.native_run_id.clone(),
1050 comparison.text_run_id.clone(),
1051 ]
1052 })
1053 .flatten()
1054 .collect::<Vec<_>>();
1055 out.push(FollowupSuggestion {
1056 title: "Make native/text tool modes behaviorally interchangeable for preset harnesses"
1057 .to_string(),
1058 body: format!(
1059 "Native and text tool modes diverged for: {}. The preset/runtime boundary should hide provider tool-channel differences where possible.",
1060 mismatched.join(", ")
1061 ),
1062 labels: vec!["agents".to_string(), "tools".to_string()],
1063 run_ids,
1064 });
1065 }
1066
1067 let unknown_pricing = runs
1068 .iter()
1069 .filter(|run| {
1070 !run.skipped
1071 && !run.pricing_known
1072 && !matches!(run.selector.provider.as_str(), "mock" | "fake")
1073 && !selector_is_local(&run.selector)
1074 })
1075 .map(|run| run.run_id.clone())
1076 .collect::<Vec<_>>();
1077 if !unknown_pricing.is_empty() {
1078 out.push(FollowupSuggestion {
1079 title: "Fill provider pricing metadata for benchmarked models".to_string(),
1080 body: "At least one live provider/model produced usage metrics but had no pricing entry, which weakens cost comparisons in eval reports.".to_string(),
1081 labels: vec!["providers".to_string(), "docs".to_string()],
1082 run_ids: unknown_pricing,
1083 });
1084 }
1085 out
1086}
1087
1088fn write_json_artifacts(output_dir: &Path, summary: &EvalSummary) -> Result<(), String> {
1089 write_json_pretty(&output_dir.join("summary.json"), summary)?;
1090 write_jsonl(&output_dir.join("per_run.jsonl"), &summary.runs)?;
1091 let summary_value = serde_json::to_value(summary).map_err(|error| error.to_string())?;
1092 let readiness = local_readiness::report_from_summary_json(
1093 &summary_value,
1094 output_dir.display().to_string(),
1095 )?;
1096 write_json_pretty(&output_dir.join("local_readiness.json"), &readiness)?;
1097 let generated_at = RealClock::new()
1098 .now_utc()
1099 .format(&time::format_description::well_known::Rfc3339)
1100 .map_err(|error| format!("failed to format parity overlay timestamp: {error}"))?;
1101 let parity_dir = output_dir.join(TOOL_MODE_PARITY_DIRECTORY);
1102 let parity_reports = tool_mode_parity::build_fixture_reports(
1103 &summary
1104 .comparisons
1105 .iter()
1106 .filter_map(parity_fixture_input)
1107 .collect::<Vec<_>>(),
1108 );
1109 let mut parity_report_counts = BTreeMap::<String, usize>::new();
1110 for report in &parity_reports {
1111 let key = sanitize_id(&format!(
1112 "{}__{}:{}",
1113 report.fixture_id, report.provider, report.model
1114 ));
1115 let occurrence = parity_report_counts
1116 .entry(key.clone())
1117 .and_modify(|count| *count += 1)
1118 .or_insert(1);
1119 let directory = if *occurrence == 1 {
1120 key
1121 } else {
1122 format!("{key}__r{occurrence}")
1123 };
1124 let path = parity_dir.join(directory).join("parity.json");
1125 tool_mode_parity::write_fixture_report(&path, report)?;
1126 }
1127 let overlay = tool_mode_parity::build_overlay(
1128 &summary.parity_by_pair,
1129 &generated_at,
1130 TOOL_MODE_PARITY_FIXTURE_SUITE,
1131 output_dir,
1132 );
1133 tool_mode_parity::write_overlay(
1134 &output_dir.join(TOOL_MODE_PARITY_OVERLAY_FILENAME),
1135 &overlay,
1136 )?;
1137 Ok(())
1138}
1139
1140fn announce_output_paths(output_dir: &Path) {
1141 eprintln!(
1142 "wrote {}, {}, {}, {}, {}, {}, and {}",
1143 output_dir.join("summary.json").display(),
1144 output_dir.join("per_run.jsonl").display(),
1145 output_dir.join("local_readiness.json").display(),
1146 output_dir.join(TOOL_MODE_PARITY_DIRECTORY).display(),
1147 output_dir.join(TOOL_MODE_PARITY_OVERLAY_FILENAME).display(),
1148 output_dir.join("summary.md").display(),
1149 output_dir.join("followups.md").display()
1150 );
1151}
1152
1153async fn write_markdown_artifacts_dispatch(
1156 output_dir: &Path,
1157 summary: &EvalSummary,
1158) -> Result<(), i32> {
1159 let markdown = render_via_dispatch(summary, "markdown").await?;
1160 if let Err(error) = fs::write(output_dir.join("summary.md"), markdown) {
1161 eprintln!("error: failed to write summary.md: {error}");
1162 return Err(1);
1163 }
1164 let followups = render_via_dispatch(summary, "followups").await?;
1165 if let Err(error) = fs::write(output_dir.join("followups.md"), followups) {
1166 eprintln!("error: failed to write followups.md: {error}");
1167 return Err(1);
1168 }
1169 Ok(())
1170}
1171
1172async fn print_summary_dispatch(summary: &EvalSummary) -> Result<(), i32> {
1173 let payload = render_via_dispatch(summary, "summary").await?;
1174 print!("{payload}");
1175 if !payload.ends_with('\n') {
1176 println!();
1177 }
1178 Ok(())
1179}
1180
1181async fn print_json_dispatch(summary: &EvalSummary) -> Result<(), i32> {
1182 let payload = render_via_dispatch(summary, "json").await?;
1183 print!("{payload}");
1184 if !payload.ends_with('\n') {
1185 println!();
1186 }
1187 Ok(())
1188}
1189
1190async fn render_via_dispatch(summary: &EvalSummary, mode: &str) -> Result<String, i32> {
1200 let summary_json = match serde_json::to_string(summary) {
1201 Ok(json) => json,
1202 Err(error) => {
1203 eprintln!("error: failed to serialise EvalSummary for dispatch: {error}");
1204 return Err(1);
1205 }
1206 };
1207 let _guard = DISPATCH_RENDER_LOCK.lock().await;
1208 let _summary = ScopedEnvVar::set(CODING_AGENT_SUMMARY_ENV, &summary_json);
1209 let _mode = ScopedEnvVar::set(CODING_AGENT_MODE_ENV, mode);
1210
1211 let outcome = dispatch::run_embedded_script("eval/coding_agent", Vec::new(), false).await;
1212 if !outcome.stderr.is_empty() {
1213 let _ = std::io::stderr().write_all(outcome.stderr.as_bytes());
1214 }
1215 if outcome.exit_code != 0 {
1216 return Err(outcome.exit_code);
1217 }
1218 Ok(outcome.stdout)
1219}
1220
1221fn write_json_pretty<T: Serialize>(path: &Path, value: &T) -> Result<(), String> {
1222 let body = serde_json::to_string_pretty(value).map_err(|error| error.to_string())?;
1223 fs::write(path, format!("{body}\n")).map_err(|error| error.to_string())
1224}
1225
1226fn write_jsonl<T: Serialize>(path: &Path, items: &[T]) -> Result<(), String> {
1227 let mut body = String::new();
1228 for item in items {
1229 let line = serde_json::to_string(item).map_err(|error| error.to_string())?;
1230 body.push_str(&line);
1231 body.push('\n');
1232 }
1233 fs::write(path, body).map_err(|error| error.to_string())
1234}
1235
1236fn read_run_summary(run_dir: &Path) -> Option<JsonValue> {
1237 let raw = fs::read_to_string(run_dir.join("summary.json")).ok()?;
1238 serde_json::from_str(&raw).ok()
1239}
1240
1241fn parse_last_json_line(stdout: &str) -> Option<JsonValue> {
1242 stdout
1243 .lines()
1244 .rev()
1245 .map(str::trim)
1246 .filter(|line| !line.is_empty())
1247 .find_map(|line| serde_json::from_str::<JsonValue>(line).ok())
1248}
1249
1250fn string_array(value: Option<&JsonValue>) -> Vec<String> {
1251 value
1252 .and_then(JsonValue::as_array)
1253 .map(|values| {
1254 values
1255 .iter()
1256 .filter_map(JsonValue::as_str)
1257 .map(str::to_string)
1258 .collect()
1259 })
1260 .unwrap_or_default()
1261}
1262
1263fn non_empty_string_array(value: Option<&JsonValue>) -> Option<Vec<String>> {
1264 let values = string_array(value);
1265 (!values.is_empty()).then_some(values)
1266}
1267
1268fn tool_call_sequence(value: Option<&JsonValue>) -> Option<Vec<String>> {
1269 let calls = value.and_then(JsonValue::as_array)?;
1270 let mut sequence = Vec::new();
1271 for call in calls {
1272 if let Some(name) = call
1273 .get("name")
1274 .or_else(|| call.get("tool_name"))
1275 .and_then(JsonValue::as_str)
1276 {
1277 sequence.push(name.to_string());
1278 }
1279 }
1280 (!sequence.is_empty()).then_some(sequence)
1281}
1282
1283fn run_id_for(
1284 fixture: &EvalPackCase,
1285 selector: &ModelSelector,
1286 tool_format: &str,
1287 replicate: usize,
1288) -> String {
1289 let suffix = (replicate > 1).then(|| format!("__r{replicate}"));
1290 sanitize_id(&format!(
1291 "{}__{}__{}{}",
1292 fixture_id(fixture),
1293 selector_label(selector),
1294 tool_format,
1295 suffix.as_deref().unwrap_or_default()
1296 ))
1297}
1298
1299fn sanitize_id(raw: &str) -> String {
1300 let mut out = String::new();
1301 for ch in raw.chars() {
1302 if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
1303 out.push(ch);
1304 } else {
1305 out.push('_');
1306 }
1307 }
1308 out.trim_matches('_').to_string()
1309}
1310
1311fn default_output_dir() -> PathBuf {
1312 PathBuf::from(".harn-runs")
1313 .join("coding-agent-bench")
1314 .join("latest")
1315}
1316
1317fn excerpt(text: &str) -> Option<String> {
1318 let trimmed = text.trim();
1319 if trimmed.is_empty() {
1320 return None;
1321 }
1322 let max = 4000;
1323 if trimmed.len() <= max {
1324 return Some(trimmed.to_string());
1325 }
1326 let mut truncated = String::new();
1327 for ch in trimmed.chars().take(max) {
1328 truncated.push(ch);
1329 }
1330 truncated.push_str("...");
1331 Some(truncated)
1332}
1333
1334fn load_env_files(paths: &[PathBuf]) -> Result<(EnvOverlay, Vec<LoadedEnvKey>), String> {
1335 let mut previous = Vec::new();
1336 let mut loaded = Vec::new();
1337 let mut touched = BTreeSet::new();
1338 for path in paths {
1339 let path = harn_vm::user_dirs::expand_home_path(path);
1340 let raw = fs::read_to_string(&path)
1341 .map_err(|error| format!("failed to read env file {}: {error}", path.display()))?;
1342 for (line_no, line) in raw.lines().enumerate() {
1343 let Some((key, value)) = parse_env_line(line).map_err(|error| {
1344 format!("{}:{}: {error}", path.display(), line_no.saturating_add(1))
1345 })?
1346 else {
1347 continue;
1348 };
1349 if touched.insert(key.clone()) {
1350 previous.push((OsString::from(&key), std::env::var_os(&key)));
1351 }
1352 std::env::set_var(&key, value);
1353 loaded.push(LoadedEnvKey {
1354 key,
1355 source: path.display().to_string(),
1356 });
1357 }
1358 }
1359 Ok((EnvOverlay { previous }, loaded))
1360}
1361
1362fn parse_env_line(line: &str) -> Result<Option<(String, String)>, String> {
1363 let trimmed = line.trim();
1364 if trimmed.is_empty() || trimmed.starts_with('#') {
1365 return Ok(None);
1366 }
1367 let trimmed = trimmed.strip_prefix("export ").unwrap_or(trimmed).trim();
1368 let Some((key, value)) = trimmed.split_once('=') else {
1369 return Err("expected KEY=VALUE".to_string());
1370 };
1371 let key = key.trim();
1372 if key.is_empty() {
1373 return Err("empty key".to_string());
1374 }
1375 if !key
1376 .chars()
1377 .all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
1378 {
1379 return Err(format!("invalid key `{key}`"));
1380 }
1381 Ok(Some((key.to_string(), unquote_env_value(value.trim()))))
1382}
1383
1384fn unquote_env_value(value: &str) -> String {
1385 if value.len() >= 2 {
1386 let bytes = value.as_bytes();
1387 if (bytes[0] == b'"' && bytes[value.len() - 1] == b'"')
1388 || (bytes[0] == b'\'' && bytes[value.len() - 1] == b'\'')
1389 {
1390 return value[1..value.len() - 1].to_string();
1391 }
1392 }
1393 value.to_string()
1394}
1395
1396#[cfg(test)]
1397#[path = "eval_coding_agent_tests.rs"]
1398mod tests;