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