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 eprintln!(
363 "{} {} {}: {}",
364 report.fixture_id,
365 selector_label(&report.selector),
366 report.tool_format,
367 report.status
368 );
369 reports.push(report);
370 }
371
372 let baseline_comparison = match &args.baseline_comparison_against {
373 Some(path) => match load_baseline_comparison(path, &reports) {
374 Ok(comparison) => Some(comparison),
375 Err(error) => {
376 eprintln!("error: --baseline-comparison-against: {error}");
377 return 1;
378 }
379 },
380 None => None,
381 };
382 let summary = build_summary(
383 &output_dir,
384 fixtures,
385 models,
386 tool_formats,
387 env_keys_loaded,
388 reports,
389 args.step_judge
390 .clone()
391 .filter(|s| !s.is_empty() && s != "none"),
392 args.run_label.clone(),
393 baseline_comparison,
394 );
395 if let Err(error) = write_json_artifacts(&output_dir, &summary) {
401 eprintln!("error: failed to write benchmark outputs: {error}");
402 return 1;
403 }
404
405 if let Err(code) = write_markdown_artifacts_dispatch(&output_dir, &summary).await {
406 return code;
407 }
408 announce_output_paths(&output_dir);
409 if args.json {
410 if let Err(code) = print_json_dispatch(&summary).await {
411 return code;
412 }
413 } else if let Err(code) = print_summary_dispatch(&summary).await {
414 return code;
415 }
416
417 i32::from(had_error)
418}
419
420fn script_argv(
421 args: &EvalCodingAgentArgs,
422 fixture: &EvalPackCase,
423 selector: &ModelSelector,
424 tool_format: &str,
425 run_dir: &Path,
426) -> Vec<String> {
427 let mut argv = vec![
428 "--fixture".to_string(),
429 fixture_id(fixture).to_string(),
430 "--task".to_string(),
431 fixture.task.clone().unwrap_or_default(),
432 "--output-dir".to_string(),
433 run_dir.display().to_string(),
434 "--provider".to_string(),
435 selector.provider.clone(),
436 "--model".to_string(),
437 selector.model.clone(),
438 "--tool-format".to_string(),
439 tool_format.to_string(),
440 "--max-iterations".to_string(),
441 args.max_iterations.to_string(),
442 "--python".to_string(),
443 args.python.clone(),
444 ];
445 if selector.provider == "mock" {
446 argv.push("--seed-mock".to_string());
447 }
448 if let Some(json) = resolve_step_judge_json(args, selector) {
449 argv.push("--step-judge-json".to_string());
450 argv.push(json);
451 }
452 if let Some(reason) = args
453 .override_reason
454 .as_deref()
455 .map(str::trim)
456 .filter(|reason| !reason.is_empty())
457 {
458 argv.push("--override-reason".to_string());
459 argv.push(reason.to_string());
460 }
461 if let Some(json) = resolve_structural_validator_json(args) {
462 argv.push("--structural-validator-json".to_string());
463 argv.push(json);
464 }
465 argv
466}
467
468async fn resolve_models(args: &EvalCodingAgentArgs) -> Result<Vec<ModelSelector>, String> {
469 let mut seen = BTreeSet::new();
470 let mut out = Vec::new();
471 for raw in normalize_model_selector_args(&args.models) {
472 let trimmed = raw.trim();
473 if trimmed.is_empty() {
474 continue;
475 }
476 let selector = resolve_selector(trimmed);
477 if seen.insert(selector_label(&selector)) {
478 out.push(selector);
479 }
480 }
481 if args.include_local {
482 for selector in discover_local_models(args).await {
483 if seen.insert(selector_label(&selector)) {
484 out.push(selector);
485 }
486 }
487 }
488 Ok(out)
489}
490
491fn normalize_model_selector_args(raw_models: &[String]) -> Vec<String> {
492 let mut out = Vec::new();
493 let mut index = 0;
494 while index < raw_models.len() {
495 let current = raw_models[index].trim();
496 if current.starts_with("provider=") && index + 1 < raw_models.len() {
497 let next = raw_models[index + 1].trim();
498 if next.starts_with("model=") {
499 out.push(format!("{current},{next}"));
500 index += 2;
501 continue;
502 }
503 }
504 out.push(current.to_string());
505 index += 1;
506 }
507 out
508}
509
510async fn discover_local_models(args: &EvalCodingAgentArgs) -> Vec<ModelSelector> {
511 let providers = if args.local_providers.is_empty() {
512 local_provider_ids(None)
513 } else {
514 args.local_providers.clone()
515 };
516 let mut selectors = Vec::new();
517 let mut seen = BTreeSet::new();
518 for provider in providers {
519 if selectors.len() >= args.max_local_models {
520 break;
521 }
522 let Ok(snapshot) = snapshot_provider(&provider, Path::new(".")).await else {
523 continue;
524 };
525 if !snapshot.reachable {
526 continue;
527 }
528 let mut models = snapshot
529 .loaded_models
530 .iter()
531 .map(|model| model.name.clone())
532 .collect::<Vec<_>>();
533 models.extend(snapshot.served_models);
534 for model in models {
535 if selectors.len() >= args.max_local_models {
536 break;
537 }
538 let selector = ModelSelector {
539 selector: format!("{provider}:{model}"),
540 provider: provider.clone(),
541 model,
542 };
543 if seen.insert(selector_label(&selector)) {
544 selectors.push(selector);
545 }
546 }
547 }
548 selectors
549}
550
551fn normalize_tool_formats(raw_formats: &[String]) -> Result<Vec<String>, String> {
552 let mut seen = BTreeSet::new();
553 let mut out = Vec::new();
554 for raw in raw_formats {
555 let format = raw.trim().to_ascii_lowercase();
556 if format.is_empty() {
557 continue;
558 }
559 if !matches!(format.as_str(), "native" | "text" | "json") {
560 return Err(format!(
561 "unsupported --tool-format `{format}`; expected `native`, `text`, or `json`"
562 ));
563 }
564 if seen.insert(format.clone()) {
565 out.push(format);
566 }
567 }
568 Ok(out)
569}
570
571fn build_matrix(
572 fixtures: &[EvalPackCase],
573 models: &[ModelSelector],
574 tool_formats: &[String],
575 max_runs: Option<usize>,
576 replicates: usize,
577) -> Vec<(EvalPackCase, ModelSelector, String, usize)> {
578 if max_runs == Some(0) || replicates == 0 {
579 return Vec::new();
580 }
581 let mut matrix = Vec::new();
582 for fixture in fixtures {
583 for selector in models {
584 for tool_format in tool_formats {
585 for replicate in 1..=replicates {
586 matrix.push((
587 fixture.clone(),
588 selector.clone(),
589 tool_format.clone(),
590 replicate,
591 ));
592 if max_runs.is_some_and(|limit| matrix.len() >= limit) {
593 return matrix;
594 }
595 }
596 }
597 }
598 }
599 matrix
600}
601
602#[allow(clippy::too_many_arguments)]
603fn build_summary(
604 output_dir: &Path,
605 fixtures: Vec<EvalPackCase>,
606 models: Vec<ModelSelector>,
607 tool_formats: Vec<String>,
608 env_keys_loaded: Vec<LoadedEnvKey>,
609 runs: Vec<RunReport>,
610 step_judge_preset: Option<String>,
611 run_label: String,
612 baseline_comparison: Option<BaselineComparison>,
613) -> EvalSummary {
614 let passed_runs = runs.iter().filter(|run| run.passed).count();
615 let skipped_runs = runs.iter().filter(|run| run.skipped).count();
616 let failed_runs = runs
617 .iter()
618 .filter(|run| !run.passed && !run.skipped)
619 .count();
620 let total_cost_usd = runs.iter().map(|run| run.cost_usd).sum();
621 let rollups = build_rollups(&runs);
622 let comparisons = compare_formats(&runs);
623 let parity_by_pair = build_parity_by_pair(&comparisons);
624 let diverged_comparisons = comparisons
625 .iter()
626 .filter(|comparison| !comparison.divergence_reasons.is_empty())
627 .count();
628 let followups = suggest_followups(&runs, &comparisons);
629 EvalSummary {
630 schema_version: 3,
631 fixture_ids: fixtures
632 .iter()
633 .map(|fixture| fixture_id(fixture).to_string())
634 .collect(),
635 fixtures: fixtures
636 .iter()
637 .map(|fixture| FixtureReport {
638 id: fixture_id(fixture).to_string(),
639 name: fixture_name(fixture),
640 tool_sequence: fixture_tool_sequence(fixture),
641 description: fixture_description(fixture),
642 })
643 .collect(),
644 output_dir: output_dir.display().to_string(),
645 models,
646 tool_formats,
647 env_keys_loaded,
648 total_runs: runs.len(),
649 passed_runs,
650 failed_runs,
651 skipped_runs,
652 diverged_comparisons,
653 total_cost_usd,
654 rollups,
655 runs,
656 comparisons,
657 parity_by_pair,
658 followups,
659 step_judge_preset,
660 run_label,
661 baseline_comparison,
662 }
663}
664
665fn load_baseline_comparison(path: &Path, runs: &[RunReport]) -> Result<BaselineComparison, String> {
666 let resolved = if path.is_dir() {
667 path.join("summary.json")
668 } else {
669 path.to_path_buf()
670 };
671 let raw = fs::read_to_string(&resolved)
672 .map_err(|e| format!("failed to read {}: {e}", resolved.display()))?;
673 let baseline: serde_json::Value = serde_json::from_str(&raw)
674 .map_err(|e| format!("failed to parse {} as JSON: {e}", resolved.display()))?;
675 let baseline_runs = baseline
676 .get("runs")
677 .and_then(|v| v.as_array())
678 .ok_or_else(|| format!("{} has no `runs` array", resolved.display()))?;
679 let mut baseline_status: BTreeMap<String, &str> = BTreeMap::new();
683 for run in baseline_runs {
684 let fixture_id = match run.get("fixture_id").and_then(|v| v.as_str()) {
685 Some(id) => id.to_string(),
686 None => continue,
687 };
688 let passed = run.get("passed").and_then(|v| v.as_bool()).unwrap_or(false);
689 let skipped = run
690 .get("skipped")
691 .and_then(|v| v.as_bool())
692 .unwrap_or(false);
693 let status = if skipped {
694 "skipped"
695 } else if passed {
696 "passed"
697 } else {
698 "failed"
699 };
700 baseline_status
701 .entry(fixture_id)
702 .and_modify(|existing| {
703 if *existing != "passed" && status == "passed" {
704 *existing = status;
705 }
706 })
707 .or_insert(status);
708 }
709 let mut cell_status: BTreeMap<String, &str> = BTreeMap::new();
710 for run in runs {
711 let status = if run.skipped {
712 "skipped"
713 } else if run.passed {
714 "passed"
715 } else {
716 "failed"
717 };
718 cell_status
719 .entry(run.fixture_id.clone())
720 .and_modify(|existing| {
721 if *existing != "passed" && status == "passed" {
722 *existing = status;
723 }
724 })
725 .or_insert(status);
726 }
727 let mut regressions = Vec::new();
728 let mut recoveries = Vec::new();
729 let mut unchanged_passes = Vec::new();
730 let mut unchanged_failures = Vec::new();
731 let mut missing_in_baseline = Vec::new();
732 let mut missing_in_cell = Vec::new();
733 for (fixture, cell) in &cell_status {
734 match baseline_status.get(fixture) {
735 None => missing_in_baseline.push(fixture.clone()),
736 Some(base) => match (*base, *cell) {
737 ("passed", "passed") => unchanged_passes.push(fixture.clone()),
738 ("passed", _) => regressions.push(FixtureStatusDelta {
739 fixture_id: fixture.clone(),
740 baseline_status: (*base).to_string(),
741 cell_status: (*cell).to_string(),
742 }),
743 (_, "passed") => recoveries.push(FixtureStatusDelta {
744 fixture_id: fixture.clone(),
745 baseline_status: (*base).to_string(),
746 cell_status: (*cell).to_string(),
747 }),
748 _ => unchanged_failures.push(fixture.clone()),
749 },
750 }
751 }
752 for fixture in baseline_status.keys() {
753 if !cell_status.contains_key(fixture) {
754 missing_in_cell.push(fixture.clone());
755 }
756 }
757 let baseline_label = baseline
758 .get("run_label")
759 .and_then(|v| v.as_str())
760 .filter(|s| !s.is_empty())
761 .or_else(|| baseline.get("output_dir").and_then(|v| v.as_str()))
762 .unwrap_or("")
763 .to_string();
764 let regressions_count = regressions.len();
765 let recoveries_count = recoveries.len();
766 let total_compared =
767 regressions_count + recoveries_count + unchanged_passes.len() + unchanged_failures.len();
768 let net_lift_pp = if total_compared == 0 {
769 0.0
770 } else {
771 let raw =
772 (recoveries_count as f64 - regressions_count as f64) / total_compared as f64 * 100.0;
773 (raw * 10.0).round() / 10.0
774 };
775 Ok(BaselineComparison {
776 baseline_label,
777 baseline_path: resolved.display().to_string(),
778 regressions,
779 recoveries,
780 unchanged_passes,
781 unchanged_failures,
782 missing_in_baseline,
783 missing_in_cell,
784 regressions_count,
785 recoveries_count,
786 net_lift_pp,
787 })
788}
789
790fn build_rollups(runs: &[RunReport]) -> EvalRollups {
791 EvalRollups {
792 by_fixture: rollup_by(runs, |run| run.fixture_id.clone()),
793 by_provider: rollup_by(runs, |run| run.selector.provider.clone()),
794 by_model: rollup_by(runs, |run| run.selector.model.clone()),
795 by_tool_format: rollup_by(runs, |run| run.tool_format.clone()),
796 by_tool_sequence: rollup_by(runs, |run| run.fixture_tool_sequence.clone()),
797 }
798}
799
800fn rollup_by<F>(runs: &[RunReport], key_for: F) -> Vec<RollupReport>
801where
802 F: Fn(&RunReport) -> String,
803{
804 let mut grouped: BTreeMap<String, RollupReport> = BTreeMap::new();
805 for run in runs {
806 let key = key_for(run);
807 let entry = grouped.entry(key.clone()).or_insert_with(|| RollupReport {
808 key,
809 total_runs: 0,
810 passed_runs: 0,
811 failed_runs: 0,
812 skipped_runs: 0,
813 total_cost_usd: 0.0,
814 });
815 entry.total_runs += 1;
816 if run.passed {
817 entry.passed_runs += 1;
818 } else if run.skipped {
819 entry.skipped_runs += 1;
820 } else {
821 entry.failed_runs += 1;
822 }
823 entry.total_cost_usd += run.cost_usd;
824 }
825 grouped.into_values().collect()
826}
827
828fn compare_formats(runs: &[RunReport]) -> Vec<FormatComparison> {
829 let mut grouped: BTreeMap<String, Vec<&RunReport>> = BTreeMap::new();
830 for run in runs {
831 grouped
832 .entry(format!(
833 "{}\0{}",
834 run.fixture_id,
835 selector_label(&run.selector)
836 ))
837 .or_default()
838 .push(run);
839 }
840 let mut out = Vec::new();
841 for group in grouped.values() {
842 let native_runs = group
843 .iter()
844 .filter(|run| run.tool_format == "native")
845 .copied()
846 .collect::<Vec<_>>();
847 let text_runs = group
848 .iter()
849 .filter(|run| run.tool_format == "text")
850 .copied()
851 .collect::<Vec<_>>();
852 for index in 0..native_runs.len().max(text_runs.len()) {
855 if let Some(comparison) = compare_format_pair(
856 native_runs.get(index).copied(),
857 text_runs.get(index).copied(),
858 ) {
859 out.push(comparison);
860 }
861 }
862 }
863 out
864}
865
866fn compare_format_pair(
867 native: Option<&RunReport>,
868 text: Option<&RunReport>,
869) -> Option<FormatComparison> {
870 let first = native.or(text)?;
871 let pair = native.zip(text);
872 let mut divergence_reasons = Vec::new();
873 if let Some((native, text)) = pair {
874 if native.status != text.status {
875 divergence_reasons.push(format!(
876 "status differs: native={} text={}",
877 native.status, text.status
878 ));
879 }
880 if native.passed != text.passed {
881 divergence_reasons.push(format!(
882 "pass result differs: native={} text={}",
883 native.passed, text.passed
884 ));
885 }
886 if native.verification_success != text.verification_success {
887 divergence_reasons.push(format!(
888 "verifier result differs: native={} text={}",
889 native.verification_success, text.verification_success
890 ));
891 }
892 if native.tool_sequence != text.tool_sequence {
893 divergence_reasons.push(format!(
894 "tool sequence differs: native=[{}] text=[{}]",
895 native.tool_sequence.join(", "),
896 text.tool_sequence.join(", ")
897 ));
898 }
899 if native.rejected_tool_calls != text.rejected_tool_calls {
900 divergence_reasons.push(format!(
901 "rejected tool-call recovery differs: native={} text={}",
902 native.rejected_tool_calls, text.rejected_tool_calls
903 ));
904 }
905 }
906 let evidence_paths = [native, text]
907 .into_iter()
908 .flatten()
909 .map(|run| run.transcript_events_path.clone())
910 .collect::<Vec<_>>();
911 Some(FormatComparison {
912 fixture_id: first.fixture_id.clone(),
913 selector: first.selector.clone(),
914 native_run_id: native.map(|run| run.run_id.clone()),
915 text_run_id: text.map(|run| run.run_id.clone()),
916 native_evidence_path: native.map(|run| run.transcript_events_path.clone()),
917 text_evidence_path: text.map(|run| run.transcript_events_path.clone()),
918 native_status: native.map(|run| run.status.clone()),
919 text_status: text.map(|run| run.status.clone()),
920 native_passed: native.map(|run| run.passed),
921 text_passed: text.map(|run| run.passed),
922 native_tool_call_count: native.map(|run| run.tool_calls),
923 text_tool_call_count: text.map(|run| run.tool_calls),
924 native_rejected_tool_call_count: native.map(|run| run.rejected_tool_calls),
925 text_rejected_tool_call_count: text.map(|run| run.rejected_tool_calls),
926 verifier_match: pair
927 .map(|(native, text)| native.verification_success == text.verification_success),
928 tool_sequence_match: pair.map(|(native, text)| native.tool_sequence == text.tool_sequence),
929 rejected_tool_call_delta_text_minus_native: pair.map(|(native, text)| {
930 text.rejected_tool_calls as i64 - native.rejected_tool_calls as i64
931 }),
932 token_delta_text_minus_native: pair.map(|(native, text)| {
933 (text.input_tokens + text.output_tokens) - (native.input_tokens + native.output_tokens)
934 }),
935 iteration_delta_text_minus_native: pair
936 .map(|(native, text)| text.iterations - native.iterations),
937 equivalent: pair.map(|(native, text)| {
938 native.status == text.status
939 && native.passed == text.passed
940 && native.skipped == text.skipped
941 && native.verification_success == text.verification_success
942 && native.tool_sequence == text.tool_sequence
943 && native.rejected_tool_calls == text.rejected_tool_calls
944 }),
945 divergence_reasons,
946 evidence_paths,
947 })
948}
949
950fn build_parity_by_pair(comparisons: &[FormatComparison]) -> Vec<ToolModeParityPairSummary> {
951 let fixture_inputs = comparisons
952 .iter()
953 .filter_map(parity_fixture_input)
954 .collect::<Vec<_>>();
955 let fixture_reports = tool_mode_parity::build_fixture_reports(&fixture_inputs);
956 tool_mode_parity::build_pair_summaries(&fixture_reports)
957}
958
959fn parity_fixture_input(comparison: &FormatComparison) -> Option<ToolModeParityFixtureInput> {
960 let native_verdict = comparison.native_status.clone()?;
961 let text_verdict = comparison.text_status.clone()?;
962 if native_verdict == "skipped" || text_verdict == "skipped" {
963 return None;
964 }
965 Some(ToolModeParityFixtureInput {
966 provider: comparison.selector.provider.clone(),
967 model: comparison.selector.model.clone(),
968 fixture_id: comparison.fixture_id.clone(),
969 native_verdict,
970 text_verdict,
971 native_passed: comparison.native_passed?,
972 text_passed: comparison.text_passed?,
973 agreement: comparison.equivalent?,
974 verifier_agreement: comparison.verifier_match?,
975 native_tool_call_count: comparison.native_tool_call_count?,
976 text_tool_call_count: comparison.text_tool_call_count?,
977 native_rejected_tool_call_count: comparison.native_rejected_tool_call_count?,
978 text_rejected_tool_call_count: comparison.text_rejected_tool_call_count?,
979 native_evidence_path: comparison.native_evidence_path.clone()?,
980 text_evidence_path: comparison.text_evidence_path.clone()?,
981 })
982}
983
984fn suggest_followups(
985 runs: &[RunReport],
986 comparisons: &[FormatComparison],
987) -> Vec<FollowupSuggestion> {
988 let mut out = Vec::new();
989 let failed = runs
990 .iter()
991 .filter(|run| !run.passed && !run.skipped)
992 .map(|run| run.run_id.clone())
993 .collect::<Vec<_>>();
994 if !failed.is_empty() {
995 out.push(FollowupSuggestion {
996 title: "Normalize coding-agent fixture failures across provider presets".to_string(),
997 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(),
998 labels: vec!["eval".to_string(), "providers".to_string()],
999 run_ids: failed,
1000 });
1001 }
1002
1003 let rejected = runs
1004 .iter()
1005 .filter(|run| run.rejected_tool_calls > 0)
1006 .map(|run| run.run_id.clone())
1007 .collect::<Vec<_>>();
1008 if !rejected.is_empty() {
1009 out.push(FollowupSuggestion {
1010 title: "Abstract rejected tool-call recovery in agent transcripts".to_string(),
1011 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(),
1012 labels: vec!["agents".to_string(), "transcripts".to_string()],
1013 run_ids: rejected,
1014 });
1015 }
1016
1017 let mismatched = comparisons
1018 .iter()
1019 .filter(|comparison| !comparison.divergence_reasons.is_empty())
1020 .map(|comparison| {
1021 format!(
1022 "{}:{} ({})",
1023 comparison.fixture_id,
1024 selector_label(&comparison.selector),
1025 comparison.divergence_reasons.join("; ")
1026 )
1027 })
1028 .collect::<Vec<_>>();
1029 if !mismatched.is_empty() {
1030 let run_ids = comparisons
1031 .iter()
1032 .filter(|comparison| !comparison.divergence_reasons.is_empty())
1033 .flat_map(|comparison| {
1034 [
1035 comparison.native_run_id.clone(),
1036 comparison.text_run_id.clone(),
1037 ]
1038 })
1039 .flatten()
1040 .collect::<Vec<_>>();
1041 out.push(FollowupSuggestion {
1042 title: "Make native/text tool modes behaviorally interchangeable for preset harnesses"
1043 .to_string(),
1044 body: format!(
1045 "Native and text tool modes diverged for: {}. The preset/runtime boundary should hide provider tool-channel differences where possible.",
1046 mismatched.join(", ")
1047 ),
1048 labels: vec!["agents".to_string(), "tools".to_string()],
1049 run_ids,
1050 });
1051 }
1052
1053 let unknown_pricing = runs
1054 .iter()
1055 .filter(|run| {
1056 !run.skipped
1057 && !run.pricing_known
1058 && !matches!(run.selector.provider.as_str(), "mock" | "fake")
1059 && !selector_is_local(&run.selector)
1060 })
1061 .map(|run| run.run_id.clone())
1062 .collect::<Vec<_>>();
1063 if !unknown_pricing.is_empty() {
1064 out.push(FollowupSuggestion {
1065 title: "Fill provider pricing metadata for benchmarked models".to_string(),
1066 body: "At least one live provider/model produced usage metrics but had no pricing entry, which weakens cost comparisons in eval reports.".to_string(),
1067 labels: vec!["providers".to_string(), "docs".to_string()],
1068 run_ids: unknown_pricing,
1069 });
1070 }
1071 out
1072}
1073
1074fn write_json_artifacts(output_dir: &Path, summary: &EvalSummary) -> Result<(), String> {
1075 write_json_pretty(&output_dir.join("summary.json"), summary)?;
1076 write_jsonl(&output_dir.join("per_run.jsonl"), &summary.runs)?;
1077 let summary_value = serde_json::to_value(summary).map_err(|error| error.to_string())?;
1078 let readiness = local_readiness::report_from_summary_json(
1079 &summary_value,
1080 output_dir.display().to_string(),
1081 )?;
1082 write_json_pretty(&output_dir.join("local_readiness.json"), &readiness)?;
1083 let generated_at = RealClock::new()
1084 .now_utc()
1085 .format(&time::format_description::well_known::Rfc3339)
1086 .map_err(|error| format!("failed to format parity overlay timestamp: {error}"))?;
1087 let parity_dir = output_dir.join(TOOL_MODE_PARITY_DIRECTORY);
1088 let parity_reports = tool_mode_parity::build_fixture_reports(
1089 &summary
1090 .comparisons
1091 .iter()
1092 .filter_map(parity_fixture_input)
1093 .collect::<Vec<_>>(),
1094 );
1095 let mut parity_report_counts = BTreeMap::<String, usize>::new();
1096 for report in &parity_reports {
1097 let key = sanitize_id(&format!(
1098 "{}__{}:{}",
1099 report.fixture_id, report.provider, report.model
1100 ));
1101 let occurrence = parity_report_counts
1102 .entry(key.clone())
1103 .and_modify(|count| *count += 1)
1104 .or_insert(1);
1105 let directory = if *occurrence == 1 {
1106 key
1107 } else {
1108 format!("{key}__r{occurrence}")
1109 };
1110 let path = parity_dir.join(directory).join("parity.json");
1111 tool_mode_parity::write_fixture_report(&path, report)?;
1112 }
1113 let overlay = tool_mode_parity::build_overlay(
1114 &summary.parity_by_pair,
1115 &generated_at,
1116 TOOL_MODE_PARITY_FIXTURE_SUITE,
1117 output_dir,
1118 );
1119 tool_mode_parity::write_overlay(
1120 &output_dir.join(TOOL_MODE_PARITY_OVERLAY_FILENAME),
1121 &overlay,
1122 )?;
1123 Ok(())
1124}
1125
1126fn announce_output_paths(output_dir: &Path) {
1127 eprintln!(
1128 "wrote {}, {}, {}, {}, {}, {}, and {}",
1129 output_dir.join("summary.json").display(),
1130 output_dir.join("per_run.jsonl").display(),
1131 output_dir.join("local_readiness.json").display(),
1132 output_dir.join(TOOL_MODE_PARITY_DIRECTORY).display(),
1133 output_dir.join(TOOL_MODE_PARITY_OVERLAY_FILENAME).display(),
1134 output_dir.join("summary.md").display(),
1135 output_dir.join("followups.md").display()
1136 );
1137}
1138
1139async fn write_markdown_artifacts_dispatch(
1142 output_dir: &Path,
1143 summary: &EvalSummary,
1144) -> Result<(), i32> {
1145 let markdown = render_via_dispatch(summary, "markdown").await?;
1146 if let Err(error) = fs::write(output_dir.join("summary.md"), markdown) {
1147 eprintln!("error: failed to write summary.md: {error}");
1148 return Err(1);
1149 }
1150 let followups = render_via_dispatch(summary, "followups").await?;
1151 if let Err(error) = fs::write(output_dir.join("followups.md"), followups) {
1152 eprintln!("error: failed to write followups.md: {error}");
1153 return Err(1);
1154 }
1155 Ok(())
1156}
1157
1158async fn print_summary_dispatch(summary: &EvalSummary) -> Result<(), i32> {
1159 let payload = render_via_dispatch(summary, "summary").await?;
1160 print!("{payload}");
1161 if !payload.ends_with('\n') {
1162 println!();
1163 }
1164 Ok(())
1165}
1166
1167async fn print_json_dispatch(summary: &EvalSummary) -> Result<(), i32> {
1168 let payload = render_via_dispatch(summary, "json").await?;
1169 print!("{payload}");
1170 if !payload.ends_with('\n') {
1171 println!();
1172 }
1173 Ok(())
1174}
1175
1176async fn render_via_dispatch(summary: &EvalSummary, mode: &str) -> Result<String, i32> {
1186 let summary_json = match serde_json::to_string(summary) {
1187 Ok(json) => json,
1188 Err(error) => {
1189 eprintln!("error: failed to serialise EvalSummary for dispatch: {error}");
1190 return Err(1);
1191 }
1192 };
1193 let _guard = DISPATCH_RENDER_LOCK.lock().await;
1194 let _summary = ScopedEnvVar::set(CODING_AGENT_SUMMARY_ENV, &summary_json);
1195 let _mode = ScopedEnvVar::set(CODING_AGENT_MODE_ENV, mode);
1196
1197 let outcome = dispatch::run_embedded_script("eval/coding_agent", Vec::new(), false).await;
1198 if !outcome.stderr.is_empty() {
1199 let _ = std::io::stderr().write_all(outcome.stderr.as_bytes());
1200 }
1201 if outcome.exit_code != 0 {
1202 return Err(outcome.exit_code);
1203 }
1204 Ok(outcome.stdout)
1205}
1206
1207fn write_json_pretty<T: Serialize>(path: &Path, value: &T) -> Result<(), String> {
1208 let body = serde_json::to_string_pretty(value).map_err(|error| error.to_string())?;
1209 fs::write(path, format!("{body}\n")).map_err(|error| error.to_string())
1210}
1211
1212fn write_jsonl<T: Serialize>(path: &Path, items: &[T]) -> Result<(), String> {
1213 let mut body = String::new();
1214 for item in items {
1215 let line = serde_json::to_string(item).map_err(|error| error.to_string())?;
1216 body.push_str(&line);
1217 body.push('\n');
1218 }
1219 fs::write(path, body).map_err(|error| error.to_string())
1220}
1221
1222fn read_run_summary(run_dir: &Path) -> Option<JsonValue> {
1223 let raw = fs::read_to_string(run_dir.join("summary.json")).ok()?;
1224 serde_json::from_str(&raw).ok()
1225}
1226
1227fn parse_last_json_line(stdout: &str) -> Option<JsonValue> {
1228 stdout
1229 .lines()
1230 .rev()
1231 .map(str::trim)
1232 .filter(|line| !line.is_empty())
1233 .find_map(|line| serde_json::from_str::<JsonValue>(line).ok())
1234}
1235
1236fn string_array(value: Option<&JsonValue>) -> Vec<String> {
1237 value
1238 .and_then(JsonValue::as_array)
1239 .map(|values| {
1240 values
1241 .iter()
1242 .filter_map(JsonValue::as_str)
1243 .map(str::to_string)
1244 .collect()
1245 })
1246 .unwrap_or_default()
1247}
1248
1249fn non_empty_string_array(value: Option<&JsonValue>) -> Option<Vec<String>> {
1250 let values = string_array(value);
1251 (!values.is_empty()).then_some(values)
1252}
1253
1254fn tool_call_sequence(value: Option<&JsonValue>) -> Option<Vec<String>> {
1255 let calls = value.and_then(JsonValue::as_array)?;
1256 let mut sequence = Vec::new();
1257 for call in calls {
1258 if let Some(name) = call
1259 .get("name")
1260 .or_else(|| call.get("tool_name"))
1261 .and_then(JsonValue::as_str)
1262 {
1263 sequence.push(name.to_string());
1264 }
1265 }
1266 (!sequence.is_empty()).then_some(sequence)
1267}
1268
1269fn run_id_for(
1270 fixture: &EvalPackCase,
1271 selector: &ModelSelector,
1272 tool_format: &str,
1273 replicate: usize,
1274) -> String {
1275 let suffix = (replicate > 1).then(|| format!("__r{replicate}"));
1276 sanitize_id(&format!(
1277 "{}__{}__{}{}",
1278 fixture_id(fixture),
1279 selector_label(selector),
1280 tool_format,
1281 suffix.as_deref().unwrap_or_default()
1282 ))
1283}
1284
1285fn sanitize_id(raw: &str) -> String {
1286 let mut out = String::new();
1287 for ch in raw.chars() {
1288 if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
1289 out.push(ch);
1290 } else {
1291 out.push('_');
1292 }
1293 }
1294 out.trim_matches('_').to_string()
1295}
1296
1297fn default_output_dir() -> PathBuf {
1298 PathBuf::from(".harn-runs")
1299 .join("coding-agent-bench")
1300 .join("latest")
1301}
1302
1303fn excerpt(text: &str) -> Option<String> {
1304 let trimmed = text.trim();
1305 if trimmed.is_empty() {
1306 return None;
1307 }
1308 let max = 4000;
1309 if trimmed.len() <= max {
1310 return Some(trimmed.to_string());
1311 }
1312 let mut truncated = String::new();
1313 for ch in trimmed.chars().take(max) {
1314 truncated.push(ch);
1315 }
1316 truncated.push_str("...");
1317 Some(truncated)
1318}
1319
1320fn load_env_files(paths: &[PathBuf]) -> Result<(EnvOverlay, Vec<LoadedEnvKey>), String> {
1321 let mut previous = Vec::new();
1322 let mut loaded = Vec::new();
1323 let mut touched = BTreeSet::new();
1324 for path in paths {
1325 let path = harn_vm::user_dirs::expand_home_path(path);
1326 let raw = fs::read_to_string(&path)
1327 .map_err(|error| format!("failed to read env file {}: {error}", path.display()))?;
1328 for (line_no, line) in raw.lines().enumerate() {
1329 let Some((key, value)) = parse_env_line(line).map_err(|error| {
1330 format!("{}:{}: {error}", path.display(), line_no.saturating_add(1))
1331 })?
1332 else {
1333 continue;
1334 };
1335 if touched.insert(key.clone()) {
1336 previous.push((OsString::from(&key), std::env::var_os(&key)));
1337 }
1338 std::env::set_var(&key, value);
1339 loaded.push(LoadedEnvKey {
1340 key,
1341 source: path.display().to_string(),
1342 });
1343 }
1344 }
1345 Ok((EnvOverlay { previous }, loaded))
1346}
1347
1348fn parse_env_line(line: &str) -> Result<Option<(String, String)>, String> {
1349 let trimmed = line.trim();
1350 if trimmed.is_empty() || trimmed.starts_with('#') {
1351 return Ok(None);
1352 }
1353 let trimmed = trimmed.strip_prefix("export ").unwrap_or(trimmed).trim();
1354 let Some((key, value)) = trimmed.split_once('=') else {
1355 return Err("expected KEY=VALUE".to_string());
1356 };
1357 let key = key.trim();
1358 if key.is_empty() {
1359 return Err("empty key".to_string());
1360 }
1361 if !key
1362 .chars()
1363 .all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
1364 {
1365 return Err(format!("invalid key `{key}`"));
1366 }
1367 Ok(Some((key.to_string(), unquote_env_value(value.trim()))))
1368}
1369
1370fn unquote_env_value(value: &str) -> String {
1371 if value.len() >= 2 {
1372 let bytes = value.as_bytes();
1373 if (bytes[0] == b'"' && bytes[value.len() - 1] == b'"')
1374 || (bytes[0] == b'\'' && bytes[value.len() - 1] == b'\'')
1375 {
1376 return value[1..value.len() - 1].to_string();
1377 }
1378 }
1379 value.to_string()
1380}
1381
1382#[cfg(test)]
1383#[path = "eval_coding_agent_tests.rs"]
1384mod tests;