1use 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
47const PROMPT_REPORT_ENV: &str = "HARN_EVAL_PROMPT_REPORT_JSON";
53
54const PROMPT_OUTPUT_ENV: &str = "HARN_EVAL_PROMPT_OUTPUT";
58
59static DISPATCH_RENDER_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
71
72#[derive(Debug, Clone, serde::Serialize)]
74struct ModelRender {
75 selector: String,
77 provider: String,
78 model: String,
79 family: String,
80 capabilities: JsonValue,
81 rendered: Option<String>,
83 error: Option<String>,
84 #[serde(default, skip_serializing_if = "Vec::is_empty")]
85 branches: Vec<TemplateBranch>,
86 auth_available: bool,
90}
91
92#[derive(Debug, Clone, serde::Serialize, Default)]
94struct ModelRunResult {
95 response: Option<String>,
96 error: Option<String>,
97 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 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
160async 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
270async 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 let outcome = dispatch::run_embedded_script("eval/prompt", Vec::new(), false).await;
312
313 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 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
348fn 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
376fn 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 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 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 other => JsonValue::String(format!("<{}>", other.type_name())),
524 }
525}
526
527async 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 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
718const 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}