Skip to main content

eval_magic/adapters/
descriptor_adapter.rs

1//! The generic descriptor-backed [`HarnessAdapter`]: one implementation
2//! serving every harness, reading declarative values from a validated
3//! [`HarnessDescriptor`] and dispatching code-backed features through the
4//! named capabilities in [`super::capabilities`].
5
6use std::io;
7use std::path::{Path, PathBuf};
8use std::time::Duration;
9
10use regex::Regex;
11
12use crate::core::{AvailableSkill, HarnessRunCapabilities, ToolInvocation};
13use crate::sandbox::GuardMarker;
14
15use super::TranscriptSummary;
16use super::cli_command::{
17    render_cli_model_arg, render_judge_dispatch_recipe, render_parallel_dispatch_recipe,
18};
19use super::descriptor::{HarnessDescriptor, render_staged_slug, stage_name_error, subst};
20use super::harness::{
21    CliDispatchContext, CliJudgeContext, CliManifestContext, HarnessAdapter, ToolVocabulary,
22};
23use super::skill_shadow::PluginShadowReport;
24use super::skills_block::{DEFAULT_HEADER, DEFAULT_ITEM, render_skills_block};
25
26/// A [`HarnessAdapter`] backed by a validated [`HarnessDescriptor`].
27#[derive(Debug)]
28pub struct DescriptorAdapter {
29    descriptor: HarnessDescriptor,
30    /// Compiled `staging.stage_name_pattern`; validated to compile at load
31    /// time, compiled once here.
32    stage_name_regex: Option<Regex>,
33}
34
35impl DescriptorAdapter {
36    /// Wrap a descriptor that already passed
37    /// [`load_descriptor`](super::descriptor::load_descriptor) (which proves
38    /// the stage-name pattern compiles).
39    pub fn from_descriptor(descriptor: HarnessDescriptor) -> Self {
40        let stage_name_regex = descriptor
41            .staging
42            .stage_name_pattern
43            .as_deref()
44            .map(|pattern| {
45                Regex::new(pattern).expect("stage_name_pattern is validated at descriptor load")
46            });
47        DescriptorAdapter {
48            descriptor,
49            stage_name_regex,
50        }
51    }
52
53    /// The resolved descriptor behind this adapter — the `harness show`/`list`
54    /// data source.
55    pub(crate) fn descriptor(&self) -> &HarnessDescriptor {
56        &self.descriptor
57    }
58
59    /// The single-dispatch command: the exec template with `{model_arg}` /
60    /// `{guard_args}` filled for this run. Empty when no template is wired.
61    fn exec_command(&self, guard: bool, agent_model: Option<&str>) -> String {
62        let Some(template) = &self.descriptor.dispatch.exec_template else {
63            return String::new();
64        };
65        let model_arg = render_cli_model_arg(self.model_flag(), agent_model);
66        subst(
67            template,
68            &[
69                ("model_arg", &model_arg),
70                ("guard_args", self.guard_args(guard)),
71            ],
72        )
73    }
74
75    /// The `{guard_args}` value for this run: the descriptor's fragment when
76    /// the guard is armed, empty otherwise.
77    fn guard_args(&self, guard: bool) -> &str {
78        if guard {
79            self.descriptor.dispatch.guard_args.as_deref().unwrap_or("")
80        } else {
81            ""
82        }
83    }
84
85    fn model_flag(&self) -> Option<&str> {
86        self.descriptor.model.as_ref().map(|m| m.flag.as_str())
87    }
88}
89
90impl HarnessAdapter for DescriptorAdapter {
91    fn label(&self) -> String {
92        self.descriptor.label.clone()
93    }
94
95    fn skills_dir(&self, repo_root: &Path) -> Option<PathBuf> {
96        self.descriptor.skills_dir.as_ref().map(|dir| {
97            dir.split('/')
98                .fold(repo_root.to_path_buf(), |path, segment| path.join(segment))
99        })
100    }
101
102    fn run_capabilities(&self) -> HarnessRunCapabilities {
103        HarnessRunCapabilities {
104            supports_guard: self.descriptor.run.supports_guard,
105            supports_bootstrap_with_no_stage: self.descriptor.run.supports_bootstrap_with_no_stage,
106            supports_stage_name_with_no_stage: self
107                .descriptor
108                .run
109                .supports_stage_name_with_no_stage,
110        }
111    }
112
113    fn config_dir_names(&self) -> Vec<String> {
114        self.descriptor.config_dirs.clone()
115    }
116
117    fn tool_vocabulary(&self) -> ToolVocabulary {
118        ToolVocabulary {
119            write_tools: self.descriptor.tools.write.clone(),
120            patch_tools: self.descriptor.tools.patch.clone(),
121            shell_tools: self.descriptor.tools.shell.clone(),
122            read_tools: self.descriptor.tools.read.clone(),
123        }
124    }
125
126    fn staged_slug(
127        &self,
128        prefix: &str,
129        iteration: u32,
130        condition: &str,
131        skill_name: &str,
132    ) -> String {
133        render_staged_slug(
134            &self.descriptor.staging,
135            prefix,
136            iteration,
137            condition,
138            skill_name,
139        )
140    }
141
142    fn validate_stage_name(&self, name: &str) -> Result<(), String> {
143        match stage_name_error(
144            &self.descriptor.staging,
145            self.stage_name_regex.as_ref(),
146            name,
147        ) {
148            Some(message) => Err(message),
149            None => Ok(()),
150        }
151    }
152
153    fn rewrites_frontmatter_name(&self) -> bool {
154        self.descriptor.staging.rewrites_frontmatter_name
155    }
156
157    fn advertises_staged_slug_name(&self) -> bool {
158        self.descriptor.staging.advertises_staged_slug_name
159    }
160
161    fn render_available_skills_block(&self, skills: &[AvailableSkill]) -> String {
162        match &self.descriptor.skills_block {
163            Some(block) => render_skills_block(&block.header, &block.item, &block.footer, skills),
164            None => render_skills_block(DEFAULT_HEADER, DEFAULT_ITEM, "", skills),
165        }
166    }
167
168    fn skill_surface_phrase(&self) -> String {
169        self.descriptor
170            .staging
171            .surface_phrase
172            .clone()
173            .unwrap_or_else(|| "as a discoverable skill".to_string())
174    }
175
176    fn skill_unresolved_phrase(&self) -> String {
177        self.descriptor
178            .staging
179            .unresolved_phrase
180            .clone()
181            .unwrap_or_else(|| "If the staged skill cannot be resolved".to_string())
182    }
183
184    fn cli_events_filename(&self) -> Option<String> {
185        self.descriptor
186            .transcript
187            .as_ref()
188            .map(|t| t.events_filename.clone())
189    }
190
191    fn parse_cli_events(&self, path: &Path) -> io::Result<Vec<ToolInvocation>> {
192        match &self.descriptor.transcript {
193            Some(transcript) => transcript.parse(path),
194            None => Err(io::Error::new(
195                io::ErrorKind::Unsupported,
196                format!(
197                    "transcript ingest is not wired for the {} harness",
198                    self.label()
199                ),
200            )),
201        }
202    }
203
204    fn parse_cli_events_full(&self, path: &Path) -> io::Result<TranscriptSummary> {
205        match &self.descriptor.transcript {
206            Some(transcript) => transcript.parse_full(path),
207            None => Err(io::Error::new(
208                io::ErrorKind::Unsupported,
209                format!(
210                    "transcript ingest is not wired for the {} harness",
211                    self.label()
212                ),
213            )),
214        }
215    }
216
217    fn transcript_skill_invocation(&self) -> Option<(String, String)> {
218        match &self.descriptor.transcript {
219            Some(t) if !t.surfaces_skill_invocation => None,
220            Some(t) => Some((
221                t.skill_tool.clone().unwrap_or_else(|| "Skill".to_string()),
222                t.skill_arg.clone().unwrap_or_else(|| "skill".to_string()),
223            )),
224            // No transcript table: the default signature (the meta-check only
225            // fires when a run record carries invocations anyway).
226            None => Some(("Skill".to_string(), "skill".to_string())),
227        }
228    }
229
230    fn cli_model_flag(&self) -> Option<String> {
231        self.model_flag().map(str::to_string)
232    }
233
234    fn install_guard(
235        &self,
236        stage_root: &Path,
237        guard_exe: &Path,
238        ttl: Option<Duration>,
239    ) -> io::Result<PathBuf> {
240        match &self.descriptor.guard {
241            Some(guard) => {
242                let skills_dir = self
243                    .skills_dir(stage_root)
244                    .expect("descriptor validation pairs [guard] with skills_dir");
245                super::guard::install_guard(guard, &skills_dir, stage_root, guard_exe, ttl)
246            }
247            None => Err(io::Error::new(
248                io::ErrorKind::Unsupported,
249                format!("--guard is not supported for the {} harness", self.label()),
250            )),
251        }
252    }
253
254    fn guard_armed_message(&self) -> Option<String> {
255        self.descriptor
256            .guard
257            .as_ref()
258            .map(|g| g.armed_message.clone())
259    }
260
261    fn guard_verdict(&self, payload: &str, marker: Option<GuardMarker>) -> Option<String> {
262        let guard = self.descriptor.guard.as_ref()?;
263        super::guard::guard_verdict(guard, payload, marker)
264    }
265
266    fn guard_hook_cleanup_dir(&self, stage_root: &Path) -> Option<PathBuf> {
267        self.descriptor.guard.as_ref().and_then(|g| {
268            super::guard::hook_cleanup_dir(g, self.descriptor.skills_dir.as_deref(), stage_root)
269        })
270    }
271
272    fn detect_shadowed_skills(
273        &self,
274        scan_root: &Path,
275        staged_skill_names: &[&str],
276    ) -> Option<PluginShadowReport> {
277        self.descriptor
278            .shadow
279            .as_ref()
280            .and_then(|s| s.preflight.detect(scan_root, staged_skill_names))
281    }
282
283    fn format_shadow_banner(&self, report: &PluginShadowReport) -> String {
284        self.descriptor.shadow.as_ref().map_or_else(
285            || super::skill_shadow::format_shadow_banner(report),
286            |shadow| shadow.preflight.format_banner(report),
287        )
288    }
289
290    fn shadow_validity_warnings(&self, report: &PluginShadowReport) -> Vec<String> {
291        self.descriptor.shadow.as_ref().map_or_else(
292            || super::skill_shadow::shadow_validity_warnings(report),
293            |shadow| shadow.preflight.validity_warnings(report),
294        )
295    }
296
297    fn has_dispatch_recipes(&self) -> bool {
298        self.descriptor.dispatch.exec_template.is_some()
299    }
300
301    fn cli_next_steps(&self, ctx: CliDispatchContext<'_>) -> String {
302        let ingest_line = format!(
303            "ingest{} --iteration {} --harness {}",
304            ctx.target_args, ctx.iteration, self.descriptor.label
305        );
306        let Some(template) = &self.descriptor.dispatch.next_steps_template else {
307            // Generic fallbacks: a descriptor with just an exec template still
308            // earns a copy-pasteable recipe; a baseline descriptor gets the
309            // harness-agnostic handoff.
310            return match &self.descriptor.dispatch.exec_template {
311                Some(_) => format!(
312                    "\nNext: iterate the tasks[] array in dispatch.json and dispatch each task \
313                     with:\n{}\nThen run `{ingest_line}`.",
314                    self.exec_command(ctx.guard, ctx.agent_model)
315                ),
316                None => format!(
317                    "\nNext: read dispatch-manifest.md and dispatch each task through your \
318                     harness's one-shot CLI from the task's eval_root, saving the agent's \
319                     final reply to outputs/final-message.md.\nThen run `{ingest_line}`."
320                ),
321            };
322        };
323        let exec_command = self.exec_command(ctx.guard, ctx.agent_model);
324        let iteration = ctx.iteration.to_string();
325        let model_note = if ctx.agent_model.is_some() {
326            self.descriptor.dispatch.model_note.as_deref().unwrap_or("")
327        } else {
328            ""
329        };
330        subst(
331            template,
332            &[
333                ("exec_command", &exec_command),
334                ("target_args", ctx.target_args),
335                ("iteration", &iteration),
336                ("model_note", model_note),
337            ],
338        )
339    }
340
341    fn cli_manifest_section(&self, ctx: CliManifestContext<'_>) -> Option<Vec<String>> {
342        let Some(template) = self.descriptor.dispatch.manifest_template.as_ref() else {
343            // An exec template without a manifest template still earns a
344            // generic recipe section; without either, the manifest's shared
345            // header text already covers the baseline handoff.
346            self.descriptor.dispatch.exec_template.as_ref()?;
347            let exec_command = self.exec_command(ctx.guard, ctx.agent_model);
348            return Some(
349                format!(
350                    "## Dispatch recipe\n\nFrom each task's `eval_root`, dispatch with:\n\
351                     {exec_command}\n\nEnsure the agent's final reply lands in the task's \
352                     `outputs/final-message.md` (capture it yourself if the command does not \
353                     write it).\n"
354                )
355                .split('\n')
356                .map(String::from)
357                .collect(),
358            );
359        };
360        let exec_command = self.exec_command(ctx.guard, ctx.agent_model);
361        let parallel_recipe = match &self.descriptor.dispatch.parallel_command_template {
362            Some(block_template) => {
363                let model_arg = render_cli_model_arg(self.model_flag(), ctx.agent_model);
364                render_parallel_dispatch_recipe(&subst(
365                    block_template,
366                    &[
367                        ("model_arg", &model_arg),
368                        ("guard_args", self.guard_args(ctx.guard)),
369                    ],
370                ))
371            }
372            None => String::new(),
373        };
374        Some(
375            subst(
376                template,
377                &[
378                    ("exec_command", &exec_command),
379                    ("parallel_recipe", &parallel_recipe),
380                ],
381            )
382            .split('\n')
383            .map(String::from)
384            .collect(),
385        )
386    }
387
388    fn cli_judge_next_steps(&self, ctx: CliJudgeContext<'_>) -> Option<String> {
389        let template = self.descriptor.dispatch.judge_command_template.as_ref()?;
390        let cwd = ctx.iteration_dir.display().to_string();
391        let command_line = subst(
392            template,
393            &[("cwd", &cwd), ("guard_args", self.guard_args(ctx.guard))],
394        );
395        Some(render_judge_dispatch_recipe(
396            &command_line,
397            // Both guaranteed by descriptor validation when the template is set.
398            self.model_flag().unwrap_or_default(),
399            self.descriptor
400                .dispatch
401                .capture_prefix
402                .as_deref()
403                .unwrap_or_default(),
404        ))
405    }
406}
407
408#[cfg(test)]
409mod tests {
410    use std::path::Path;
411
412    use crate::adapters::harness::{CliDispatchContext, CliJudgeContext};
413    use crate::adapters::registry::adapter_for;
414    use crate::core::{AvailableSkill, Harness};
415
416    fn skill(name: &str, description: &str) -> AvailableSkill {
417        AvailableSkill {
418            name: name.into(),
419            path: format!("/x/{name}/SKILL.md"),
420            description: description.into(),
421        }
422    }
423
424    fn next_steps(harness: Harness, agent_model: Option<&str>) -> String {
425        adapter_for(harness).cli_next_steps(CliDispatchContext {
426            guard: harness == Harness::resolve("codex").unwrap(),
427            target_args: " --skill-dir /tmp/skills --skill widget-skill",
428            iteration: 2,
429            agent_model,
430        })
431    }
432
433    fn adapter_from(toml_src: &str) -> super::DescriptorAdapter {
434        super::DescriptorAdapter::from_descriptor(
435            crate::adapters::descriptor::load_descriptor(toml_src, "test.toml").unwrap(),
436        )
437    }
438
439    #[test]
440    fn exec_template_alone_yields_generic_dispatch_recipes() {
441        use crate::adapters::harness::{CliManifestContext, HarnessAdapter};
442        let adapter = adapter_from(
443            "label = \"cool-custom-harness\"\n\n[dispatch]\nexec_template = \"cool-cli run <dispatch_prompt_path>{model_arg}\"\n",
444        );
445        let next = adapter.cli_next_steps(CliDispatchContext {
446            guard: false,
447            target_args: " --skill-dir /s --skill x",
448            iteration: 3,
449            agent_model: None,
450        });
451        assert!(next.contains("cool-cli run"), "{next}");
452        assert!(
453            next.contains(
454                "ingest --skill-dir /s --skill x --iteration 3 --harness cool-custom-harness"
455            ),
456            "{next}"
457        );
458        let manifest = adapter
459            .cli_manifest_section(CliManifestContext {
460                guard: false,
461                agent_model: None,
462            })
463            .expect("an exec template earns a generic manifest recipe")
464            .join("\n");
465        assert!(manifest.contains("cool-cli run"), "{manifest}");
466        assert!(manifest.contains("final-message.md"), "{manifest}");
467    }
468
469    #[test]
470    fn baseline_descriptor_yields_the_generic_handoff() {
471        use crate::adapters::harness::{CliManifestContext, HarnessAdapter};
472        let adapter = adapter_from("label = \"cool-custom-harness\"\n");
473        let next = adapter.cli_next_steps(CliDispatchContext {
474            guard: false,
475            target_args: " --skill x",
476            iteration: 1,
477            agent_model: None,
478        });
479        assert!(next.contains("one-shot CLI"), "{next}");
480        assert!(next.contains("outputs/final-message.md"), "{next}");
481        assert!(
482            next.contains("ingest --skill x --iteration 1 --harness cool-custom-harness"),
483            "{next}"
484        );
485        assert!(
486            adapter
487                .cli_manifest_section(CliManifestContext {
488                    guard: false,
489                    agent_model: None,
490                })
491                .is_none(),
492            "the manifest's generic header already covers the no-recipe baseline"
493        );
494    }
495
496    #[test]
497    fn exec_recipe_includes_model_only_when_declared() {
498        let with = next_steps(Harness::resolve("claude-code").unwrap(), Some("opus"));
499        assert!(with.contains("--model opus"), "{with}");
500        let without = next_steps(Harness::resolve("claude-code").unwrap(), None);
501        assert!(!without.contains("--model "), "{without}");
502    }
503
504    #[test]
505    fn codex_recipes_gate_hook_trust_on_guard() {
506        let guarded = next_steps(Harness::resolve("codex").unwrap(), Some("gpt-5-mini"));
507        assert!(
508            guarded.contains(
509                "codex --ask-for-approval never exec --cd <eval-root> --sandbox workspace-write --dangerously-bypass-hook-trust -m gpt-5-mini --json \\"
510            ),
511            "{guarded}"
512        );
513        let unguarded =
514            adapter_for(Harness::resolve("codex").unwrap()).cli_next_steps(CliDispatchContext {
515                guard: false,
516                target_args: "",
517                iteration: 2,
518                agent_model: None,
519            });
520        assert!(
521            !unguarded.contains("--dangerously-bypass-hook-trust"),
522            "{unguarded}"
523        );
524    }
525
526    #[test]
527    fn opencode_exec_recipe_carries_dir_auto_and_the_model_flag() {
528        let with = next_steps(
529            Harness::resolve("opencode").unwrap(),
530            Some("opencode/gpt-5-nano"),
531        );
532        assert!(
533            with.contains(
534                "opencode run --dir <eval-root> --format json --auto -m opencode/gpt-5-nano \\"
535            ),
536            "{with}"
537        );
538        let without = next_steps(Harness::resolve("opencode").unwrap(), None);
539        assert!(
540            without.contains("opencode run --dir <eval-root> --format json --auto \\"),
541            "{without}"
542        );
543        assert!(!without.contains(" -m "), "{without}");
544    }
545
546    #[test]
547    fn codex_judge_recipe_splices_model_arg_in_one_command_shape() {
548        let recipe = adapter_for(Harness::resolve("codex").unwrap())
549            .cli_judge_next_steps(CliJudgeContext {
550                guard: true,
551                iteration_dir: Path::new("/work/iter-1"),
552            })
553            .expect("codex judge recipe is wired");
554        // One command shape: the optional model flag is spliced via $model_arg
555        // (same structure as the Claude judge recipe), not an if/else pair.
556        assert!(
557            recipe.contains(
558                "    codex --ask-for-approval never exec --cd \"/work/iter-1\" --sandbox workspace-write --dangerously-bypass-hook-trust $model_arg --json \\"
559            ),
560            "{recipe}"
561        );
562        assert!(
563            recipe.contains("    model_arg=\"\"; [ -n \"$model\" ] && model_arg=\"-m $model\""),
564            "{recipe}"
565        );
566        assert!(!recipe.contains("if [ -n"), "{recipe}");
567    }
568
569    #[test]
570    fn claude_judge_recipe_snapshot_is_stable() {
571        // Full-string pin carried over from the pre-descriptor adapter: locks
572        // the Claude judge recipe byte-for-byte through the descriptor path.
573        let recipe = adapter_for(Harness::resolve("claude-code").unwrap())
574            .cli_judge_next_steps(CliJudgeContext {
575                guard: false,
576                iteration_dir: Path::new("/work/iter-1"),
577            })
578            .expect("claude judge recipe is wired");
579        let expected = r#"Dispatch each judge task from judge-tasks.json with:
580
581```bash
582JOBS=${JOBS:-4}
583jq -j '.tasks[] | [.dispatch_prompt_path, .response_path, (.model // "")] | @tsv + "\u0000"' judge-tasks.json | \
584  xargs -0 -P "$JOBS" -I{} sh -c '
585    prompt_path="$(printf "%s" "$1" | cut -f1)"
586    response_path="$(printf "%s" "$1" | cut -f2)"
587    model="$(printf "%s" "$1" | cut -f3)"
588    response_base="${response_path%.json}"
589    mkdir -p "$(dirname "$response_path")"
590    model_arg=""; [ -n "$model" ] && model_arg="--model $model"
591    cd "/work/iter-1" && claude -p --output-format stream-json --verbose --permission-mode acceptEdits $model_arg \
592      "Read the file at $prompt_path and follow it exactly. You are a judge worker only: write the JSON verdict to $response_path, then reply with one sentence. Do not run eval-magic. Do not dispatch other judge tasks. Do not wait for other workers." \
593      </dev/null \
594      > "$response_base.claude-events.jsonl" \
595      2> "$response_base.claude-stderr.log"
596  ' sh {}
597```"#;
598        assert_eq!(recipe, expected);
599    }
600
601    #[test]
602    fn skills_blocks_render_each_harness_native_shape() {
603        let skills = vec![skill("zebra", "z skill"), skill("alpha", "a skill")];
604
605        let claude = adapter_for(Harness::resolve("claude-code").unwrap())
606            .render_available_skills_block(&skills);
607        assert!(
608            claude.starts_with("The following skills are available for use with the Skill tool:"),
609            "{claude}"
610        );
611        assert!(claude.contains("\n- alpha: a skill"), "{claude}");
612
613        let codex =
614            adapter_for(Harness::resolve("codex").unwrap()).render_available_skills_block(&skills);
615        assert!(codex.starts_with("## Skills"), "{codex}");
616        assert!(
617            codex.contains("- alpha: a skill (file: /x/alpha/SKILL.md)"),
618            "{codex}"
619        );
620
621        let opencode = adapter_for(Harness::resolve("opencode").unwrap())
622            .render_available_skills_block(&skills);
623        assert!(opencode.starts_with("<available_skills>"), "{opencode}");
624        assert!(opencode.ends_with("\n</available_skills>"), "{opencode}");
625        assert!(opencode.contains("<name>alpha</name>"), "{opencode}");
626        assert!(
627            opencode.contains("<description>a skill</description>"),
628            "{opencode}"
629        );
630
631        // Sorted by name in every shape, and empty renders empty.
632        for harness in Harness::known() {
633            let block = adapter_for(harness).render_available_skills_block(&skills);
634            assert!(
635                block.find("alpha").unwrap() < block.find("zebra").unwrap(),
636                "{harness:?} sorts by name: {block}"
637            );
638            assert_eq!(adapter_for(harness).render_available_skills_block(&[]), "");
639        }
640    }
641
642    #[test]
643    fn opencode_stage_name_rules_match_the_old_validator() {
644        let adapter = adapter_for(Harness::resolve("opencode").unwrap());
645        assert!(adapter.validate_stage_name("valid-name-2").is_ok());
646        for invalid in [
647            "Invalid_Name",
648            "double--hyphen",
649            "-leading",
650            "trailing-",
651            "",
652        ] {
653            let err = adapter
654                .validate_stage_name(invalid)
655                .expect_err("name should be rejected");
656            assert!(
657                err.contains(&format!("OpenCode skill name \"{invalid}\" is invalid")),
658                "{err}"
659            );
660        }
661        assert!(adapter.validate_stage_name(&"a".repeat(64)).is_ok());
662        assert!(adapter.validate_stage_name(&"a".repeat(65)).is_err());
663    }
664}