1use serde::Deserialize;
4
5#[derive(Debug, Default, Deserialize)]
6#[serde(deny_unknown_fields)]
7struct RawBudget {
8 max_turns: Option<u32>,
9 max_tool_calls: Option<u32>,
10 max_attempts: Option<u32>,
11 best_of: Option<u32>,
12}
13
14#[derive(Debug, Default, Deserialize)]
15#[serde(deny_unknown_fields)]
16struct RawBug {
17 reproduce_tries: Option<u32>,
18 test: Option<Vec<String>>,
19}
20
21#[derive(Debug, Default, Deserialize)]
22#[serde(deny_unknown_fields)]
23struct RawAcceptance {
24 generate: Option<bool>,
25 held_out: Option<bool>,
26 strength: Option<bool>,
27 min_strength: Option<f64>,
28 near_miss: Option<u32>,
29 permitted: Option<u32>,
30 strength_timeout_secs: Option<u64>,
31}
32
33#[derive(Debug, Clone, PartialEq)]
37pub struct StrengthConfig {
38 pub enabled: bool,
40 pub min: Option<f64>,
42 pub near_miss: u32,
44 pub permitted: u32,
47 pub timeout: std::time::Duration,
49}
50
51impl Eq for StrengthConfig {}
53
54impl Default for StrengthConfig {
55 fn default() -> Self {
56 Self {
57 enabled: true,
58 min: None,
59 near_miss: 2,
60 permitted: 1,
61 timeout: std::time::Duration::from_secs(900),
62 }
63 }
64}
65
66#[derive(Debug, Default, Deserialize)]
67#[serde(deny_unknown_fields)]
68struct RawFormat {
69 argv: Vec<String>,
70}
71
72#[derive(Debug, Default, Deserialize)]
73#[serde(deny_unknown_fields)]
74struct RawTests {
75 frozen: Option<String>,
76}
77
78#[derive(Debug, Default, Deserialize)]
79#[serde(deny_unknown_fields)]
80struct RawMonitor {
81 enabled: Option<bool>,
82}
83
84#[derive(Debug, Default, Deserialize)]
85#[serde(deny_unknown_fields)]
86struct RawContext {
87 instructions: Option<Vec<String>>,
88}
89
90#[derive(Debug, Default, Deserialize)]
91#[serde(deny_unknown_fields)]
92struct RawConverge {
93 max_rounds: Option<u32>,
94}
95
96#[derive(Debug, Default, Deserialize)]
97#[serde(deny_unknown_fields)]
98struct RawReview {
99 required: Option<bool>,
100 oracle_samples: Option<i64>,
101}
102
103#[derive(Debug, Default, Deserialize)]
104#[serde(deny_unknown_fields)]
105struct RawAttest {
106 enabled: Option<bool>,
107 signer: Option<String>,
108 sign_at: Option<String>,
109 sigstore: Option<RawSigstore>,
110}
111
112#[derive(Debug, Default, Deserialize)]
113#[serde(deny_unknown_fields)]
114struct RawSigstore {
115 identity: Option<String>,
116 issuer: Option<String>,
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub enum Signer {
122 Git,
124 Sigstore,
126 None,
128}
129
130impl Signer {
131 #[must_use]
133 pub const fn word(self) -> &'static str {
134 match self {
135 Self::Git => "git",
136 Self::Sigstore => "sigstore",
137 Self::None => "none",
138 }
139 }
140}
141
142#[derive(Debug, Clone, PartialEq, Eq)]
144pub struct AttestConfig {
145 pub enabled: bool,
147 pub signer: Signer,
149 pub sign_later: bool,
151 pub sigstore_identity: Option<String>,
154 pub sigstore_issuer: Option<String>,
156}
157
158impl Default for AttestConfig {
159 fn default() -> Self {
160 Self {
161 enabled: true,
162 signer: Signer::Git,
163 sign_later: false,
164 sigstore_identity: None,
165 sigstore_issuer: None,
166 }
167 }
168}
169
170#[derive(Debug, Default, Deserialize)]
171#[serde(deny_unknown_fields)]
172struct RawApprovals {
173 wait: Option<String>,
174 budget: Option<i64>,
175 contained: Option<String>,
176}
177
178#[derive(Debug, Default, Deserialize)]
179#[serde(deny_unknown_fields)]
180struct RawGit {
181 commit: Option<String>,
182}
183
184#[derive(Debug, Default, Deserialize)]
185#[serde(deny_unknown_fields)]
186struct RawCommands {
187 #[serde(default)]
188 allow: Vec<Vec<String>>,
189 #[serde(default)]
190 allow_prefix: Vec<Vec<String>>,
191}
192
193pub const DEFAULT_BUDGET: u32 = 12;
195
196#[derive(Debug, Clone, PartialEq, Eq)]
198pub struct RunConfig {
199 pub max_turns: u32,
201 pub max_tool_calls: u32,
203 pub max_attempts: u32,
205 pub best_of: u32,
207 pub allow: Vec<Vec<String>>,
209 pub allow_prefix: Vec<Vec<String>>,
212 pub approval_budget: u32,
215 pub contained: bool,
218 pub commit_per_task: bool,
220 pub acceptance: bool,
222 pub held_out: bool,
225 pub strength: StrengthConfig,
227 pub max_converge_rounds: u32,
229 pub monitor: bool,
231 pub review_required: bool,
234 pub oracle_samples: u8,
238 pub frozen_additions: bool,
241 pub format: Option<Vec<String>>,
244 pub approval_wait: std::time::Duration,
247 pub attest: AttestConfig,
249 pub instructions: Vec<String>,
252 pub reproduce_tries: u32,
254 pub bug_test: Option<Vec<String>>,
257}
258
259impl Default for RunConfig {
260 fn default() -> Self {
261 Self {
262 max_turns: 30,
263 max_tool_calls: 100,
264 max_attempts: 2,
265 best_of: 1,
266 allow: Vec::new(),
267 allow_prefix: Vec::new(),
268 approval_budget: DEFAULT_BUDGET,
269 contained: true,
270 commit_per_task: true,
271 acceptance: true,
272 held_out: true,
273 strength: StrengthConfig::default(),
274 max_converge_rounds: 3,
275 monitor: false,
276 review_required: false,
277 oracle_samples: 2,
278 frozen_additions: false,
279 format: None,
280 approval_wait: std::time::Duration::from_secs(7 * 24 * 3600),
281 attest: AttestConfig::default(),
282 instructions: vec!["AGENTS.md".to_owned()],
283 reproduce_tries: speccoder_core::bug::DEFAULT_TRIES,
284 bug_test: None,
285 }
286 }
287}
288
289fn wait(text: &str) -> Result<std::time::Duration, String> {
291 let text = text.trim();
292 let (n, unit) = text.split_at(text.char_indices().last().map_or(0, |(i, _)| i));
294 let secs = match unit {
295 "m" => 60,
296 "h" => 3600,
297 "d" => 86_400,
298 _ => 0,
299 };
300 match n.parse::<u64>() {
301 Ok(n) if n > 0 && secs > 0 => Ok(std::time::Duration::from_secs(n.saturating_mul(secs))),
302 _ => Err(format!(
303 "[approvals]: wait = \"{text}\" (a positive number of m, h or d, e.g. \"7d\")"
304 )),
305 }
306}
307
308pub fn parse(text: &str) -> Result<RunConfig, String> {
310 let table: toml::Table = toml::from_str(text).map_err(|e| e.to_string())?;
311 let budget: RawBudget = match table.get("budget") {
312 Some(v) => v.clone().try_into().map_err(|e| format!("[budget]: {e}"))?,
313 None => RawBudget::default(),
314 };
315 let commands: RawCommands = match table.get("commands") {
316 Some(v) => v
317 .clone()
318 .try_into()
319 .map_err(|e| format!("[commands]: {e}"))?,
320 None => RawCommands::default(),
321 };
322 let git: RawGit = match table.get("git") {
323 Some(v) => v.clone().try_into().map_err(|e| format!("[git]: {e}"))?,
324 None => RawGit::default(),
325 };
326 let commit_per_task = match git.commit.as_deref() {
327 None | Some("per-task") => true,
328 Some("none") => false,
329 Some(other) => return Err(format!("[git]: commit = \"{other}\" (per-task or none)")),
330 };
331 let acceptance: RawAcceptance = match table.get("acceptance") {
332 Some(v) => v
333 .clone()
334 .try_into()
335 .map_err(|e| format!("[acceptance]: {e}"))?,
336 None => RawAcceptance::default(),
337 };
338 let converge: RawConverge = match table.get("converge") {
339 Some(v) => v
340 .clone()
341 .try_into()
342 .map_err(|e| format!("[converge]: {e}"))?,
343 None => RawConverge::default(),
344 };
345 let review: RawReview = match table.get("review") {
346 Some(v) => v.clone().try_into().map_err(|e| format!("[review]: {e}"))?,
347 None => RawReview::default(),
348 };
349 let monitor: RawMonitor = match table.get("monitor") {
350 Some(v) => v
351 .clone()
352 .try_into()
353 .map_err(|e| format!("[monitor]: {e}"))?,
354 None => RawMonitor::default(),
355 };
356 let tests: RawTests = match table.get("tests") {
357 Some(v) => v.clone().try_into().map_err(|e| format!("[tests]: {e}"))?,
358 None => RawTests::default(),
359 };
360 let frozen_additions = match tests.frozen.as_deref() {
361 None | Some("ask") => false,
362 Some("additions") => true,
363 Some(other) => {
364 return Err(format!("[tests]: frozen = \"{other}\" (ask or additions)"));
365 }
366 };
367 let format: Option<RawFormat> = match table.get("format") {
368 Some(v) => Some(v.clone().try_into().map_err(|e| format!("[format]: {e}"))?),
369 None => None,
370 };
371 if format
372 .as_ref()
373 .is_some_and(|f| f.argv.first().is_none_or(String::is_empty))
374 {
375 return Err("[format]: `argv` must name a program".to_owned());
376 }
377 let approvals: RawApprovals = match table.get("approvals") {
378 Some(v) => v
379 .clone()
380 .try_into()
381 .map_err(|e| format!("[approvals]: {e}"))?,
382 None => RawApprovals::default(),
383 };
384 let raw_attest: RawAttest = match table.get("attest") {
385 Some(v) => v.clone().try_into().map_err(|e| format!("[attest]: {e}"))?,
386 None => RawAttest::default(),
387 };
388 let attest = AttestConfig {
389 enabled: raw_attest.enabled.unwrap_or(true),
390 signer: match raw_attest.signer.as_deref() {
391 None | Some("git") => Signer::Git,
392 Some("sigstore") => Signer::Sigstore,
393 Some("none") => Signer::None,
394 Some(other) => {
395 return Err(format!(
396 "[attest]: signer = \"{other}\" (git, sigstore or none)"
397 ));
398 }
399 },
400 sign_later: match raw_attest.sign_at.as_deref() {
401 None | Some("tick") => false,
402 Some("later") => true,
403 Some(other) => {
404 return Err(format!("[attest]: sign_at = \"{other}\" (tick or later)"));
405 }
406 },
407 sigstore_identity: raw_attest
408 .sigstore
409 .as_ref()
410 .and_then(|s| s.identity.clone()),
411 sigstore_issuer: raw_attest.sigstore.and_then(|s| s.issuer),
412 };
413 let context: RawContext = match table.get("context") {
414 Some(v) => v
415 .clone()
416 .try_into()
417 .map_err(|e| format!("[context]: {e}"))?,
418 None => RawContext::default(),
419 };
420 if let Some(bad) = context.instructions.iter().flatten().find(|n| {
421 n.is_empty() || n.contains('/') || n.contains('\\') || matches!(n.as_str(), "." | "..")
422 }) {
423 return Err(format!(
424 "[context]: instructions = \"{bad}\": a file name such as AGENTS.md, without a directory"
425 ));
426 }
427 let d = RunConfig::default();
428 let strength = {
429 let ds = StrengthConfig::default();
430 if let Some(m) = acceptance.min_strength
431 && !(0.0..=1.0).contains(&m)
432 {
433 return Err(format!(
434 "[acceptance]: min_strength = {m} (a share from 0.0 to 1.0)"
435 ));
436 }
437 let near_miss = acceptance.near_miss.unwrap_or(ds.near_miss);
438 if near_miss > 3 {
439 return Err(format!("[acceptance]: near_miss = {near_miss} (0 to 3)"));
440 }
441 let permitted = acceptance.permitted.unwrap_or(ds.permitted);
442 if permitted > 2 {
443 return Err(format!("[acceptance]: permitted = {permitted} (0 to 2)"));
444 }
445 StrengthConfig {
446 enabled: acceptance.strength.unwrap_or(ds.enabled),
447 min: acceptance.min_strength,
448 near_miss,
449 permitted,
450 timeout: acceptance
451 .strength_timeout_secs
452 .map_or(ds.timeout, |s| std::time::Duration::from_secs(s.max(1))),
453 }
454 };
455 let bug: RawBug = match table.get("bug") {
456 Some(v) => v.clone().try_into().map_err(|e| format!("[bug]: {e}"))?,
457 None => RawBug::default(),
458 };
459 let reproduce_tries = bug.reproduce_tries.unwrap_or(d.reproduce_tries);
460 if !(1..=speccoder_core::bug::MAX_TRIES).contains(&reproduce_tries) {
461 return Err(format!(
462 "[bug]: reproduce_tries = {reproduce_tries} (1 to {})",
463 speccoder_core::bug::MAX_TRIES
464 ));
465 }
466 if bug.test.as_ref().is_some_and(Vec::is_empty) {
467 return Err("[bug]: test = [] (a program and its arguments)".to_owned());
468 }
469 let approval_wait = match approvals.wait.as_deref() {
470 Some(w) => wait(w)?,
471 None => d.approval_wait,
472 };
473 let approval_budget = match approvals.budget {
474 None => d.approval_budget,
475 Some(n) => u32::try_from(n)
476 .ok()
477 .filter(|n| *n <= 100)
478 .ok_or_else(|| format!("[approvals]: budget = {n} (a whole number from 0 to 100)"))?,
479 };
480 let contained = match approvals.contained.as_deref() {
481 None | Some("allow") => true,
482 Some("ask") => false,
483 Some(other) => {
484 return Err(format!(
485 "[approvals]: contained = \"{other}\" (allow or ask)"
486 ));
487 }
488 };
489 let oracle_samples = match review.oracle_samples {
490 None => d.oracle_samples,
491 Some(n @ 1..=2) => u8::try_from(n).unwrap_or(d.oracle_samples),
492 Some(n) => return Err(format!("[review]: oracle_samples = {n} (1 or 2)")),
493 };
494 if let Some(why) = commands
495 .allow_prefix
496 .iter()
497 .find_map(|p| crate::tools::policy::prefix_refusal(p))
498 {
499 return Err(format!("[commands] allow_prefix: {why}"));
500 }
501 Ok(RunConfig {
502 max_turns: budget.max_turns.unwrap_or(d.max_turns).max(1),
503 max_tool_calls: budget.max_tool_calls.unwrap_or(d.max_tool_calls),
504 max_attempts: budget.max_attempts.unwrap_or(d.max_attempts).max(1),
505 best_of: budget.best_of.unwrap_or(d.best_of).clamp(1, 10),
506 allow: commands
507 .allow
508 .into_iter()
509 .filter(|p| !p.is_empty())
510 .collect(),
511 allow_prefix: commands.allow_prefix,
512 approval_budget,
513 contained,
514 commit_per_task,
515 acceptance: acceptance.generate.unwrap_or(true),
516 held_out: acceptance.held_out.unwrap_or(true),
517 strength,
518 max_converge_rounds: converge.max_rounds.unwrap_or(d.max_converge_rounds),
519 monitor: monitor.enabled.unwrap_or(false),
520 review_required: review.required.unwrap_or(false),
521 oracle_samples,
522 frozen_additions,
523 format: format.map(|f| f.argv),
524 approval_wait,
525 attest,
526 instructions: context.instructions.unwrap_or(d.instructions),
527 reproduce_tries,
528 bug_test: bug.test,
529 })
530}
531
532#[cfg(test)]
533mod tests {
534 use super::*;
535
536 #[test]
537 fn bug_tries_default_to_three_and_stay_within_five() {
538 assert_eq!(parse("").unwrap().reproduce_tries, 3);
539 assert_eq!(
540 parse("[bug]\nreproduce_tries = 5\n")
541 .unwrap()
542 .reproduce_tries,
543 5
544 );
545 assert!(parse("[bug]\nreproduce_tries = 6\n").is_err());
546 assert!(parse("[bug]\nreproduce_tries = 0\n").is_err());
547 assert!(parse("[bug]\ntries = 2\n").is_err());
548 let c = parse("[bug]\ntest = [\"sh\", \"t.sh\", \"{file}\", \"{name}\"]\n").unwrap();
549 assert_eq!(c.bug_test.unwrap_or_default().len(), 4);
550 }
551
552 #[test]
553 fn agents_md_is_read_unless_configured_otherwise() {
554 assert_eq!(parse("").unwrap().instructions, ["AGENTS.md"]);
555 let c = parse("[context]\ninstructions = [\"AGENTS.md\", \"CLAUDE.md\"]\n").unwrap();
556 assert_eq!(c.instructions, ["AGENTS.md", "CLAUDE.md"]);
557 assert!(
558 parse("[context]\ninstructions = []\n")
559 .unwrap()
560 .instructions
561 .is_empty()
562 );
563 assert!(parse("[context]\ninstructions = [\"docs/AGENTS.md\"]\n").is_err());
564 assert!(parse("[context]\nfiles = []\n").is_err());
565 }
566
567 #[test]
568 fn attestations_are_on_and_signed_by_git_unless_configured() {
569 let d = parse("").unwrap_or_default().attest;
570 assert!(d.enabled && d.signer == Signer::Git && !d.sign_later);
571 let c = parse("[attest]\nsigner = \"sigstore\"\nsign_at = \"later\"\n[attest.sigstore]\nidentity = \"ada@example.com\"\nissuer = \"https://accounts.example\"\n")
572 .unwrap_or_default()
573 .attest;
574 assert_eq!((c.signer, c.sign_later), (Signer::Sigstore, true));
575 assert_eq!(c.sigstore_identity.as_deref(), Some("ada@example.com"));
576 assert!(parse("[attest]\nsigner = \"x509\"\n").is_err());
577 assert!(parse("[attest]\nsign_at = \"never\"\n").is_err());
578 assert!(parse("[attest]\nkey = \"x\"\n").is_err());
579 }
580
581 #[test]
582 fn an_approval_waits_seven_days_unless_configured() {
583 assert_eq!(
584 parse("").unwrap_or_default().approval_wait.as_secs(),
585 7 * 86_400
586 );
587 let w = |t: &str| {
588 parse(&format!("[approvals]\nwait = \"{t}\"\n")).map(|c| c.approval_wait.as_secs())
589 };
590 assert_eq!(w("30m"), Ok(1800));
591 assert_eq!(w("12h"), Ok(43_200));
592 for bad in ["0d", "7", "7w", "-1d", ""] {
593 assert!(w(bad).is_err(), "{bad}");
594 }
595 }
596
597 #[test]
598 fn strength_is_measured_by_default_and_bounded() {
599 let c = parse("").unwrap().strength;
600 assert!(c.enabled && c.min.is_none());
601 assert_eq!((c.near_miss, c.permitted), (2, 1));
602 let c = parse("[acceptance]\nmin_strength = 0.8\nnear_miss = 3\npermitted = 0\nstrength_timeout_secs = 60\n")
603 .unwrap()
604 .strength;
605 assert_eq!(c.min, Some(0.8));
606 assert_eq!((c.near_miss, c.permitted, c.timeout.as_secs()), (3, 0, 60));
607 assert!(
608 !parse("[acceptance]\nstrength = false\n")
609 .unwrap()
610 .strength
611 .enabled
612 );
613 for bad in ["min_strength = 1.5", "near_miss = 4", "permitted = 3"] {
614 assert!(parse(&format!("[acceptance]\n{bad}\n")).is_err(), "{bad}");
615 }
616 }
617
618 #[test]
619 fn oracle_samples_are_one_or_two() {
620 assert_eq!(parse("").unwrap().oracle_samples, 2);
621 assert_eq!(
622 parse("[review]\noracle_samples = 1\n")
623 .unwrap()
624 .oracle_samples,
625 1
626 );
627 for bad in ["0", "3", "-1"] {
628 assert!(parse(&format!("[review]\noracle_samples = {bad}\n")).is_err());
629 }
630 }
631
632 #[test]
633 fn held_out_is_on_by_default_and_can_be_switched_off() {
634 assert!(parse("").unwrap().held_out);
635 let c = parse("[acceptance]\nheld_out = false\n").unwrap();
636 assert!(!c.held_out && c.acceptance);
637 assert!(parse("[acceptance]\nheldout = true\n").is_err());
638 }
639
640 #[test]
641 fn defaults_and_overrides() {
642 assert_eq!(parse("").unwrap(), RunConfig::default());
643 let c =
644 parse("[budget]\nmax_turns = 5\n[commands]\nallow = [[\"cargo\", \"fmt\"]]\n").unwrap();
645 assert_eq!(c.max_turns, 5);
646 assert_eq!(c.max_tool_calls, 100);
647 assert_eq!(c.max_attempts, 2);
648 assert_eq!(
649 parse("[budget]\nmax_attempts = 0\n").unwrap().max_attempts,
650 1
651 );
652 assert_eq!(c.allow, [vec!["cargo".to_owned(), "fmt".to_owned()]]);
653 assert!(parse("[budget]\nmax_cost = 1\n").is_err());
654 assert!(!parse("[git]\ncommit = \"none\"\n").unwrap().commit_per_task);
655 assert!(parse("[git]\ncommit = \"always\"\n").is_err());
656 assert!(
657 parse("[tests]\nfrozen = \"additions\"\n")
658 .unwrap()
659 .frozen_additions
660 );
661 assert!(
662 !parse("[tests]\nfrozen = \"ask\"\n")
663 .unwrap()
664 .frozen_additions
665 );
666 assert!(parse("[tests]\nfrozen = \"anything\"\n").is_err());
667 }
668
669 #[test]
670 fn the_asking_budget_and_prefixes_are_checked() {
671 assert_eq!(parse("").unwrap().approval_budget, 12);
672 assert_eq!(
673 parse("[approvals]\nbudget = 0\n").unwrap().approval_budget,
674 0
675 );
676 for bad in ["101", "-1"] {
677 let e = parse(&format!("[approvals]\nbudget = {bad}\n")).unwrap_err();
678 assert!(e.contains("budget"), "{e}");
679 }
680 let c = parse("[commands]\nallow_prefix = [[\"npm\", \"run\"]]\n").unwrap();
681 assert_eq!(c.allow_prefix, [vec!["npm".to_owned(), "run".to_owned()]]);
682 let e = parse("[commands]\nallow_prefix = [[\"python3\"]]\n").unwrap_err();
683 assert!(e.contains("a bare program allows any argument"), "{e}");
684 }
685
686 #[test]
687 fn contained_calls_are_allowed_unless_asked_for() {
688 assert!(parse("").unwrap().contained);
689 assert!(
690 parse("[approvals]\ncontained = \"allow\"\n")
691 .unwrap()
692 .contained
693 );
694 assert!(
695 !parse("[approvals]\ncontained = \"ask\"\n")
696 .unwrap()
697 .contained
698 );
699 let e = parse("[approvals]\ncontained = \"yes\"\n").unwrap_err();
700 assert_eq!(e, "[approvals]: contained = \"yes\" (allow or ask)");
701 }
702}