Skip to main content

skilltest_core/
config.rs

1//! Configuration: which provider runs skills, the default platforms and models a
2//! run fans out across, and the model used for natural-language evals.
3//!
4//! Config is loaded from a YAML file (default `skilltest.yaml`) and then refined
5//! by CLI overrides (see [`Config::apply_overrides`]).
6
7use std::path::Path;
8
9use serde::{Deserialize, Serialize};
10
11use crate::error::{Error, Result};
12use crate::mock::MockDecl;
13
14fn default_oneharness_bin() -> String {
15    "oneharness".to_string()
16}
17
18fn default_judge_harness() -> String {
19    "claude-code".to_string()
20}
21
22fn default_timeout_secs() -> u64 {
23    120
24}
25
26fn default_api_timeout_secs() -> u64 {
27    60
28}
29
30fn default_curl_bin() -> String {
31    "curl".to_string()
32}
33
34fn default_true() -> bool {
35    true
36}
37
38/// Settings for the default [`oneharness`](https://github.com/nickderobertis/oneharness)
39/// provider, which runs each prompt on a harness via `oneharness run`.
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(deny_unknown_fields)]
42pub struct OneharnessConfig {
43    /// The `oneharness` binary (resolved on `PATH`).
44    #[serde(default = "default_oneharness_bin")]
45    pub bin: String,
46    /// The harness used for evals and the simulated user (kept independent of the
47    /// harness under test, so the evaluator does not vary with the matrix).
48    #[serde(default = "default_judge_harness")]
49    pub judge_harness: String,
50    /// Per-call timeout passed through to `oneharness run --timeout`.
51    #[serde(default = "default_timeout_secs")]
52    pub timeout_secs: u64,
53    /// Record each skill run to oneharness's run history (`oneharness run
54    /// --history`), so a past run can be reviewed later with `oneharness
55    /// history`. On by default. The judge and simulated-user calls are never
56    /// recorded — only the skill under test.
57    #[serde(default = "default_true")]
58    pub history: bool,
59    /// Directory the shared run history is written to (passed as `oneharness run
60    /// --history-dir`). Defaults to a single centralized location reused across
61    /// every skilltest invocation (see
62    /// [`crate::provider::default_history_dir`]), so past runs accumulate in one
63    /// reviewable place instead of scattering per project.
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub history_dir: Option<String>,
66}
67
68impl Default for OneharnessConfig {
69    fn default() -> Self {
70        Self {
71            bin: default_oneharness_bin(),
72            judge_harness: default_judge_harness(),
73            timeout_secs: default_timeout_secs(),
74            history: true,
75            history_dir: None,
76        }
77    }
78}
79
80/// Settings for a custom provider command speaking the JSON-lines protocol (see
81/// `docs/protocol.md`). Used by the bundled `skilltest-fake-provider` and any
82/// provider you write yourself.
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(deny_unknown_fields)]
85pub struct CommandConfig {
86    /// The provider command as an argv vector.
87    pub command: Vec<String>,
88}
89
90/// Which provider backs a run.
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92#[serde(tag = "kind", rename_all = "lowercase")]
93pub enum ProviderConfig {
94    /// Run skills through `oneharness` (the default).
95    Oneharness(OneharnessConfig),
96    /// Run a custom command speaking the JSON-lines protocol.
97    Command(CommandConfig),
98}
99
100impl Default for ProviderConfig {
101    fn default() -> Self {
102        ProviderConfig::Oneharness(OneharnessConfig::default())
103    }
104}
105
106/// Which model vendor's API the direct-API judge talks to.
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
108#[serde(rename_all = "lowercase")]
109pub enum ApiVendor {
110    /// Anthropic Messages API (`POST /v1/messages`).
111    Anthropic,
112    /// OpenAI Chat Completions API (`POST /v1/chat/completions`).
113    Openai,
114}
115
116/// Settings for judging evals and the simulated user with a direct model API
117/// call instead of running them through a harness. This trades the harness's
118/// auth-portability for a single fast HTTP round trip per judge call (no
119/// agent-loop cold start), with normalized token usage surfaced into the report.
120///
121/// The judge *model* is the run's `judge_model` (it must be a valid API model
122/// id for the chosen `vendor`, e.g. `claude-opus-4-8` or `gpt-4o`); only the
123/// transport is configured here.
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125#[serde(deny_unknown_fields)]
126pub struct ApiJudgeConfig {
127    /// Which vendor's API to call.
128    pub vendor: ApiVendor,
129    /// Environment variable holding the API key. Defaults to `ANTHROPIC_API_KEY`
130    /// or `OPENAI_API_KEY` by vendor. The key is read at run time and never
131    /// stored in config.
132    #[serde(default)]
133    pub api_key_env: Option<String>,
134    /// Override the API endpoint (e.g. a proxy or an OpenAI-compatible gateway).
135    /// Defaults to the vendor's standard endpoint.
136    #[serde(default)]
137    pub base_url: Option<String>,
138    /// Per-call timeout in seconds, passed to `curl --max-time`.
139    #[serde(default = "default_api_timeout_secs")]
140    pub timeout_secs: u64,
141    /// The `curl` binary (resolved on `PATH`).
142    #[serde(default = "default_curl_bin")]
143    pub curl_bin: String,
144    /// Constrain the judge's verdict to the `{value, reason}` JSON schema via the
145    /// vendor's structured-outputs feature (Anthropic `output_config.format`,
146    /// OpenAI `response_format: json_schema`). On by default — it removes a class
147    /// of judge-parse fragility. Turn it off for a model/endpoint that doesn't
148    /// support structured outputs (the tolerant `{…}` extraction still applies).
149    #[serde(default = "default_true")]
150    pub strict_json: bool,
151}
152
153/// How evals and the simulated user are judged, independent of the provider that
154/// runs the skill. Absent (the default) means the run's provider judges too
155/// (e.g. the oneharness `judge_harness`).
156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
157#[serde(tag = "kind", rename_all = "lowercase")]
158pub enum JudgeConfig {
159    /// Judge with a direct model API call (see [`ApiJudgeConfig`]).
160    Api(ApiJudgeConfig),
161}
162
163/// The full configuration for a run.
164#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
165#[serde(default, deny_unknown_fields)]
166pub struct Config {
167    /// The provider that executes skills and evals.
168    pub provider: ProviderConfig,
169    /// Harness platforms a case runs on (e.g. `claude-code`, `codex`).
170    pub platforms: Vec<String>,
171    /// Models a case runs on (must be valid for the chosen harness, e.g.
172    /// `sonnet`/`haiku` for `claude-code`).
173    pub models: Vec<String>,
174    /// Model used for natural-language evals and the simulated user. Falls back
175    /// to the first entry of `models` when empty.
176    pub judge_model: String,
177    /// Default cap on assistant turns for multi-turn cases. A case may lower it.
178    pub max_turns: u32,
179    /// Optional judge backend that overrides how evals and the simulated user are
180    /// scored, independent of the skill-running provider. When `None`, the
181    /// provider judges (e.g. the oneharness `judge_harness`).
182    #[serde(default, skip_serializing_if = "Option::is_none")]
183    pub judge: Option<JudgeConfig>,
184    /// Shared mock/spy declarations, prepended to every case's own `mocks`
185    /// (first match wins, so these shadow the case's). Populated by the CLI's
186    /// `--mocks <file>` — how the SDKs deliver code-level mocks — or written
187    /// here directly for suite-wide rules.
188    #[serde(default)]
189    pub mocks: Vec<MockDecl>,
190    /// Force the mock/spy observation channel on for every run, even for cases
191    /// that declare no `mocks` (the CLI's `--spy`; how SDK spies get records).
192    #[serde(default)]
193    pub spy: bool,
194}
195
196impl Default for Config {
197    fn default() -> Self {
198        Self {
199            provider: ProviderConfig::default(),
200            platforms: vec!["claude-code".to_string()],
201            models: vec!["claude-opus-4-8".to_string()],
202            judge_model: String::new(),
203            max_turns: 8,
204            judge: None,
205            mocks: Vec::new(),
206            spy: false,
207        }
208    }
209}
210
211/// CLI-supplied overrides. `None`/empty fields leave the config value in place.
212#[derive(Debug, Clone, Default)]
213pub struct Overrides {
214    /// If set, switch to a [`ProviderConfig::Command`] with this argv.
215    pub command_provider: Option<Vec<String>>,
216    /// Override the `oneharness` binary (only applies to the oneharness provider).
217    pub oneharness_bin: Option<String>,
218    /// Override the judge harness (only applies to the oneharness provider).
219    pub judge_harness: Option<String>,
220    /// Override the per-call timeout (only applies to the oneharness provider).
221    pub timeout_secs: Option<u64>,
222    pub platforms: Vec<String>,
223    pub models: Vec<String>,
224    pub judge_model: Option<String>,
225    pub max_turns: Option<u32>,
226    /// Mock/spy declarations prepended before any config-level ones (the CLI's
227    /// `--mocks` file / SDK-passed mocks).
228    pub mocks: Vec<MockDecl>,
229    /// Force the observation channel on (the CLI's `--spy`).
230    pub spy: bool,
231}
232
233impl Config {
234    /// Load configuration from `path`. The standard config filename is
235    /// `skilltest.yaml`.
236    ///
237    /// # Errors
238    /// [`Error::Io`] if the file cannot be read, [`Error::Yaml`] if it does not
239    /// parse, and [`Error::Invalid`] if it parses but is internally
240    /// inconsistent (see [`Config::validate`]).
241    pub fn load(path: &Path) -> Result<Self> {
242        let text = std::fs::read_to_string(path).map_err(|source| Error::Io {
243            path: path.to_path_buf(),
244            source,
245        })?;
246        let config: Config = serde_yaml::from_str(&text).map_err(|source| Error::Yaml {
247            path: path.to_path_buf(),
248            source,
249        })?;
250        config.validate()?;
251        Ok(config)
252    }
253
254    /// Load `path` if it exists, otherwise return [`Config::default`].
255    ///
256    /// # Errors
257    /// Same as [`Config::load`] when the file is present but invalid.
258    pub fn load_or_default(path: &Path) -> Result<Self> {
259        if path.is_file() {
260            Self::load(path)
261        } else {
262            Ok(Self::default())
263        }
264    }
265
266    /// Apply CLI overrides in place, then re-validate.
267    ///
268    /// # Errors
269    /// [`Error::Invalid`] if the merged configuration is inconsistent.
270    pub fn apply_overrides(&mut self, overrides: Overrides) -> Result<()> {
271        if let Some(command) = overrides.command_provider {
272            self.provider = ProviderConfig::Command(CommandConfig { command });
273        } else if let ProviderConfig::Oneharness(oh) = &mut self.provider {
274            if let Some(bin) = overrides.oneharness_bin {
275                oh.bin = bin;
276            }
277            if let Some(judge_harness) = overrides.judge_harness {
278                oh.judge_harness = judge_harness;
279            }
280            if let Some(timeout) = overrides.timeout_secs {
281                oh.timeout_secs = timeout;
282            }
283        }
284        if !overrides.platforms.is_empty() {
285            self.platforms = overrides.platforms;
286        }
287        if !overrides.models.is_empty() {
288            self.models = overrides.models;
289        }
290        if let Some(judge) = overrides.judge_model {
291            self.judge_model = judge;
292        }
293        if let Some(max_turns) = overrides.max_turns {
294            self.max_turns = max_turns;
295        }
296        if !overrides.mocks.is_empty() {
297            // Override mocks come first: first match wins, so the most local
298            // declaration (CLI/SDK) shadows a config-level one.
299            let mut mocks = overrides.mocks;
300            mocks.append(&mut self.mocks);
301            self.mocks = mocks;
302        }
303        self.spy = self.spy || overrides.spy;
304        self.validate()
305    }
306
307    /// The model used for evals and the simulated user: `judge_model` if set,
308    /// otherwise the first configured model.
309    #[must_use]
310    pub fn effective_judge_model(&self) -> &str {
311        if self.judge_model.is_empty() {
312            self.models.first().map_or("", String::as_str)
313        } else {
314            &self.judge_model
315        }
316    }
317
318    /// Check internal consistency.
319    ///
320    /// # Errors
321    /// [`Error::Invalid`] when the provider is misconfigured or no
322    /// platform/model is set.
323    pub fn validate(&self) -> Result<()> {
324        match &self.provider {
325            ProviderConfig::Oneharness(oh) => {
326                if oh.bin.trim().is_empty() {
327                    return Err(Error::Invalid(
328                        "config `provider.bin` must name the oneharness binary".into(),
329                    ));
330                }
331                if oh.judge_harness.trim().is_empty() {
332                    return Err(Error::Invalid(
333                        "config `provider.judge_harness` must name a harness".into(),
334                    ));
335                }
336                if oh.timeout_secs == 0 {
337                    return Err(Error::Invalid(
338                        "config `provider.timeout_secs` must be at least 1".into(),
339                    ));
340                }
341            }
342            ProviderConfig::Command(c) => {
343                if c.command.is_empty() {
344                    return Err(Error::Invalid(
345                        "config `provider.command` must name a command".into(),
346                    ));
347                }
348            }
349        }
350        if self.platforms.is_empty() {
351            return Err(Error::Invalid(
352                "config `platforms` must list at least one harness platform".into(),
353            ));
354        }
355        if self.models.is_empty() {
356            return Err(Error::Invalid(
357                "config `models` must list at least one model".into(),
358            ));
359        }
360        if self.max_turns == 0 {
361            return Err(Error::Invalid(
362                "config `max_turns` must be at least 1".into(),
363            ));
364        }
365        if let Some(JudgeConfig::Api(api)) = &self.judge {
366            if api.timeout_secs == 0 {
367                return Err(Error::Invalid(
368                    "config `judge.timeout_secs` must be at least 1".into(),
369                ));
370            }
371            if api.curl_bin.trim().is_empty() {
372                return Err(Error::Invalid(
373                    "config `judge.curl_bin` must name the curl binary".into(),
374                ));
375            }
376        }
377        for (i, decl) in self.mocks.iter().enumerate() {
378            let label = decl.name.clone().unwrap_or_else(|| format!("#{i}"));
379            decl.validate(&format!("config mock `{label}`"))?;
380        }
381        Ok(())
382    }
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388
389    #[test]
390    fn defaults_are_valid_and_use_oneharness() {
391        let config = Config::default();
392        config.validate().unwrap();
393        assert!(matches!(config.provider, ProviderConfig::Oneharness(_)));
394    }
395
396    #[test]
397    fn command_override_switches_provider() {
398        let mut config = Config::default();
399        config
400            .apply_overrides(Overrides {
401                command_provider: Some(vec!["fake".into()]),
402                ..Default::default()
403            })
404            .unwrap();
405        assert_eq!(
406            config.provider,
407            ProviderConfig::Command(CommandConfig {
408                command: vec!["fake".into()]
409            })
410        );
411    }
412
413    #[test]
414    fn oneharness_bin_override_applies() {
415        let mut config = Config::default();
416        config
417            .apply_overrides(Overrides {
418                oneharness_bin: Some("/tmp/oneharness".into()),
419                ..Default::default()
420            })
421            .unwrap();
422        let ProviderConfig::Oneharness(oh) = &config.provider else {
423            panic!("expected oneharness provider");
424        };
425        assert_eq!(oh.bin, "/tmp/oneharness");
426    }
427
428    #[test]
429    fn parses_command_provider_yaml() {
430        let yaml = "provider:\n  kind: command\n  command: [\"prov\", \"--flag\"]\n";
431        let config: Config = serde_yaml::from_str(yaml).unwrap();
432        assert_eq!(
433            config.provider,
434            ProviderConfig::Command(CommandConfig {
435                command: vec!["prov".into(), "--flag".into()]
436            })
437        );
438    }
439
440    #[test]
441    fn parses_oneharness_provider_yaml() {
442        let yaml = "provider:\n  kind: oneharness\n  bin: oh\n  judge_harness: codex\n";
443        let config: Config = serde_yaml::from_str(yaml).unwrap();
444        let ProviderConfig::Oneharness(oh) = &config.provider else {
445            panic!("expected oneharness provider");
446        };
447        assert_eq!(oh.bin, "oh");
448        assert_eq!(oh.judge_harness, "codex");
449        // Unspecified fields fall back to defaults.
450        assert_eq!(oh.timeout_secs, 120);
451        // History recording is on by default, with the centralized default dir.
452        assert!(oh.history);
453        assert!(oh.history_dir.is_none());
454    }
455
456    #[test]
457    fn parses_oneharness_history_overrides() {
458        let yaml =
459            "provider:\n  kind: oneharness\n  history: false\n  history_dir: /shared/history\n";
460        let config: Config = serde_yaml::from_str(yaml).unwrap();
461        let ProviderConfig::Oneharness(oh) = &config.provider else {
462            panic!("expected oneharness provider");
463        };
464        assert!(!oh.history);
465        assert_eq!(oh.history_dir.as_deref(), Some("/shared/history"));
466        config.validate().unwrap();
467    }
468
469    #[test]
470    fn judge_model_falls_back_to_first_model() {
471        let config = Config::default();
472        assert_eq!(config.effective_judge_model(), "claude-opus-4-8");
473    }
474
475    #[test]
476    fn empty_models_is_invalid() {
477        let mut config = Config::default();
478        config.models.clear();
479        assert!(config.validate().is_err());
480    }
481
482    #[test]
483    fn parses_api_judge_config() {
484        let yaml = "\
485provider:\n  kind: oneharness\njudge:\n  kind: api\n  vendor: anthropic\n  timeout_secs: 30\n";
486        let config: Config = serde_yaml::from_str(yaml).unwrap();
487        let Some(JudgeConfig::Api(api)) = &config.judge else {
488            panic!("expected an api judge");
489        };
490        assert_eq!(api.vendor, ApiVendor::Anthropic);
491        assert_eq!(api.timeout_secs, 30);
492        // Unspecified fields fall back to defaults.
493        assert_eq!(api.curl_bin, "curl");
494        assert!(api.api_key_env.is_none());
495        assert!(api.strict_json, "strict JSON is on by default");
496        config.validate().unwrap();
497    }
498
499    #[test]
500    fn api_judge_zero_timeout_is_invalid() {
501        let yaml = "judge:\n  kind: api\n  vendor: openai\n  timeout_secs: 0\n";
502        let config: Config = serde_yaml::from_str(yaml).unwrap();
503        assert!(config.validate().is_err());
504    }
505
506    #[test]
507    fn default_config_has_no_judge_override() {
508        assert!(Config::default().judge.is_none());
509    }
510
511    /// Write `yaml` to a unique temp file and return its path.
512    fn config_file(tag: &str, yaml: &str) -> std::path::PathBuf {
513        use std::sync::atomic::{AtomicU64, Ordering};
514        static N: AtomicU64 = AtomicU64::new(0);
515        let dir = std::env::temp_dir().join(format!(
516            "skilltest-config-{}-{tag}-{}",
517            std::process::id(),
518            N.fetch_add(1, Ordering::Relaxed)
519        ));
520        std::fs::create_dir_all(&dir).unwrap();
521        let path = dir.join("skilltest.yaml");
522        std::fs::write(&path, yaml).unwrap();
523        path
524    }
525
526    #[test]
527    fn load_reads_and_validates_a_file() {
528        let path = config_file(
529            "load",
530            "provider:\n  kind: command\n  command: [\"prov\"]\nplatforms: [demo]\nmodels: [m]\n",
531        );
532        let config = Config::load(&path).unwrap();
533        assert_eq!(config.platforms, vec!["demo".to_string()]);
534        assert!(matches!(config.provider, ProviderConfig::Command(_)));
535    }
536
537    #[test]
538    fn load_missing_file_is_io_error() {
539        let path = std::env::temp_dir().join(format!("skilltest-none-{}.yaml", std::process::id()));
540        assert!(matches!(Config::load(&path), Err(Error::Io { .. })));
541    }
542
543    #[test]
544    fn load_malformed_yaml_is_yaml_error() {
545        let path = config_file("bad", "platforms: [unterminated\n");
546        assert!(matches!(Config::load(&path), Err(Error::Yaml { .. })));
547    }
548
549    #[test]
550    fn load_inconsistent_config_is_invalid_error() {
551        // Parses fine, but an empty command provider fails validation.
552        let path = config_file(
553            "inconsistent",
554            "provider:\n  kind: command\n  command: []\n",
555        );
556        assert!(matches!(Config::load(&path), Err(Error::Invalid(_))));
557    }
558
559    #[test]
560    fn load_or_default_returns_default_when_absent() {
561        let path =
562            std::env::temp_dir().join(format!("skilltest-absent-{}.yaml", std::process::id()));
563        let config = Config::load_or_default(&path).unwrap();
564        assert_eq!(config, Config::default());
565    }
566
567    #[test]
568    fn load_or_default_loads_when_present() {
569        let path = config_file("present", "platforms: [a, b]\nmodels: [m]\n");
570        let config = Config::load_or_default(&path).unwrap();
571        assert_eq!(config.platforms, vec!["a".to_string(), "b".to_string()]);
572    }
573
574    #[test]
575    fn overrides_apply_judge_harness_timeout_and_run_fields() {
576        let mut config = Config::default();
577        config
578            .apply_overrides(Overrides {
579                judge_harness: Some("codex".into()),
580                timeout_secs: Some(45),
581                platforms: vec!["p1".into(), "p2".into()],
582                models: vec!["mod".into()],
583                judge_model: Some("haiku".into()),
584                max_turns: Some(3),
585                ..Default::default()
586            })
587            .unwrap();
588        let ProviderConfig::Oneharness(oh) = &config.provider else {
589            panic!("still oneharness");
590        };
591        assert_eq!(oh.judge_harness, "codex");
592        assert_eq!(oh.timeout_secs, 45);
593        assert_eq!(config.platforms, vec!["p1".to_string(), "p2".to_string()]);
594        assert_eq!(config.models, vec!["mod".to_string()]);
595        assert_eq!(config.judge_model, "haiku");
596        assert_eq!(config.max_turns, 3);
597    }
598
599    #[test]
600    fn effective_judge_model_prefers_explicit_judge_model() {
601        let config = Config {
602            judge_model: "haiku".into(),
603            ..Config::default()
604        };
605        assert_eq!(config.effective_judge_model(), "haiku");
606    }
607
608    #[test]
609    fn validate_rejects_blank_oneharness_fields() {
610        let mut config = Config::default();
611        if let ProviderConfig::Oneharness(oh) = &mut config.provider {
612            oh.bin = "  ".into();
613        }
614        assert!(config.validate().is_err());
615
616        let mut config = Config::default();
617        if let ProviderConfig::Oneharness(oh) = &mut config.provider {
618            oh.judge_harness = "".into();
619        }
620        assert!(config.validate().is_err());
621
622        let mut config = Config::default();
623        if let ProviderConfig::Oneharness(oh) = &mut config.provider {
624            oh.timeout_secs = 0;
625        }
626        assert!(config.validate().is_err());
627    }
628
629    #[test]
630    fn validate_rejects_empty_platforms_and_zero_max_turns() {
631        let mut config = Config::default();
632        config.platforms.clear();
633        assert!(config.validate().is_err());
634
635        let config = Config {
636            max_turns: 0,
637            ..Config::default()
638        };
639        assert!(config.validate().is_err());
640    }
641
642    #[test]
643    fn validate_rejects_blank_api_judge_curl_bin() {
644        let yaml = "judge:\n  kind: api\n  vendor: anthropic\n  curl_bin: \"  \"\n";
645        let config: Config = serde_yaml::from_str(yaml).unwrap();
646        assert!(config.validate().is_err());
647    }
648
649    #[test]
650    fn config_round_trips_through_yaml() {
651        let config = Config {
652            judge: Some(JudgeConfig::Api(ApiJudgeConfig {
653                vendor: ApiVendor::Openai,
654                api_key_env: Some("X".into()),
655                base_url: None,
656                timeout_secs: 30,
657                curl_bin: "curl".into(),
658                strict_json: false,
659            })),
660            ..Config::default()
661        };
662        let yaml = serde_yaml::to_string(&config).unwrap();
663        let parsed: Config = serde_yaml::from_str(&yaml).unwrap();
664        assert_eq!(parsed, config);
665    }
666}