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