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