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_surfaces_skill_invocation(&self) -> bool {
218        self.descriptor
219            .transcript
220            .as_ref()
221            .is_none_or(|t| t.surfaces_skill_invocation)
222    }
223
224    fn cli_model_flag(&self) -> Option<String> {
225        self.model_flag().map(str::to_string)
226    }
227
228    fn install_guard(
229        &self,
230        stage_root: &Path,
231        guard_exe: &Path,
232        ttl: Option<Duration>,
233    ) -> io::Result<PathBuf> {
234        match &self.descriptor.guard {
235            Some(guard) => {
236                let skills_dir = self
237                    .skills_dir(stage_root)
238                    .expect("descriptor validation pairs [guard] with skills_dir");
239                super::guard::install_guard(guard, &skills_dir, stage_root, guard_exe, ttl)
240            }
241            None => Err(io::Error::new(
242                io::ErrorKind::Unsupported,
243                format!("--guard is not supported for the {} harness", self.label()),
244            )),
245        }
246    }
247
248    fn guard_armed_message(&self) -> Option<String> {
249        self.descriptor
250            .guard
251            .as_ref()
252            .map(|g| g.armed_message.clone())
253    }
254
255    fn guard_verdict(&self, payload: &str, marker: Option<GuardMarker>) -> Option<String> {
256        let guard = self.descriptor.guard.as_ref()?;
257        super::guard::guard_verdict(guard, payload, marker)
258    }
259
260    fn guard_hook_cleanup_dir(&self, stage_root: &Path) -> Option<PathBuf> {
261        self.descriptor.guard.as_ref().and_then(|g| {
262            super::guard::hook_cleanup_dir(g, self.descriptor.skills_dir.as_deref(), stage_root)
263        })
264    }
265
266    fn detect_shadowed_skills(
267        &self,
268        scan_root: &Path,
269        staged_skill_names: &[&str],
270    ) -> Option<PluginShadowReport> {
271        self.descriptor
272            .shadow
273            .as_ref()
274            .and_then(|s| s.preflight.detect(scan_root, staged_skill_names))
275    }
276
277    fn has_dispatch_recipes(&self) -> bool {
278        self.descriptor.dispatch.exec_template.is_some()
279    }
280
281    fn cli_next_steps(&self, ctx: CliDispatchContext<'_>) -> String {
282        let ingest_line = format!(
283            "ingest{} --iteration {} --harness {}",
284            ctx.target_args, ctx.iteration, self.descriptor.label
285        );
286        let Some(template) = &self.descriptor.dispatch.next_steps_template else {
287            // Generic fallbacks: a descriptor with just an exec template still
288            // earns a copy-pasteable recipe; a baseline descriptor gets the
289            // harness-agnostic handoff.
290            return match &self.descriptor.dispatch.exec_template {
291                Some(_) => format!(
292                    "\nNext: iterate the tasks[] array in dispatch.json and dispatch each task \
293                     with:\n{}\nThen run `{ingest_line}`.",
294                    self.exec_command(ctx.guard, ctx.agent_model)
295                ),
296                None => format!(
297                    "\nNext: read dispatch-manifest.md and dispatch each task through your \
298                     harness's one-shot CLI from the task's eval_root, saving the agent's \
299                     final reply to outputs/final-message.md.\nThen run `{ingest_line}`."
300                ),
301            };
302        };
303        let exec_command = self.exec_command(ctx.guard, ctx.agent_model);
304        let iteration = ctx.iteration.to_string();
305        let model_note = if ctx.agent_model.is_some() {
306            self.descriptor.dispatch.model_note.as_deref().unwrap_or("")
307        } else {
308            ""
309        };
310        subst(
311            template,
312            &[
313                ("exec_command", &exec_command),
314                ("target_args", ctx.target_args),
315                ("iteration", &iteration),
316                ("model_note", model_note),
317            ],
318        )
319    }
320
321    fn cli_manifest_section(&self, ctx: CliManifestContext<'_>) -> Option<Vec<String>> {
322        let Some(template) = self.descriptor.dispatch.manifest_template.as_ref() else {
323            // An exec template without a manifest template still earns a
324            // generic recipe section; without either, the manifest's shared
325            // header text already covers the baseline handoff.
326            self.descriptor.dispatch.exec_template.as_ref()?;
327            let exec_command = self.exec_command(ctx.guard, ctx.agent_model);
328            return Some(
329                format!(
330                    "## Dispatch recipe\n\nFrom each task's `eval_root`, dispatch with:\n\
331                     {exec_command}\n\nEnsure the agent's final reply lands in the task's \
332                     `outputs/final-message.md` (capture it yourself if the command does not \
333                     write it).\n"
334                )
335                .split('\n')
336                .map(String::from)
337                .collect(),
338            );
339        };
340        let exec_command = self.exec_command(ctx.guard, ctx.agent_model);
341        let parallel_recipe = match &self.descriptor.dispatch.parallel_command_template {
342            Some(block_template) => {
343                let model_arg = render_cli_model_arg(self.model_flag(), ctx.agent_model);
344                render_parallel_dispatch_recipe(&subst(
345                    block_template,
346                    &[
347                        ("model_arg", &model_arg),
348                        ("guard_args", self.guard_args(ctx.guard)),
349                    ],
350                ))
351            }
352            None => String::new(),
353        };
354        Some(
355            subst(
356                template,
357                &[
358                    ("exec_command", &exec_command),
359                    ("parallel_recipe", &parallel_recipe),
360                ],
361            )
362            .split('\n')
363            .map(String::from)
364            .collect(),
365        )
366    }
367
368    fn cli_judge_next_steps(&self, ctx: CliJudgeContext<'_>) -> Option<String> {
369        let template = self.descriptor.dispatch.judge_command_template.as_ref()?;
370        let cwd = ctx.iteration_dir.display().to_string();
371        let command_line = subst(
372            template,
373            &[("cwd", &cwd), ("guard_args", self.guard_args(ctx.guard))],
374        );
375        Some(render_judge_dispatch_recipe(
376            &command_line,
377            // Both guaranteed by descriptor validation when the template is set.
378            self.model_flag().unwrap_or_default(),
379            self.descriptor
380                .dispatch
381                .capture_prefix
382                .as_deref()
383                .unwrap_or_default(),
384        ))
385    }
386}
387
388#[cfg(test)]
389mod tests {
390    use std::path::Path;
391
392    use crate::adapters::harness::{CliDispatchContext, CliJudgeContext};
393    use crate::adapters::registry::adapter_for;
394    use crate::core::{AvailableSkill, Harness};
395
396    fn skill(name: &str, description: &str) -> AvailableSkill {
397        AvailableSkill {
398            name: name.into(),
399            path: format!("/x/{name}/SKILL.md"),
400            description: description.into(),
401        }
402    }
403
404    fn next_steps(harness: Harness, agent_model: Option<&str>) -> String {
405        adapter_for(harness).cli_next_steps(CliDispatchContext {
406            guard: harness == Harness::resolve("codex").unwrap(),
407            target_args: " --skill-dir /tmp/skills --skill widget-skill",
408            iteration: 2,
409            agent_model,
410        })
411    }
412
413    fn adapter_from(toml_src: &str) -> super::DescriptorAdapter {
414        super::DescriptorAdapter::from_descriptor(
415            crate::adapters::descriptor::load_descriptor(toml_src, "test.toml").unwrap(),
416        )
417    }
418
419    #[test]
420    fn exec_template_alone_yields_generic_dispatch_recipes() {
421        use crate::adapters::harness::{CliManifestContext, HarnessAdapter};
422        let adapter = adapter_from(
423            "label = \"cool-custom-harness\"\n\n[dispatch]\nexec_template = \"cool-cli run <dispatch_prompt_path>{model_arg}\"\n",
424        );
425        let next = adapter.cli_next_steps(CliDispatchContext {
426            guard: false,
427            target_args: " --skill-dir /s --skill x",
428            iteration: 3,
429            agent_model: None,
430        });
431        assert!(next.contains("cool-cli run"), "{next}");
432        assert!(
433            next.contains(
434                "ingest --skill-dir /s --skill x --iteration 3 --harness cool-custom-harness"
435            ),
436            "{next}"
437        );
438        let manifest = adapter
439            .cli_manifest_section(CliManifestContext {
440                guard: false,
441                agent_model: None,
442            })
443            .expect("an exec template earns a generic manifest recipe")
444            .join("\n");
445        assert!(manifest.contains("cool-cli run"), "{manifest}");
446        assert!(manifest.contains("final-message.md"), "{manifest}");
447    }
448
449    #[test]
450    fn baseline_descriptor_yields_the_generic_handoff() {
451        use crate::adapters::harness::{CliManifestContext, HarnessAdapter};
452        let adapter = adapter_from("label = \"cool-custom-harness\"\n");
453        let next = adapter.cli_next_steps(CliDispatchContext {
454            guard: false,
455            target_args: " --skill x",
456            iteration: 1,
457            agent_model: None,
458        });
459        assert!(next.contains("one-shot CLI"), "{next}");
460        assert!(next.contains("outputs/final-message.md"), "{next}");
461        assert!(
462            next.contains("ingest --skill x --iteration 1 --harness cool-custom-harness"),
463            "{next}"
464        );
465        assert!(
466            adapter
467                .cli_manifest_section(CliManifestContext {
468                    guard: false,
469                    agent_model: None,
470                })
471                .is_none(),
472            "the manifest's generic header already covers the no-recipe baseline"
473        );
474    }
475
476    #[test]
477    fn exec_recipe_includes_model_only_when_declared() {
478        let with = next_steps(Harness::resolve("claude-code").unwrap(), Some("opus"));
479        assert!(with.contains("--model opus"), "{with}");
480        let without = next_steps(Harness::resolve("claude-code").unwrap(), None);
481        assert!(!without.contains("--model "), "{without}");
482    }
483
484    #[test]
485    fn codex_recipes_gate_hook_trust_on_guard() {
486        let guarded = next_steps(Harness::resolve("codex").unwrap(), Some("gpt-5-mini"));
487        assert!(
488            guarded.contains(
489                "codex --ask-for-approval never exec --cd <eval-root> --sandbox workspace-write --dangerously-bypass-hook-trust -m gpt-5-mini --json \\"
490            ),
491            "{guarded}"
492        );
493        let unguarded =
494            adapter_for(Harness::resolve("codex").unwrap()).cli_next_steps(CliDispatchContext {
495                guard: false,
496                target_args: "",
497                iteration: 2,
498                agent_model: None,
499            });
500        assert!(
501            !unguarded.contains("--dangerously-bypass-hook-trust"),
502            "{unguarded}"
503        );
504    }
505
506    #[test]
507    fn codex_judge_recipe_splices_model_arg_in_one_command_shape() {
508        let recipe = adapter_for(Harness::resolve("codex").unwrap())
509            .cli_judge_next_steps(CliJudgeContext {
510                guard: true,
511                iteration_dir: Path::new("/work/iter-1"),
512            })
513            .expect("codex judge recipe is wired");
514        // One command shape: the optional model flag is spliced via $model_arg
515        // (same structure as the Claude judge recipe), not an if/else pair.
516        assert!(
517            recipe.contains(
518                "    codex --ask-for-approval never exec --cd \"/work/iter-1\" --sandbox workspace-write --dangerously-bypass-hook-trust $model_arg --json \\"
519            ),
520            "{recipe}"
521        );
522        assert!(
523            recipe.contains("    model_arg=\"\"; [ -n \"$model\" ] && model_arg=\"-m $model\""),
524            "{recipe}"
525        );
526        assert!(!recipe.contains("if [ -n"), "{recipe}");
527    }
528
529    #[test]
530    fn claude_judge_recipe_snapshot_is_stable() {
531        // Full-string pin carried over from the pre-descriptor adapter: locks
532        // the Claude judge recipe byte-for-byte through the descriptor path.
533        let recipe = adapter_for(Harness::resolve("claude-code").unwrap())
534            .cli_judge_next_steps(CliJudgeContext {
535                guard: false,
536                iteration_dir: Path::new("/work/iter-1"),
537            })
538            .expect("claude judge recipe is wired");
539        let expected = r#"Dispatch each judge task from judge-tasks.json with:
540
541```bash
542JOBS=${JOBS:-4}
543jq -j '.tasks[] | [.dispatch_prompt_path, .response_path, (.model // "")] | @tsv + "\u0000"' judge-tasks.json | \
544  xargs -0 -P "$JOBS" -I{} sh -c '
545    prompt_path="$(printf "%s" "$1" | cut -f1)"
546    response_path="$(printf "%s" "$1" | cut -f2)"
547    model="$(printf "%s" "$1" | cut -f3)"
548    response_base="${response_path%.json}"
549    mkdir -p "$(dirname "$response_path")"
550    model_arg=""; [ -n "$model" ] && model_arg="--model $model"
551    cd "/work/iter-1" && claude -p --output-format stream-json --verbose --permission-mode acceptEdits $model_arg \
552      "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." \
553      </dev/null \
554      > "$response_base.claude-events.jsonl" \
555      2> "$response_base.claude-stderr.log"
556  ' sh {}
557```"#;
558        assert_eq!(recipe, expected);
559    }
560
561    #[test]
562    fn skills_blocks_render_each_harness_native_shape() {
563        let skills = vec![skill("zebra", "z skill"), skill("alpha", "a skill")];
564
565        let claude = adapter_for(Harness::resolve("claude-code").unwrap())
566            .render_available_skills_block(&skills);
567        assert!(
568            claude.starts_with("The following skills are available for use with the Skill tool:"),
569            "{claude}"
570        );
571        assert!(claude.contains("\n- alpha: a skill"), "{claude}");
572
573        let codex =
574            adapter_for(Harness::resolve("codex").unwrap()).render_available_skills_block(&skills);
575        assert!(codex.starts_with("## Skills"), "{codex}");
576        assert!(
577            codex.contains("- alpha: a skill (file: /x/alpha/SKILL.md)"),
578            "{codex}"
579        );
580
581        let opencode = adapter_for(Harness::resolve("opencode").unwrap())
582            .render_available_skills_block(&skills);
583        assert!(opencode.starts_with("<available_skills>"), "{opencode}");
584        assert!(opencode.ends_with("\n</available_skills>"), "{opencode}");
585        assert!(opencode.contains("<name>alpha</name>"), "{opencode}");
586        assert!(
587            opencode.contains("<description>a skill</description>"),
588            "{opencode}"
589        );
590
591        // Sorted by name in every shape, and empty renders empty.
592        for harness in Harness::known() {
593            let block = adapter_for(harness).render_available_skills_block(&skills);
594            assert!(
595                block.find("alpha").unwrap() < block.find("zebra").unwrap(),
596                "{harness:?} sorts by name: {block}"
597            );
598            assert_eq!(adapter_for(harness).render_available_skills_block(&[]), "");
599        }
600    }
601
602    #[test]
603    fn opencode_stage_name_rules_match_the_old_validator() {
604        let adapter = adapter_for(Harness::resolve("opencode").unwrap());
605        assert!(adapter.validate_stage_name("valid-name-2").is_ok());
606        for invalid in [
607            "Invalid_Name",
608            "double--hyphen",
609            "-leading",
610            "trailing-",
611            "",
612        ] {
613            let err = adapter
614                .validate_stage_name(invalid)
615                .expect_err("name should be rejected");
616            assert!(
617                err.contains(&format!("OpenCode skill name \"{invalid}\" is invalid")),
618                "{err}"
619            );
620        }
621        assert!(adapter.validate_stage_name(&"a".repeat(64)).is_ok());
622        assert!(adapter.validate_stage_name(&"a".repeat(65)).is_err());
623    }
624}