use serde::Deserialize;
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawBudget {
max_turns: Option<u32>,
max_tool_calls: Option<u32>,
max_attempts: Option<u32>,
best_of: Option<u32>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawBug {
reproduce_tries: Option<u32>,
test: Option<Vec<String>>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawAcceptance {
generate: Option<bool>,
held_out: Option<bool>,
strength: Option<bool>,
min_strength: Option<f64>,
near_miss: Option<u32>,
permitted: Option<u32>,
strength_timeout_secs: Option<u64>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct StrengthConfig {
pub enabled: bool,
pub min: Option<f64>,
pub near_miss: u32,
pub permitted: u32,
pub timeout: std::time::Duration,
}
impl Eq for StrengthConfig {}
impl Default for StrengthConfig {
fn default() -> Self {
Self {
enabled: true,
min: None,
near_miss: 2,
permitted: 1,
timeout: std::time::Duration::from_secs(900),
}
}
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawFormat {
argv: Vec<String>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawTests {
frozen: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawMonitor {
enabled: Option<bool>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawContext {
instructions: Option<Vec<String>>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawConverge {
max_rounds: Option<u32>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawReview {
required: Option<bool>,
oracle_samples: Option<i64>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawAttest {
enabled: Option<bool>,
signer: Option<String>,
sign_at: Option<String>,
sigstore: Option<RawSigstore>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawSigstore {
identity: Option<String>,
issuer: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Signer {
Git,
Sigstore,
None,
}
impl Signer {
#[must_use]
pub const fn word(self) -> &'static str {
match self {
Self::Git => "git",
Self::Sigstore => "sigstore",
Self::None => "none",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AttestConfig {
pub enabled: bool,
pub signer: Signer,
pub sign_later: bool,
pub sigstore_identity: Option<String>,
pub sigstore_issuer: Option<String>,
}
impl Default for AttestConfig {
fn default() -> Self {
Self {
enabled: true,
signer: Signer::Git,
sign_later: false,
sigstore_identity: None,
sigstore_issuer: None,
}
}
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawApprovals {
wait: Option<String>,
budget: Option<i64>,
contained: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawGit {
commit: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawCommands {
#[serde(default)]
allow: Vec<Vec<String>>,
#[serde(default)]
allow_prefix: Vec<Vec<String>>,
}
pub const DEFAULT_BUDGET: u32 = 12;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RunConfig {
pub max_turns: u32,
pub max_tool_calls: u32,
pub max_attempts: u32,
pub best_of: u32,
pub allow: Vec<Vec<String>>,
pub allow_prefix: Vec<Vec<String>>,
pub approval_budget: u32,
pub contained: bool,
pub commit_per_task: bool,
pub acceptance: bool,
pub held_out: bool,
pub strength: StrengthConfig,
pub max_converge_rounds: u32,
pub monitor: bool,
pub review_required: bool,
pub oracle_samples: u8,
pub frozen_additions: bool,
pub format: Option<Vec<String>>,
pub approval_wait: std::time::Duration,
pub attest: AttestConfig,
pub instructions: Vec<String>,
pub reproduce_tries: u32,
pub bug_test: Option<Vec<String>>,
}
impl Default for RunConfig {
fn default() -> Self {
Self {
max_turns: 30,
max_tool_calls: 100,
max_attempts: 2,
best_of: 1,
allow: Vec::new(),
allow_prefix: Vec::new(),
approval_budget: DEFAULT_BUDGET,
contained: true,
commit_per_task: true,
acceptance: true,
held_out: true,
strength: StrengthConfig::default(),
max_converge_rounds: 3,
monitor: false,
review_required: false,
oracle_samples: 2,
frozen_additions: false,
format: None,
approval_wait: std::time::Duration::from_secs(7 * 24 * 3600),
attest: AttestConfig::default(),
instructions: vec!["AGENTS.md".to_owned()],
reproduce_tries: speccoder_core::bug::DEFAULT_TRIES,
bug_test: None,
}
}
}
fn wait(text: &str) -> Result<std::time::Duration, String> {
let text = text.trim();
let (n, unit) = text.split_at(text.char_indices().last().map_or(0, |(i, _)| i));
let secs = match unit {
"m" => 60,
"h" => 3600,
"d" => 86_400,
_ => 0,
};
match n.parse::<u64>() {
Ok(n) if n > 0 && secs > 0 => Ok(std::time::Duration::from_secs(n.saturating_mul(secs))),
_ => Err(format!(
"[approvals]: wait = \"{text}\" (a positive number of m, h or d, e.g. \"7d\")"
)),
}
}
pub fn parse(text: &str) -> Result<RunConfig, String> {
let table: toml::Table = toml::from_str(text).map_err(|e| e.to_string())?;
let budget: RawBudget = match table.get("budget") {
Some(v) => v.clone().try_into().map_err(|e| format!("[budget]: {e}"))?,
None => RawBudget::default(),
};
let commands: RawCommands = match table.get("commands") {
Some(v) => v
.clone()
.try_into()
.map_err(|e| format!("[commands]: {e}"))?,
None => RawCommands::default(),
};
let git: RawGit = match table.get("git") {
Some(v) => v.clone().try_into().map_err(|e| format!("[git]: {e}"))?,
None => RawGit::default(),
};
let commit_per_task = match git.commit.as_deref() {
None | Some("per-task") => true,
Some("none") => false,
Some(other) => return Err(format!("[git]: commit = \"{other}\" (per-task or none)")),
};
let acceptance: RawAcceptance = match table.get("acceptance") {
Some(v) => v
.clone()
.try_into()
.map_err(|e| format!("[acceptance]: {e}"))?,
None => RawAcceptance::default(),
};
let converge: RawConverge = match table.get("converge") {
Some(v) => v
.clone()
.try_into()
.map_err(|e| format!("[converge]: {e}"))?,
None => RawConverge::default(),
};
let review: RawReview = match table.get("review") {
Some(v) => v.clone().try_into().map_err(|e| format!("[review]: {e}"))?,
None => RawReview::default(),
};
let monitor: RawMonitor = match table.get("monitor") {
Some(v) => v
.clone()
.try_into()
.map_err(|e| format!("[monitor]: {e}"))?,
None => RawMonitor::default(),
};
let tests: RawTests = match table.get("tests") {
Some(v) => v.clone().try_into().map_err(|e| format!("[tests]: {e}"))?,
None => RawTests::default(),
};
let frozen_additions = match tests.frozen.as_deref() {
None | Some("ask") => false,
Some("additions") => true,
Some(other) => {
return Err(format!("[tests]: frozen = \"{other}\" (ask or additions)"));
}
};
let format: Option<RawFormat> = match table.get("format") {
Some(v) => Some(v.clone().try_into().map_err(|e| format!("[format]: {e}"))?),
None => None,
};
if format
.as_ref()
.is_some_and(|f| f.argv.first().is_none_or(String::is_empty))
{
return Err("[format]: `argv` must name a program".to_owned());
}
let approvals: RawApprovals = match table.get("approvals") {
Some(v) => v
.clone()
.try_into()
.map_err(|e| format!("[approvals]: {e}"))?,
None => RawApprovals::default(),
};
let raw_attest: RawAttest = match table.get("attest") {
Some(v) => v.clone().try_into().map_err(|e| format!("[attest]: {e}"))?,
None => RawAttest::default(),
};
let attest = AttestConfig {
enabled: raw_attest.enabled.unwrap_or(true),
signer: match raw_attest.signer.as_deref() {
None | Some("git") => Signer::Git,
Some("sigstore") => Signer::Sigstore,
Some("none") => Signer::None,
Some(other) => {
return Err(format!(
"[attest]: signer = \"{other}\" (git, sigstore or none)"
));
}
},
sign_later: match raw_attest.sign_at.as_deref() {
None | Some("tick") => false,
Some("later") => true,
Some(other) => {
return Err(format!("[attest]: sign_at = \"{other}\" (tick or later)"));
}
},
sigstore_identity: raw_attest
.sigstore
.as_ref()
.and_then(|s| s.identity.clone()),
sigstore_issuer: raw_attest.sigstore.and_then(|s| s.issuer),
};
let context: RawContext = match table.get("context") {
Some(v) => v
.clone()
.try_into()
.map_err(|e| format!("[context]: {e}"))?,
None => RawContext::default(),
};
if let Some(bad) = context.instructions.iter().flatten().find(|n| {
n.is_empty() || n.contains('/') || n.contains('\\') || matches!(n.as_str(), "." | "..")
}) {
return Err(format!(
"[context]: instructions = \"{bad}\": a file name such as AGENTS.md, without a directory"
));
}
let d = RunConfig::default();
let strength = {
let ds = StrengthConfig::default();
if let Some(m) = acceptance.min_strength
&& !(0.0..=1.0).contains(&m)
{
return Err(format!(
"[acceptance]: min_strength = {m} (a share from 0.0 to 1.0)"
));
}
let near_miss = acceptance.near_miss.unwrap_or(ds.near_miss);
if near_miss > 3 {
return Err(format!("[acceptance]: near_miss = {near_miss} (0 to 3)"));
}
let permitted = acceptance.permitted.unwrap_or(ds.permitted);
if permitted > 2 {
return Err(format!("[acceptance]: permitted = {permitted} (0 to 2)"));
}
StrengthConfig {
enabled: acceptance.strength.unwrap_or(ds.enabled),
min: acceptance.min_strength,
near_miss,
permitted,
timeout: acceptance
.strength_timeout_secs
.map_or(ds.timeout, |s| std::time::Duration::from_secs(s.max(1))),
}
};
let bug: RawBug = match table.get("bug") {
Some(v) => v.clone().try_into().map_err(|e| format!("[bug]: {e}"))?,
None => RawBug::default(),
};
let reproduce_tries = bug.reproduce_tries.unwrap_or(d.reproduce_tries);
if !(1..=speccoder_core::bug::MAX_TRIES).contains(&reproduce_tries) {
return Err(format!(
"[bug]: reproduce_tries = {reproduce_tries} (1 to {})",
speccoder_core::bug::MAX_TRIES
));
}
if bug.test.as_ref().is_some_and(Vec::is_empty) {
return Err("[bug]: test = [] (a program and its arguments)".to_owned());
}
let approval_wait = match approvals.wait.as_deref() {
Some(w) => wait(w)?,
None => d.approval_wait,
};
let approval_budget = match approvals.budget {
None => d.approval_budget,
Some(n) => u32::try_from(n)
.ok()
.filter(|n| *n <= 100)
.ok_or_else(|| format!("[approvals]: budget = {n} (a whole number from 0 to 100)"))?,
};
let contained = match approvals.contained.as_deref() {
None | Some("allow") => true,
Some("ask") => false,
Some(other) => {
return Err(format!(
"[approvals]: contained = \"{other}\" (allow or ask)"
));
}
};
let oracle_samples = match review.oracle_samples {
None => d.oracle_samples,
Some(n @ 1..=2) => u8::try_from(n).unwrap_or(d.oracle_samples),
Some(n) => return Err(format!("[review]: oracle_samples = {n} (1 or 2)")),
};
if let Some(why) = commands
.allow_prefix
.iter()
.find_map(|p| crate::tools::policy::prefix_refusal(p))
{
return Err(format!("[commands] allow_prefix: {why}"));
}
Ok(RunConfig {
max_turns: budget.max_turns.unwrap_or(d.max_turns).max(1),
max_tool_calls: budget.max_tool_calls.unwrap_or(d.max_tool_calls),
max_attempts: budget.max_attempts.unwrap_or(d.max_attempts).max(1),
best_of: budget.best_of.unwrap_or(d.best_of).clamp(1, 10),
allow: commands
.allow
.into_iter()
.filter(|p| !p.is_empty())
.collect(),
allow_prefix: commands.allow_prefix,
approval_budget,
contained,
commit_per_task,
acceptance: acceptance.generate.unwrap_or(true),
held_out: acceptance.held_out.unwrap_or(true),
strength,
max_converge_rounds: converge.max_rounds.unwrap_or(d.max_converge_rounds),
monitor: monitor.enabled.unwrap_or(false),
review_required: review.required.unwrap_or(false),
oracle_samples,
frozen_additions,
format: format.map(|f| f.argv),
approval_wait,
attest,
instructions: context.instructions.unwrap_or(d.instructions),
reproduce_tries,
bug_test: bug.test,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bug_tries_default_to_three_and_stay_within_five() {
assert_eq!(parse("").unwrap().reproduce_tries, 3);
assert_eq!(
parse("[bug]\nreproduce_tries = 5\n")
.unwrap()
.reproduce_tries,
5
);
assert!(parse("[bug]\nreproduce_tries = 6\n").is_err());
assert!(parse("[bug]\nreproduce_tries = 0\n").is_err());
assert!(parse("[bug]\ntries = 2\n").is_err());
let c = parse("[bug]\ntest = [\"sh\", \"t.sh\", \"{file}\", \"{name}\"]\n").unwrap();
assert_eq!(c.bug_test.unwrap_or_default().len(), 4);
}
#[test]
fn agents_md_is_read_unless_configured_otherwise() {
assert_eq!(parse("").unwrap().instructions, ["AGENTS.md"]);
let c = parse("[context]\ninstructions = [\"AGENTS.md\", \"CLAUDE.md\"]\n").unwrap();
assert_eq!(c.instructions, ["AGENTS.md", "CLAUDE.md"]);
assert!(
parse("[context]\ninstructions = []\n")
.unwrap()
.instructions
.is_empty()
);
assert!(parse("[context]\ninstructions = [\"docs/AGENTS.md\"]\n").is_err());
assert!(parse("[context]\nfiles = []\n").is_err());
}
#[test]
fn attestations_are_on_and_signed_by_git_unless_configured() {
let d = parse("").unwrap_or_default().attest;
assert!(d.enabled && d.signer == Signer::Git && !d.sign_later);
let c = parse("[attest]\nsigner = \"sigstore\"\nsign_at = \"later\"\n[attest.sigstore]\nidentity = \"ada@example.com\"\nissuer = \"https://accounts.example\"\n")
.unwrap_or_default()
.attest;
assert_eq!((c.signer, c.sign_later), (Signer::Sigstore, true));
assert_eq!(c.sigstore_identity.as_deref(), Some("ada@example.com"));
assert!(parse("[attest]\nsigner = \"x509\"\n").is_err());
assert!(parse("[attest]\nsign_at = \"never\"\n").is_err());
assert!(parse("[attest]\nkey = \"x\"\n").is_err());
}
#[test]
fn an_approval_waits_seven_days_unless_configured() {
assert_eq!(
parse("").unwrap_or_default().approval_wait.as_secs(),
7 * 86_400
);
let w = |t: &str| {
parse(&format!("[approvals]\nwait = \"{t}\"\n")).map(|c| c.approval_wait.as_secs())
};
assert_eq!(w("30m"), Ok(1800));
assert_eq!(w("12h"), Ok(43_200));
for bad in ["0d", "7", "7w", "-1d", ""] {
assert!(w(bad).is_err(), "{bad}");
}
}
#[test]
fn strength_is_measured_by_default_and_bounded() {
let c = parse("").unwrap().strength;
assert!(c.enabled && c.min.is_none());
assert_eq!((c.near_miss, c.permitted), (2, 1));
let c = parse("[acceptance]\nmin_strength = 0.8\nnear_miss = 3\npermitted = 0\nstrength_timeout_secs = 60\n")
.unwrap()
.strength;
assert_eq!(c.min, Some(0.8));
assert_eq!((c.near_miss, c.permitted, c.timeout.as_secs()), (3, 0, 60));
assert!(
!parse("[acceptance]\nstrength = false\n")
.unwrap()
.strength
.enabled
);
for bad in ["min_strength = 1.5", "near_miss = 4", "permitted = 3"] {
assert!(parse(&format!("[acceptance]\n{bad}\n")).is_err(), "{bad}");
}
}
#[test]
fn oracle_samples_are_one_or_two() {
assert_eq!(parse("").unwrap().oracle_samples, 2);
assert_eq!(
parse("[review]\noracle_samples = 1\n")
.unwrap()
.oracle_samples,
1
);
for bad in ["0", "3", "-1"] {
assert!(parse(&format!("[review]\noracle_samples = {bad}\n")).is_err());
}
}
#[test]
fn held_out_is_on_by_default_and_can_be_switched_off() {
assert!(parse("").unwrap().held_out);
let c = parse("[acceptance]\nheld_out = false\n").unwrap();
assert!(!c.held_out && c.acceptance);
assert!(parse("[acceptance]\nheldout = true\n").is_err());
}
#[test]
fn defaults_and_overrides() {
assert_eq!(parse("").unwrap(), RunConfig::default());
let c =
parse("[budget]\nmax_turns = 5\n[commands]\nallow = [[\"cargo\", \"fmt\"]]\n").unwrap();
assert_eq!(c.max_turns, 5);
assert_eq!(c.max_tool_calls, 100);
assert_eq!(c.max_attempts, 2);
assert_eq!(
parse("[budget]\nmax_attempts = 0\n").unwrap().max_attempts,
1
);
assert_eq!(c.allow, [vec!["cargo".to_owned(), "fmt".to_owned()]]);
assert!(parse("[budget]\nmax_cost = 1\n").is_err());
assert!(!parse("[git]\ncommit = \"none\"\n").unwrap().commit_per_task);
assert!(parse("[git]\ncommit = \"always\"\n").is_err());
assert!(
parse("[tests]\nfrozen = \"additions\"\n")
.unwrap()
.frozen_additions
);
assert!(
!parse("[tests]\nfrozen = \"ask\"\n")
.unwrap()
.frozen_additions
);
assert!(parse("[tests]\nfrozen = \"anything\"\n").is_err());
}
#[test]
fn the_asking_budget_and_prefixes_are_checked() {
assert_eq!(parse("").unwrap().approval_budget, 12);
assert_eq!(
parse("[approvals]\nbudget = 0\n").unwrap().approval_budget,
0
);
for bad in ["101", "-1"] {
let e = parse(&format!("[approvals]\nbudget = {bad}\n")).unwrap_err();
assert!(e.contains("budget"), "{e}");
}
let c = parse("[commands]\nallow_prefix = [[\"npm\", \"run\"]]\n").unwrap();
assert_eq!(c.allow_prefix, [vec!["npm".to_owned(), "run".to_owned()]]);
let e = parse("[commands]\nallow_prefix = [[\"python3\"]]\n").unwrap_err();
assert!(e.contains("a bare program allows any argument"), "{e}");
}
#[test]
fn contained_calls_are_allowed_unless_asked_for() {
assert!(parse("").unwrap().contained);
assert!(
parse("[approvals]\ncontained = \"allow\"\n")
.unwrap()
.contained
);
assert!(
!parse("[approvals]\ncontained = \"ask\"\n")
.unwrap()
.contained
);
let e = parse("[approvals]\ncontained = \"yes\"\n").unwrap_err();
assert_eq!(e, "[approvals]: contained = \"yes\" (allow or ask)");
}
}