Skip to main content

harn_cli/commands/
eval_coding_agent.rs

1//! `harn eval coding-agent` - empirical preset/provider benchmark for a
2//! small coding-agent fixture suite.
3//!
4//! ## Dispatch boundary
5//!
6//! The **matrix execution pipeline** (fixture resolution, model
7//! discovery, per-cell `execute_run` invocation, Ollama snapshot/
8//! cleanup, scoring, rollups, native/text comparisons, follow-up
9//! generation, baseline diff) stays in Rust. Every cell drives the
10//! embedded `coding_agent_suite.harn` driver through `execute_run`,
11//! which itself reaches into VM internals (`commands::run`,
12//! `harn_vm::llm`, `commands::local::runtime`) that are not exposed as
13//! script capabilities.
14//!
15//! The **rendering layer** (the `summary.md` body, the `followups.md`
16//! body, the one-line human stdout summary, the `--json` pretty form)
17//! is delegated to
18//! `crates/harn-stdlib/src/stdlib/cli/eval/coding_agent.harn`. The
19//! Rust shim pre-serialises the assembled `EvalSummary` to JSON,
20//! forwards it via [`CODING_AGENT_SUMMARY_ENV`], dispatches four
21//! times (markdown for `summary.md`, followups for `followups.md`,
22//! then either the summary line or the `--json` pretty form for
23//! stdout), and writes the captured payloads to disk / real stdout.
24//!
25//! The on-disk JSON artifacts (`summary.json`, `per_run.jsonl`,
26//! `local_readiness.json`) stay on the serde-driven Rust path because
27//! Harn's `json_stringify_pretty` sorts dict keys alphabetically and
28//! the on-disk format is consumed by the experiment driver in
29//! `experiments/step-judge/run.sh`, the local-readiness regression
30//! check, and hosted ingestion — all of which depend on the serde
31//! struct-field byte order.
32
33mod 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
68/// Env var the embedded `cli/eval/coding_agent` script reads to pick
69/// up the pre-serialised [`EvalSummary`]. The Rust shim does all the
70/// matrix execution and scoring and hands the script the assembled
71/// summary so it only has to format it.
72const CODING_AGENT_SUMMARY_ENV: &str = "HARN_EVAL_CODING_AGENT_SUMMARY_JSON";
73const CODING_AGENT_EVAL_PACK_ID: &str = "coding-agent";
74
75/// Env var the script reads to pick the rendering mode — one of
76/// `"markdown"` (summary.md body), `"followups"` (followups.md body),
77/// `"summary"` (one-line stdout summary), or `"json"` (--json pretty
78/// form). Defaulted to `"summary"` if unset so the script stays robust
79/// against future Rust-side bugs.
80const CODING_AGENT_MODE_ENV: &str = "HARN_EVAL_CODING_AGENT_MODE";
81const TOOL_FORMAT_OVERRIDE_WARNING_PREFIX: &str = "warning: tool_format override:";
82
83/// Serialises the dispatch-render path so concurrent in-process
84/// callers (the existing `eval_coding_agent_cli` integration test plus
85/// any future fanout caller) don't race on the global env vars the
86/// Rust shim sets to hand the report off to the .harn script. The CLI
87/// binary itself is single-call, so this mutex is uncontended in
88/// production; in tests it serialises the dispatch window only —
89/// matrix execution still parallelises freely.
90///
91/// Matches the other eval render shims so the cross-script env-var handoff
92/// stays consistent across the eval cluster.
93static 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    /// Step-judge preset applied to all runs in this invocation, if any.
248    /// Used by the experiment driver (experiments/step-judge/run.sh) to
249    /// group repeat invocations into cells.
250    #[serde(skip_serializing_if = "Option::is_none")]
251    step_judge_preset: Option<String>,
252    /// Free-form label for grouping repeat invocations (e.g.
253    /// "replicate-1", "probe-rubric-adversarial"). Empty when unset.
254    #[serde(skip_serializing_if = "String::is_empty")]
255    run_label: String,
256    /// Optional per-fixture diff against a prior run's `summary.json`,
257    /// listing regressions (baseline passed, this cell failed) and
258    /// recoveries (baseline failed, this cell passed) plus aggregate
259    /// counts and a net lift in percentage points. Populated when the
260    /// caller passes `--baseline-comparison-against <path>` (harn#2318).
261    #[serde(skip_serializing_if = "Option::is_none")]
262    baseline_comparison: Option<BaselineComparison>,
263}
264
265#[derive(Debug, Clone, Serialize, Default)]
266struct BaselineComparison {
267    /// `output_dir` or `run_label` of the baseline summary, for context.
268    baseline_label: String,
269    /// Resolved path to the baseline `summary.json` that was diffed against.
270    baseline_path: String,
271    regressions: Vec<FixtureStatusDelta>,
272    recoveries: Vec<FixtureStatusDelta>,
273    /// Fixtures that passed in both runs.
274    unchanged_passes: Vec<String>,
275    /// Fixtures that failed in both runs.
276    unchanged_failures: Vec<String>,
277    /// Fixtures present in only one of the two runs (skipped from the
278    /// diff but listed for visibility).
279    missing_in_baseline: Vec<String>,
280    missing_in_cell: Vec<String>,
281    regressions_count: usize,
282    recoveries_count: usize,
283    /// `(recoveries_count - regressions_count) / total_fixtures_compared * 100`,
284    /// rounded to one decimal place. Negative when the cell regresses more
285    /// than it recovers.
286    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    // The JSON artifacts (summary.json, per_run.jsonl,
396    // local_readiness.json) always stay on the serde-driven Rust path —
397    // see module docstring for the byte-format rationale. They write
398    // before any rendering so a render failure doesn't leave a partially
399    // written report directory.
400    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    // Index baseline status by fixture_id. When the baseline has multiple
680    // runs per fixture (e.g. native + text), prefer the first passing run
681    // so a fixture passes the comparison if ANY baseline variant did.
682    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        // Matrix construction preserves replicate order within each format, so
853        // index-pairing compares the same fixture trial across native and text.
854        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
1139// ─── Dispatch (.harn) render path ────────────────────────────────────────
1140
1141async 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
1176/// Dispatch to the embedded `cli/eval/coding_agent.harn` script for one
1177/// of the four rendering modes (markdown / followups / summary / json).
1178/// Returns the captured stdout on success, or a propagated exit code
1179/// on failure.
1180///
1181/// **Concurrency.** Held under [`DISPATCH_RENDER_LOCK`] so concurrent
1182/// in-process callers don't race on the global env vars the Rust shim
1183/// sets to hand the report to the script. See the lock's docstring
1184/// for the trade-off rationale.
1185async 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;