Skip to main content

harn_cli/commands/
eval_prompt.rs

1//! `harn eval prompt <file> --fleet <models>` — render and optionally run
2//! a single `.harn.prompt` template across a fleet of models so authors
3//! can validate the capability-adapted envelope per model side-by-side.
4//!
5//! Render mode is the v1 acceptance path: it pushes an LLM render
6//! context per model, calls the template engine, and emits the rendered
7//! envelope plus a wire-format diff. Run and judge modes synthesize a
8//! tiny Harn driver and route through the existing `execute_run`
9//! pipeline so credentialed LLM calls, mock fixtures, and the
10//! `LlmRenderContext` injection all stay on the canonical path.
11//!
12//! ## .harn dispatch
13//!
14//! The **aggregation layer** (fleet resolution, per-model rendering via
15//! `LlmRenderContext`, run/judge fanout, context-fixture evaluation)
16//! stays in Rust — it reaches into `harn_vm::stdlib::template`,
17//! `harn_vm::llm_config`, and `harn_vm::orchestration` internals that
18//! aren't exposed to script-land today.
19//!
20//! The **rendering layer** (terminal / JSON / HTML) is delegated to
21//! `crates/harn-stdlib/src/stdlib/cli/eval/prompt.harn`. The Rust shim
22//! serialises the assembled `PromptReport` to JSON and forwards it via
23//! [`PROMPT_REPORT_ENV`] plus a couple of mode env vars, then routes
24//! through the standard dispatch wedge. The script just reads the
25//! report, picks a formatter, and emits the payload (or writes it to
26//! `--out-file`).
27
28use std::collections::{BTreeMap, BTreeSet};
29use std::fs;
30use std::path::{Path, PathBuf};
31
32use harn_vm::llm_config;
33use harn_vm::stdlib::template::{
34    render_template_to_string_with_branch_trace, BranchDecision, LlmRenderContext,
35    LlmRenderContextGuard,
36};
37use harn_vm::value::VmValue;
38use serde_json::Value as JsonValue;
39
40use crate::cli::{EvalPromptArgs, EvalPromptMode, EvalPromptOutput};
41use crate::dispatch;
42use crate::env_guard::ScopedEnvVar;
43use harn_modules::project_config;
44
45use super::eval_prompt_context::{evaluate_context_fixtures, PromptContextEvalReport};
46
47/// Env var the embedded `cli/eval/prompt` script reads to pick up the
48/// pre-serialised [`PromptReport`]. The Rust shim does all of the
49/// aggregation (fleet rendering, run/judge fanout, context-fixture
50/// evaluation) and hands the script the assembled report so it only
51/// has to format it.
52const PROMPT_REPORT_ENV: &str = "HARN_EVAL_PROMPT_REPORT_JSON";
53
54/// Env var the script reads to select the output format ("terminal",
55/// "json", or "html"). Defaulted to "terminal" if unset so the script
56/// stays robust against future Rust-side bugs.
57const PROMPT_OUTPUT_ENV: &str = "HARN_EVAL_PROMPT_OUTPUT";
58
59/// Serializes the dispatch-render path so concurrent in-process callers
60/// (the existing `eval_prompt_cli` integration tests run multiple
61/// `run` invocations in parallel) don't race on the global env vars
62/// the Rust shim sets to hand the report off to the .harn script. The
63/// CLI binary itself is single-call, so this mutex is uncontended in
64/// production; in tests it serialises the dispatch window only —
65/// aggregation still parallelises freely.
66///
67/// A future iteration could pass the report through a script-local
68/// channel that doesn't go through process-global env vars, but adding
69/// that to G1's dispatch wedge is out of scope for W5.
70static DISPATCH_RENDER_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
71
72/// Resolved per-model envelope produced by `--mode render`.
73#[derive(Debug, Clone, serde::Serialize)]
74struct ModelRender {
75    /// User-supplied selector (alias or `provider:model`).
76    selector: String,
77    provider: String,
78    model: String,
79    family: String,
80    capabilities: JsonValue,
81    /// `Some` on success; `None` if template rendering failed.
82    rendered: Option<String>,
83    error: Option<String>,
84    #[serde(default, skip_serializing_if = "Vec::is_empty")]
85    branches: Vec<TemplateBranch>,
86    /// `true` when the model's provider has no usable credentials. In
87    /// render mode this is informational; in run/judge mode it controls
88    /// whether the call is skipped.
89    auth_available: bool,
90}
91
92/// Per-model artifact produced by `--mode run`.
93#[derive(Debug, Clone, serde::Serialize, Default)]
94struct ModelRunResult {
95    response: Option<String>,
96    error: Option<String>,
97    /// True if the call was skipped because the provider was unauthenticated.
98    skipped: bool,
99}
100
101#[derive(Debug, Clone, serde::Serialize)]
102struct PromptReport {
103    template_path: PathBuf,
104    mode: &'static str,
105    renders: Vec<ModelRender>,
106    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
107    runs: BTreeMap<String, ModelRunResult>,
108    #[serde(skip_serializing_if = "Option::is_none")]
109    judge: Option<JudgeReport>,
110    #[serde(skip_serializing_if = "Option::is_none")]
111    context_eval: Option<PromptContextEvalReport>,
112}
113
114#[derive(Debug, Clone, serde::Serialize)]
115struct JudgeReport {
116    judge_model: String,
117    /// Raw judge response text — the built-in judge template asks for a
118    /// short JSON or prose verdict; we surface it verbatim so the user
119    /// can inspect.
120    verdict: String,
121}
122
123#[derive(Debug, Clone, serde::Serialize)]
124struct TemplateBranch {
125    kind: String,
126    template_uri: String,
127    line: usize,
128    col: usize,
129    branch_id: String,
130    #[serde(skip_serializing_if = "Option::is_none")]
131    branch_label: Option<String>,
132}
133
134impl From<&BranchDecision> for TemplateBranch {
135    fn from(decision: &BranchDecision) -> Self {
136        Self {
137            kind: decision.kind.as_str().to_string(),
138            template_uri: decision.template_uri.clone(),
139            line: decision.line,
140            col: decision.col,
141            branch_id: decision.branch_id.clone(),
142            branch_label: decision.branch_label.clone(),
143        }
144    }
145}
146
147pub async fn run(args: EvalPromptArgs) -> i32 {
148    let report = match aggregate_report(&args).await {
149        Ok(report) => report,
150        Err(code) => return code,
151    };
152
153    let exit_code = post_render_exit_code(&report);
154    match dispatch_render(&report, args.output, args.out_file.as_deref()).await {
155        Ok(()) => exit_code,
156        Err(code) => code,
157    }
158}
159
160/// Build the aggregated [`PromptReport`] without rendering it.
161///
162/// Pulled out of [`run`] so host aggregation stays separate from the
163/// `.harn` rendering script. Returns an exit code on any aggregation
164/// failure (template read, fleet resolution, context fixture parse /
165/// evaluate, run/judge dispatch).
166async fn aggregate_report(args: &EvalPromptArgs) -> Result<PromptReport, i32> {
167    let template_path = match fs::canonicalize(&args.file) {
168        Ok(p) => p,
169        Err(error) => {
170            eprintln!(
171                "error: cannot resolve template path {}: {error}",
172                args.file.display()
173            );
174            return Err(1);
175        }
176    };
177    let template_source = match fs::read_to_string(&template_path) {
178        Ok(s) => s,
179        Err(error) => {
180            eprintln!("error: failed to read {}: {error}", template_path.display());
181            return Err(1);
182        }
183    };
184
185    let fleet = match resolve_fleet(args, &template_path) {
186        Ok(f) => f,
187        Err(error) => {
188            eprintln!("error: {error}");
189            return Err(2);
190        }
191    };
192    if fleet.is_empty() {
193        eprintln!("error: fleet is empty — supply `--fleet <models>` or `--fleet-name <name>`");
194        return Err(2);
195    }
196
197    let bindings = match load_bindings(args.bindings.as_deref()) {
198        Ok(b) => b,
199        Err(error) => {
200            eprintln!("error: {error}");
201            return Err(1);
202        }
203    };
204
205    let renders = render_fleet(&fleet, &template_source, &template_path, bindings.as_ref());
206
207    let mode = args.mode;
208    let mut report = PromptReport {
209        template_path: template_path.clone(),
210        mode: mode_label(mode),
211        renders,
212        runs: BTreeMap::new(),
213        judge: None,
214        context_eval: None,
215    };
216
217    if !args.context_fixture.is_empty() {
218        match evaluate_context_fixtures(
219            &args.context_fixture,
220            &fleet,
221            &template_source,
222            &template_path,
223            bindings.as_ref(),
224        ) {
225            Ok(context_eval) => report.context_eval = Some(context_eval),
226            Err(error) => {
227                eprintln!("error: {error}");
228                return Err(1);
229            }
230        }
231    }
232
233    if matches!(mode, EvalPromptMode::Run | EvalPromptMode::Judge) {
234        let bindings_text = args
235            .bindings
236            .as_ref()
237            .map(|p| p.to_string_lossy().to_string());
238        let outputs = execute_runs(
239            &report.renders,
240            &template_path,
241            bindings_text.as_deref(),
242            args.max_tokens,
243            args.max_concurrent,
244            args.fail_on_unauthorized,
245        )
246        .await;
247        match outputs {
248            Ok(map) => report.runs = map,
249            Err(code) => return Err(code),
250        }
251    }
252
253    if matches!(mode, EvalPromptMode::Judge) {
254        match execute_judge(
255            &report,
256            args.judge_template.as_deref(),
257            &args.judge_model,
258            args.max_tokens,
259        )
260        .await
261        {
262            Ok(judge) => report.judge = Some(judge),
263            Err(code) => return Err(code),
264        }
265    }
266
267    Ok(report)
268}
269
270/// Dispatch to the embedded `cli/eval/prompt` script for the rendering
271/// pass. The script reads the pre-serialised report from
272/// [`PROMPT_REPORT_ENV`] and picks a formatter based on
273/// [`PROMPT_OUTPUT_ENV`], always emitting the payload to stdout.
274///
275/// `--out-file` is honored on the Rust side (capture-mode dispatch)
276/// rather than in the script: the script runs inside the standard
277/// `harn run` sandbox, where `harness.fs.write_text` is constrained to
278/// `workspace_roots`.
279///
280/// **Concurrency.** Held under [`DISPATCH_RENDER_LOCK`] so concurrent
281/// in-process callers don't race on the global env vars that hand the
282/// report to the script. See the lock's docstring for the trade-off
283/// rationale.
284async fn dispatch_render(
285    report: &PromptReport,
286    output: EvalPromptOutput,
287    out_file: Option<&Path>,
288) -> Result<(), i32> {
289    let report_json = match serde_json::to_string(report) {
290        Ok(json) => json,
291        Err(error) => {
292            eprintln!("error: failed to serialise PromptReport for dispatch: {error}");
293            return Err(1);
294        }
295    };
296    let output_label = match output {
297        EvalPromptOutput::Terminal => "terminal",
298        EvalPromptOutput::Json => "json",
299        EvalPromptOutput::Html => "html",
300    };
301
302    let _dispatch_guard = DISPATCH_RENDER_LOCK.lock().await;
303    let _report_guard = ScopedEnvVar::set(PROMPT_REPORT_ENV, &report_json);
304    let _output_guard = ScopedEnvVar::set(PROMPT_OUTPUT_ENV, output_label);
305
306    // We intentionally don't forward `--json` to the wedge: the script
307    // already knows the output format via PROMPT_OUTPUT_ENV. The wedge's
308    // `--json` env (`HARN_OUTPUT_JSON`) is a separate convention for
309    // commands that have only two modes (human / json envelope); this
310    // script has three (terminal / json-report / html).
311    let outcome = dispatch::run_embedded_script("eval/prompt", Vec::new(), false).await;
312
313    // Always flush the script's stderr to the real terminal, regardless
314    // of out_file handling, so error/warning lines surface.
315    if !outcome.stderr.is_empty() {
316        use std::io::Write as _;
317        let _ = std::io::stderr().write_all(outcome.stderr.as_bytes());
318    }
319
320    if outcome.exit_code != 0 {
321        // Surface the script's stdout too on failure — the script's
322        // diagnostic posture is to use stderr for messages but a future
323        // contributor might trip and emit a partial payload to stdout
324        // before exiting. Better to surface that than silently drop it.
325        if !outcome.stdout.is_empty() {
326            use std::io::Write as _;
327            let _ = std::io::stdout().write_all(outcome.stdout.as_bytes());
328        }
329        return Err(outcome.exit_code);
330    }
331
332    match out_file {
333        Some(path) => {
334            if let Err(error) = fs::write(path, &outcome.stdout) {
335                eprintln!("error: failed to write {}: {error}", path.display());
336                return Err(1);
337            }
338            eprintln!("wrote {}", path.display());
339        }
340        None => {
341            use std::io::Write as _;
342            let _ = std::io::stdout().write_all(outcome.stdout.as_bytes());
343        }
344    }
345    Ok(())
346}
347
348/// Compute the post-render exit code shared by host aggregation and
349/// the dispatch renderer.
350fn post_render_exit_code(report: &PromptReport) -> i32 {
351    let context_eval_active = report.context_eval.is_some();
352    if !context_eval_active && report.renders.iter().any(|r| r.error.is_some()) {
353        return 1;
354    }
355    if report.runs.values().any(|r| r.error.is_some()) {
356        return 1;
357    }
358    if report
359        .context_eval
360        .as_ref()
361        .is_some_and(|context_eval| !context_eval.pass)
362    {
363        return 1;
364    }
365    0
366}
367
368fn mode_label(mode: EvalPromptMode) -> &'static str {
369    match mode {
370        EvalPromptMode::Render => "render",
371        EvalPromptMode::Run => "run",
372        EvalPromptMode::Judge => "judge",
373    }
374}
375
376/// Resolve the fleet entries from `--fleet` / `--fleet-name`, expanding
377/// aliases through `llm_config::resolve_model_info` so downstream code
378/// works with `(provider, model)` pairs regardless of input shape.
379fn resolve_fleet(args: &EvalPromptArgs, template_path: &Path) -> Result<Vec<FleetEntry>, String> {
380    let raw_selectors: Vec<String> = if let Some(name) = args.fleet_name.as_ref() {
381        let cfg = project_config::load_for_path(template_path)
382            .map_err(|error| format!("failed to load harn.toml: {error}"))?;
383        let Some(fleet) = cfg.eval.fleets.get(name) else {
384            let available: Vec<&str> = cfg.eval.fleets.keys().map(|s| s.as_str()).collect();
385            return Err(if available.is_empty() {
386                format!("unknown fleet `{name}` — no `[eval.fleets.*]` entries found in harn.toml")
387            } else {
388                format!(
389                    "unknown fleet `{name}` — known fleets: {}",
390                    available.join(", "),
391                )
392            });
393        };
394        fleet.models.clone()
395    } else {
396        args.fleet.clone()
397    };
398
399    let mut seen = BTreeSet::new();
400    let mut out = Vec::new();
401    for selector in raw_selectors {
402        let trimmed = selector.trim();
403        if trimmed.is_empty() {
404            continue;
405        }
406        if !seen.insert(trimmed.to_string()) {
407            continue;
408        }
409        let resolved = llm_config::resolve_model_info(trimmed);
410        out.push(FleetEntry {
411            selector: trimmed.to_string(),
412            provider: resolved.provider,
413            model: resolved.id,
414        });
415    }
416    Ok(out)
417}
418
419#[derive(Debug, Clone)]
420pub(crate) struct FleetEntry {
421    pub(crate) selector: String,
422    pub(crate) provider: String,
423    pub(crate) model: String,
424}
425
426fn load_bindings(path: Option<&Path>) -> Result<Option<VmValue>, String> {
427    let Some(path) = path else {
428        return Ok(None);
429    };
430    let raw = fs::read_to_string(path)
431        .map_err(|error| format!("failed to read bindings {}: {error}", path.display()))?;
432    let json: JsonValue = serde_json::from_str(&raw)
433        .map_err(|error| format!("failed to parse bindings {}: {error}", path.display()))?;
434    if !json.is_object() {
435        return Err(format!(
436            "bindings file {} must be a JSON object at the top level",
437            path.display(),
438        ));
439    }
440    Ok(Some(harn_vm::json_to_vm_value(&json)))
441}
442
443fn render_fleet(
444    fleet: &[FleetEntry],
445    template_source: &str,
446    template_path: &Path,
447    bindings: Option<&VmValue>,
448) -> Vec<ModelRender> {
449    let base = template_path.parent();
450    let bindings_dict: Option<harn_vm::value::DictMap> = bindings.and_then(|v| match v {
451        VmValue::Dict(dict) => Some(dict.as_ref().clone()),
452        _ => None,
453    });
454
455    fleet
456        .iter()
457        .map(|entry| {
458            // Resolve a fresh capability snapshot per model so the
459            // `llm` scope inside the template reflects the model under
460            // evaluation rather than a stale parent frame.
461            let ctx = LlmRenderContext::resolve(&entry.provider, &entry.model);
462            let family = ctx.family.clone();
463            let capabilities = vm_value_to_json(&ctx.capabilities);
464            let auth_available = harn_vm::llm::provider_auth_status(&entry.provider).available;
465
466            let result = {
467                let _guard = LlmRenderContextGuard::enter(ctx);
468                render_template_to_string_with_branch_trace(
469                    template_source,
470                    bindings_dict.as_ref(),
471                    base,
472                    Some(template_path),
473                )
474            };
475
476            let (rendered, branches, error) = match result {
477                Ok((text, trace)) => (
478                    Some(text),
479                    trace.iter().map(TemplateBranch::from).collect(),
480                    None,
481                ),
482                Err(message) => (None, Vec::new(), Some(message)),
483            };
484
485            ModelRender {
486                selector: entry.selector.clone(),
487                provider: entry.provider.clone(),
488                model: entry.model.clone(),
489                family,
490                capabilities,
491                rendered,
492                error,
493                branches,
494                auth_available,
495            }
496        })
497        .collect()
498}
499
500fn vm_value_to_json(value: &VmValue) -> JsonValue {
501    match value {
502        VmValue::Nil => JsonValue::Null,
503        VmValue::Bool(b) => JsonValue::Bool(*b),
504        VmValue::Int(i) => JsonValue::Number((*i).into()),
505        VmValue::Float(f) => serde_json::Number::from_f64(*f)
506            .map(JsonValue::Number)
507            .unwrap_or(JsonValue::Null),
508        // Decimal as a string to preserve exact precision (was the `<decimal>`
509        // sentinel, which dropped the value).
510        VmValue::Decimal(d) => JsonValue::String(d.to_string()),
511        VmValue::String(s) => JsonValue::String(s.to_string()),
512        VmValue::List(items) => JsonValue::Array(items.iter().map(vm_value_to_json).collect()),
513        VmValue::Dict(d) => {
514            let mut map = serde_json::Map::new();
515            for (k, v) in d.iter() {
516                map.insert(k.to_string(), vm_value_to_json(v));
517            }
518            JsonValue::Object(map)
519        }
520        // Anything else (closures, handles, etc.) is unlikely to appear
521        // in a capability snapshot but we surface a sentinel so callers
522        // don't crash on a future-added kind.
523        other => JsonValue::String(format!("<{}>", other.type_name())),
524    }
525}
526
527// ─── Run mode ──────────────────────────────────────────────────────────────
528
529async fn execute_runs(
530    renders: &[ModelRender],
531    template_path: &Path,
532    bindings_path: Option<&str>,
533    max_tokens: i64,
534    max_concurrent: usize,
535    fail_on_unauthorized: bool,
536) -> Result<BTreeMap<String, ModelRunResult>, i32> {
537    let mut runnable: Vec<&ModelRender> = Vec::new();
538    let mut runs: BTreeMap<String, ModelRunResult> = BTreeMap::new();
539    let mock_active = std::env::var("HARN_LLM_PROVIDER")
540        .map(|v| v == "mock")
541        .unwrap_or(false);
542    for render in renders {
543        if render.error.is_some() {
544            runs.insert(
545                render.selector.clone(),
546                ModelRunResult {
547                    error: Some("template render failed — see render section".to_string()),
548                    ..Default::default()
549                },
550            );
551            continue;
552        }
553        if !mock_active && !render.auth_available {
554            if fail_on_unauthorized {
555                eprintln!(
556                    "error: provider `{}` (for `{}`) has no credentials configured",
557                    render.provider, render.selector,
558                );
559                return Err(1);
560            }
561            eprintln!(
562                "warn: provider `{}` (for `{}`) unauthenticated — skipping run",
563                render.provider, render.selector,
564            );
565            runs.insert(
566                render.selector.clone(),
567                ModelRunResult {
568                    skipped: true,
569                    ..Default::default()
570                },
571            );
572            continue;
573        }
574        runnable.push(render);
575    }
576    if runnable.is_empty() {
577        return Ok(runs);
578    }
579
580    let script = build_run_script(
581        &runnable,
582        template_path,
583        bindings_path,
584        max_tokens,
585        max_concurrent.max(1),
586    );
587    let outputs = match invoke_harn_script(&script).await {
588        Ok(out) => out,
589        Err(err) => {
590            eprintln!("error: run-mode harn script failed: {err}");
591            return Err(1);
592        }
593    };
594    for line in outputs.lines() {
595        if line.trim().is_empty() {
596            continue;
597        }
598        let entry: HarnRunLine = match serde_json::from_str(line) {
599            Ok(e) => e,
600            Err(_) => continue,
601        };
602        let result = ModelRunResult {
603            response: entry.response,
604            error: entry.error,
605            skipped: false,
606        };
607        runs.insert(entry.selector, result);
608    }
609    Ok(runs)
610}
611
612#[derive(Debug, serde::Deserialize)]
613struct HarnRunLine {
614    selector: String,
615    #[serde(default)]
616    response: Option<String>,
617    #[serde(default)]
618    error: Option<String>,
619}
620
621fn build_run_script(
622    fleet: &[&ModelRender],
623    template_path: &Path,
624    bindings_path: Option<&str>,
625    max_tokens: i64,
626    _max_concurrent: usize,
627) -> String {
628    // Sequential dispatch: `llm_call` pushes its own `LlmRenderContext`
629    // guard for the duration of each call and asserts strict LIFO drop
630    // ordering against the shared thread-local template stack. Running
631    // multiple `llm_call`s concurrently on the VM's LocalSet interleaves
632    // those pushes and trips the guard. `--max-concurrent` is accepted
633    // for forward compatibility with a future per-provider parallelism
634    // path; today it is a no-op.
635    let template_path_lit = json_string_literal(&template_path.to_string_lossy());
636    let bindings_load = if let Some(path) = bindings_path {
637        let path_lit = json_string_literal(path);
638        format!("    const bindings = json_parse(harness.fs.read_text({path_lit}))\n")
639    } else {
640        "    const bindings = {}\n".to_string()
641    };
642    let fleet_items: Vec<String> = fleet
643        .iter()
644        .map(|r| {
645            format!(
646                "        {{selector: {}, provider: {}, model: {}}}",
647                json_string_literal(&r.selector),
648                json_string_literal(&r.provider),
649                json_string_literal(&r.model),
650            )
651        })
652        .collect();
653    let fleet_list = if fleet_items.is_empty() {
654        "[]".to_string()
655    } else {
656        format!("[\n{}\n    ]", fleet_items.join(",\n"))
657    };
658
659    format!(
660        "pipeline main(harness: Harness) {{\n\
661{bindings_load}\
662    const fleet = {fleet_list}\n\
663    for entry in fleet {{\n\
664        const pushed = harness.agent.push_llm_render_context(entry.provider, entry.model)\n\
665        const rendered = render({template_path_lit}, bindings)\n\
666        try {{\n\
667            const resp = harness.llm.call(rendered, nil, {{\n\
668                provider: entry.provider,\n\
669                model: entry.model,\n\
670                max_tokens: {max_tokens}\n\
671            }})\n\
672            harness.stdio.println(json_stringify({{selector: entry.selector, response: resp}}))\n\
673        }} catch (err) {{\n\
674            harness.stdio.println(json_stringify({{selector: entry.selector, error: to_string(err)}}))\n\
675        }}\n\
676        if pushed {{\n\
677            harness.agent.pop_llm_render_context()\n\
678        }}\n\
679    }}\n\
680}}\n",
681    )
682}
683
684async fn invoke_harn_script(script: &str) -> Result<String, String> {
685    use std::collections::HashSet;
686    let tmp = tempfile::Builder::new()
687        .prefix("harn-eval-prompt-")
688        .suffix(".harn")
689        .tempfile()
690        .map_err(|e| format!("tempfile: {e}"))?;
691    fs::write(tmp.path(), script).map_err(|e| format!("write tempfile: {e}"))?;
692
693    let outcome = crate::commands::run::execute_run(
694        &tmp.path().to_string_lossy(),
695        false,
696        HashSet::new(),
697        Vec::new(),
698        Vec::new(),
699        crate::commands::run::CliLlmMockMode::Off,
700        None,
701        crate::commands::run::RunProfileOptions::default(),
702    )
703    .await;
704
705    if outcome.exit_code != 0 {
706        return Err(format!(
707            "harn run exited {} — stderr:\n{}",
708            outcome.exit_code, outcome.stderr,
709        ));
710    }
711    Ok(outcome.stdout)
712}
713
714fn json_string_literal(value: &str) -> String {
715    serde_json::Value::String(value.to_string()).to_string()
716}
717
718// ─── Judge mode ────────────────────────────────────────────────────────────
719
720const DEFAULT_JUDGE_TEMPLATE: &str = r#"You are a strict-equivalence judge for prompt-engineering output.
721
722The same logical prompt was rendered for several models and each model returned a response. Your task is to determine whether the responses are *semantically equivalent* — the wire envelope may differ (XML vs markdown vs native tool calls), but the user-facing intent and information content should be the same.
723
724Source prompt template (for context):
725
726{{ template_source }}
727
728Per-model responses:
729{{ for entry in entries }}
730---
731model: {{ entry.selector }} (provider={{ entry.provider }}, family={{ entry.family }})
732
733rendered prompt:
734{{ entry.rendered }}
735
736response:
737{{ entry.response }}
738{{ end }}
739
740Reply with a short JSON object on a single line of the form:
741{"equivalent": true|false, "differences": ["..."], "notes": "..."}
742"#;
743
744async fn execute_judge(
745    report: &PromptReport,
746    judge_template: Option<&Path>,
747    judge_model: &str,
748    max_tokens: i64,
749) -> Result<JudgeReport, i32> {
750    let judge_template_body = match judge_template {
751        Some(path) => fs::read_to_string(path).map_err(|error| {
752            eprintln!(
753                "error: failed to read judge template {}: {error}",
754                path.display()
755            );
756            1i32
757        })?,
758        None => DEFAULT_JUDGE_TEMPLATE.to_string(),
759    };
760    let prompt_source = fs::read_to_string(&report.template_path).unwrap_or_default();
761
762    let entries: Vec<JudgeEntry> = report
763        .renders
764        .iter()
765        .map(|r| JudgeEntry {
766            selector: r.selector.clone(),
767            provider: r.provider.clone(),
768            family: r.family.clone(),
769            rendered: r.rendered.clone().unwrap_or_default(),
770            response: report
771                .runs
772                .get(&r.selector)
773                .and_then(|run| run.response.clone())
774                .unwrap_or_else(|| "<no response>".to_string()),
775        })
776        .collect();
777
778    let entries_json = serde_json::to_string(&entries).unwrap_or_else(|_| "[]".to_string());
779    let template_lit = json_string_literal(&judge_template_body);
780    let entries_lit = json_string_literal(&entries_json);
781    let source_lit = json_string_literal(&prompt_source);
782
783    let resolved_judge = llm_config::resolve_model_info(judge_model);
784    let provider_lit = json_string_literal(&resolved_judge.provider);
785    let model_lit = json_string_literal(&resolved_judge.id);
786
787    let script = format!(
788        "pipeline main(harness: Harness) {{\n\
789    const entries = json_parse({entries_lit})\n\
790    const prompt = harness.fs.render_template({template_lit}, {{\n\
791        template_source: {source_lit},\n\
792        entries: entries\n\
793    }})\n\
794    const verdict = harness.llm.call(prompt, nil, {{\n\
795        provider: {provider_lit},\n\
796        model: {model_lit},\n\
797        max_tokens: {max_tokens}\n\
798    }})\n\
799    harness.stdio.println(verdict)\n\
800}}\n",
801    );
802
803    let verdict = match invoke_harn_script(&script).await {
804        Ok(out) => out.trim().to_string(),
805        Err(err) => {
806            eprintln!("error: judge-mode harn script failed: {err}");
807            return Err(1);
808        }
809    };
810
811    Ok(JudgeReport {
812        judge_model: judge_model.to_string(),
813        verdict,
814    })
815}
816
817#[derive(Debug, serde::Serialize)]
818struct JudgeEntry {
819    selector: String,
820    provider: String,
821    family: String,
822    rendered: String,
823    response: String,
824}
825
826#[cfg(test)]
827mod tests {
828    use super::*;
829
830    #[test]
831    fn fleet_resolution_dedupes_and_expands_aliases() {
832        let args = EvalPromptArgs {
833            file: PathBuf::from("/tmp/missing.harn.prompt"),
834            fleet: vec![
835                "claude-3-5-sonnet".to_string(),
836                "claude-3-5-sonnet".to_string(),
837                "ollama:qwen3.5".to_string(),
838            ],
839            fleet_name: None,
840            bindings: None,
841            context_fixture: Vec::new(),
842            mode: EvalPromptMode::Render,
843            output: EvalPromptOutput::Terminal,
844            out_file: None,
845            max_concurrent: 1,
846            judge_template: None,
847            judge_model: "claude-opus-4-7".to_string(),
848            max_tokens: 256,
849            fail_on_unauthorized: false,
850        };
851        let entries = resolve_fleet(&args, Path::new("/tmp")).expect("resolve");
852        assert_eq!(entries.len(), 2);
853        assert_eq!(entries[0].selector, "claude-3-5-sonnet");
854        assert_eq!(entries[1].selector, "ollama:qwen3.5");
855        assert_eq!(entries[1].provider, "ollama");
856        assert_eq!(entries[1].model, "qwen3.5");
857    }
858
859    #[test]
860    fn render_fleet_emits_per_capability_envelope() {
861        let template = "{{ if llm.capabilities.native_tools }}native{{ else }}text{{ end }}\n";
862        let fleet = vec![FleetEntry {
863            selector: "ollama:qwen3.5".to_string(),
864            provider: "ollama".to_string(),
865            model: "qwen3.5".to_string(),
866        }];
867        let renders = render_fleet(&fleet, template, Path::new("/tmp/x.harn.prompt"), None);
868        assert_eq!(renders.len(), 1);
869        assert!(renders[0].error.is_none(), "{:?}", renders[0].error);
870        assert!(renders[0].rendered.is_some());
871    }
872}