1use std::path::Path;
8
9use serde::{Deserialize, Serialize};
10
11use super::walk::Verdict;
12
13pub const PANEL_PROMPT_ROOT: &str = ".agents/sloop";
18
19pub const NO_VERDICT_REPORTED: &str = "no verdict reported";
21
22pub const MIN_PANEL_REVIEWERS: usize = 2;
25pub const MAX_PANEL_REVIEWERS: usize = 5;
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36pub struct Panel {
37 pub prompt: String,
41 pub reviewers: Vec<Reviewer>,
42 pub quorum: u32,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50pub struct Reviewer {
51 pub target: String,
54 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub model: Option<String>,
56 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub effort: Option<String>,
58}
59
60#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(rename_all = "snake_case")]
66pub enum Confidence {
67 Low,
68 #[default]
69 Medium,
70 High,
71}
72
73impl Confidence {
74 pub fn as_str(self) -> &'static str {
75 match self {
76 Self::Low => "low",
77 Self::Medium => "medium",
78 Self::High => "high",
79 }
80 }
81
82 pub fn parse(value: &str) -> Option<Self> {
83 match value {
84 "low" => Some(Self::Low),
85 "medium" => Some(Self::Medium),
86 "high" => Some(Self::High),
87 _ => None,
88 }
89 }
90}
91
92pub(super) fn parse_panel(stage: &str, raw: RawPanel) -> Result<Panel, String> {
98 let prompt = raw
99 .prompt
100 .map(|prompt| prompt.trim().to_owned())
101 .filter(|prompt| !prompt.is_empty())
102 .ok_or_else(|| format!("stage `{stage}` panel must define a non-empty `prompt`"))?;
103 if Path::new(&prompt).is_absolute() || prompt.split('/').any(|segment| segment == "..") {
104 return Err(format!(
105 "stage `{stage}` panel prompt must be a relative path under `{PANEL_PROMPT_ROOT}` \
106 without `..`"
107 ));
108 }
109 let reviewers = raw.reviewers.unwrap_or_default();
110 if !(MIN_PANEL_REVIEWERS..=MAX_PANEL_REVIEWERS).contains(&reviewers.len()) {
111 return Err(format!(
112 "stage `{stage}` panel must define between {MIN_PANEL_REVIEWERS} and \
113 {MAX_PANEL_REVIEWERS} reviewers; found {}",
114 reviewers.len()
115 ));
116 }
117 let reviewers = reviewers
118 .into_iter()
119 .map(|reviewer| {
120 let target = reviewer.target.trim().to_owned();
121 if target.is_empty() {
122 return Err(format!(
123 "stage `{stage}` panel reviewer must name a non-empty `target`"
124 ));
125 }
126 Ok(Reviewer {
127 target,
128 model: reviewer.model,
129 effort: reviewer.effort,
130 })
131 })
132 .collect::<Result<Vec<_>, _>>()?;
133 let quorum = raw
134 .require
135 .and_then(|require| require.quorum)
136 .unwrap_or(reviewers.len() as u32);
137 if quorum == 0 || quorum as usize > reviewers.len() {
138 return Err(format!(
139 "stage `{stage}` panel quorum must be between 1 and {}; found {quorum}",
140 reviewers.len()
141 ));
142 }
143 Ok(Panel {
144 prompt,
145 reviewers,
146 quorum,
147 })
148}
149
150#[derive(Debug, Deserialize)]
151pub(super) struct RawPanel {
152 prompt: Option<String>,
153 reviewers: Option<Vec<RawReviewer>>,
154 require: Option<RawRequire>,
155}
156
157#[derive(Debug, Deserialize)]
158struct RawReviewer {
159 target: String,
160 model: Option<String>,
161 effort: Option<String>,
162}
163
164#[derive(Debug, Deserialize)]
165struct RawRequire {
166 quorum: Option<u32>,
167}
168
169#[derive(Debug, Clone, PartialEq, Eq)]
171pub struct ReviewerReport {
172 pub verdict: Verdict,
173 pub confidence: Option<Confidence>,
176 pub reason: String,
177}
178
179impl ReviewerReport {
180 fn silent() -> Self {
184 Self {
185 verdict: Verdict::Fail,
186 confidence: None,
187 reason: NO_VERDICT_REPORTED.to_owned(),
188 }
189 }
190}
191
192#[derive(Debug, Clone, PartialEq, Eq)]
195pub struct PanelOutcome {
196 pub verdict: Verdict,
197 pub reports: Vec<ReviewerReport>,
199 pub reason: String,
201}
202
203pub fn aggregate(panel: &Panel, reported: &[Option<ReviewerReport>]) -> PanelOutcome {
217 let reports: Vec<ReviewerReport> = (0..panel.reviewers.len())
218 .map(|seat| {
219 reported
220 .get(seat)
221 .cloned()
222 .flatten()
223 .unwrap_or_else(ReviewerReport::silent)
224 })
225 .collect();
226 let passed = reports
227 .iter()
228 .filter(|report| report.verdict == Verdict::Pass)
229 .count();
230 let verdict = if passed as u64 >= u64::from(panel.quorum) {
231 Verdict::Pass
232 } else {
233 Verdict::Fail
234 };
235 PanelOutcome {
236 verdict,
237 reason: format!(
238 "panel: {passed} of {} reviewers passed, quorum {}",
239 reports.len(),
240 panel.quorum,
241 ),
242 reports,
243 }
244}
245
246#[cfg(test)]
247mod tests {
248 use crate::flow::{
249 Check, Confidence, Flow, NO_VERDICT_REPORTED, Panel, Reviewer, ReviewerReport, Verdict,
250 aggregate, parse,
251 };
252
253 fn error(yaml: &str) -> String {
254 parse("example", yaml).unwrap_err()
255 }
256
257 fn panel_yaml(reviewers: &str, require: &str) -> String {
258 format!(
259 "- name: build\n action: agent\n result_check:\n panel:\n prompt: prompts/review.md\n reviewers: {reviewers}\n{require}"
260 )
261 }
262
263 fn panel_of(seats: usize, quorum: u32) -> Panel {
264 Panel {
265 prompt: "prompts/review.md".into(),
266 reviewers: (0..seats)
267 .map(|seat| Reviewer {
268 target: format!("target{seat}"),
269 model: None,
270 effort: None,
271 })
272 .collect(),
273 quorum,
274 }
275 }
276
277 fn report(verdict: Verdict) -> Option<ReviewerReport> {
278 Some(ReviewerReport {
279 verdict,
280 confidence: Some(Confidence::Medium),
281 reason: "considered".into(),
282 })
283 }
284
285 #[test]
286 fn a_panel_parses_with_its_seats_and_quorum() {
287 let flow = parse(
288 "example",
289 &panel_yaml(
290 "[{ target: claude }, { target: codex, model: gpt, effort: high }]",
291 " require: { quorum: 1 }\n",
292 ),
293 )
294 .unwrap();
295
296 assert_eq!(
297 flow.stages[0].result_check,
298 Check::Panel(Panel {
299 prompt: "prompts/review.md".into(),
300 reviewers: vec![
301 Reviewer {
302 target: "claude".into(),
303 model: None,
304 effort: None,
305 },
306 Reviewer {
307 target: "codex".into(),
308 model: Some("gpt".into()),
309 effort: Some("high".into()),
310 },
311 ],
312 quorum: 1,
313 })
314 );
315 }
316
317 #[test]
320 fn an_unstated_quorum_is_unanimity() {
321 let flow = parse(
322 "example",
323 &panel_yaml("[{ target: a }, { target: b }, { target: c }]", ""),
324 )
325 .unwrap();
326
327 let Check::Panel(panel) = &flow.stages[0].result_check else {
328 panic!("expected a panel");
329 };
330 assert_eq!(panel.quorum, 3);
331 }
332
333 #[test]
334 fn a_panel_survives_a_snapshot_round_trip() {
335 let flow = parse(
336 "example",
337 &panel_yaml(
338 "[{ target: claude }, { target: codex }]",
339 " require: { quorum: 2 }\n",
340 ),
341 )
342 .unwrap();
343
344 let snapshot = serde_json::to_string(&flow).unwrap();
345 assert_eq!(serde_json::from_str::<Flow>(&snapshot).unwrap(), flow);
346 }
347
348 #[test]
349 fn a_panel_must_seat_between_two_and_five_reviewers() {
350 for reviewers in [
351 "[]",
352 "[{ target: a }]",
353 "[{target: a}, {target: b}, {target: c}, {target: d}, {target: e}, {target: f}]",
354 ] {
355 let error = error(&panel_yaml(reviewers, ""));
356 assert!(
357 error.contains("panel must define between 2 and 5 reviewers"),
358 "{error}"
359 );
360 }
361 }
362
363 #[test]
364 fn a_panel_quorum_must_fit_its_seats() {
365 for quorum in ["0", "3"] {
366 let error = error(&panel_yaml(
367 "[{ target: a }, { target: b }]",
368 &format!(" require: {{ quorum: {quorum} }}\n"),
369 ));
370 assert!(error.contains("stage `build`"), "{error}");
371 assert!(
372 error.contains("panel quorum must be between 1 and 2"),
373 "{error}"
374 );
375 }
376 }
377
378 #[test]
379 fn a_panel_prompt_must_be_a_relative_path_inside_the_sloop_directory() {
380 let missing = error(
381 "- name: build\n action: agent\n result_check:\n panel:\n reviewers: [{target: a}, {target: b}]\n",
382 );
383 assert!(
384 missing.contains("panel must define a non-empty `prompt`"),
385 "{missing}"
386 );
387
388 for prompt in ["/etc/passwd", "../../secrets.md"] {
389 let escaping = error(&format!(
390 "- name: build\n action: agent\n result_check:\n panel:\n prompt: {prompt}\n reviewers: [{{target: a}}, {{target: b}}]\n",
391 ));
392 assert!(
393 escaping.contains("must be a relative path under `.agents/sloop`"),
394 "{escaping}"
395 );
396 }
397 }
398
399 #[test]
402 fn panel_seats_count_towards_the_worst_case_execution_budget() {
403 let flow = |seats: &str| {
404 format!(
405 "- name: build\n action: agent\n result_check:\n panel:\n prompt: prompts/review.md\n reviewers: {seats}\n- {{ name: lint, action: {{ exec: ['true'] }} }}\n- {{ name: audit, action: {{ exec: ['true'] }} }}\n- {{ name: test, action: {{ exec: ['true'] }}, fail_action: {{ return_to: build, attempts: 3 }} }}\n",
406 )
407 };
408
409 let bounded = parse("example", &flow("[{target: a}, {target: b}, {target: c}]"));
410 assert!(bounded.is_ok(), "{bounded:?}");
411
412 let error = error(&flow(
413 "[{target: a}, {target: b}, {target: c}, {target: d}, {target: e}]",
414 ));
415 assert!(
416 error.contains("at most 32 stages in the worst case"),
417 "{error}"
418 );
419 assert!(error.contains("imply 36"), "{error}");
420 }
421
422 #[test]
428 fn aggregation_is_a_pass_count_against_the_quorum() {
429 let states = [report(Verdict::Pass), report(Verdict::Fail), None];
430 for seats in [2usize, 3] {
431 for quorum in 1..=seats as u32 {
432 let panel = panel_of(seats, quorum);
433 for combination in 0..states.len().pow(seats as u32) {
434 let reported: Vec<Option<ReviewerReport>> = (0..seats)
435 .map(|seat| states[combination / states.len().pow(seat as u32) % 3].clone())
436 .collect();
437 let passes = reported
438 .iter()
439 .filter(|report| {
440 report.as_ref().map(|report| report.verdict) == Some(Verdict::Pass)
441 })
442 .count();
443
444 let outcome = aggregate(&panel, &reported);
445
446 let expected = if passes as u32 >= quorum {
447 Verdict::Pass
448 } else {
449 Verdict::Fail
450 };
451 assert_eq!(
452 outcome.verdict, expected,
453 "seats {seats}, quorum {quorum}, reports {reported:?}"
454 );
455 assert_eq!(outcome.reports.len(), seats);
456 assert_eq!(
457 outcome.reason,
458 format!("panel: {passes} of {seats} reviewers passed, quorum {quorum}")
459 );
460 }
461 }
462 }
463 }
464
465 #[test]
469 fn a_silent_reviewer_fills_its_seat_with_a_fail() {
470 let panel = panel_of(3, 2);
471
472 let outcome = aggregate(
473 &panel,
474 &[report(Verdict::Pass), None, report(Verdict::Pass)],
475 );
476 assert_eq!(outcome.verdict, Verdict::Pass);
477 assert_eq!(outcome.reports[1].verdict, Verdict::Fail);
478 assert_eq!(outcome.reports[1].confidence, None);
479 assert_eq!(outcome.reports[1].reason, NO_VERDICT_REPORTED);
480
481 let truncated = aggregate(&panel, &[report(Verdict::Pass)]);
482 assert_eq!(truncated.verdict, Verdict::Fail);
483 assert_eq!(truncated.reports.len(), 3);
484 }
485
486 #[test]
490 fn confidence_is_recorded_but_never_weighted() {
491 let panel = panel_of(3, 2);
492 let sure = |verdict, confidence| {
493 Some(ReviewerReport {
494 verdict,
495 confidence: Some(confidence),
496 reason: "considered".into(),
497 })
498 };
499
500 let outcome = aggregate(
501 &panel,
502 &[
503 sure(Verdict::Pass, Confidence::Low),
504 sure(Verdict::Pass, Confidence::Low),
505 sure(Verdict::Fail, Confidence::High),
506 ],
507 );
508
509 assert_eq!(outcome.verdict, Verdict::Pass);
510 assert_eq!(outcome.reports[2].confidence, Some(Confidence::High));
511 }
512
513 #[test]
516 fn a_panel_may_not_judge_a_merge_stage() {
517 let error = error(
518 "- { name: build, action: agent }\n- name: merge\n action: { builtin: merge }\n result_check:\n panel:\n prompt: prompts/review.md\n reviewers: [{target: a}, {target: b}]\n",
519 );
520 assert!(error.contains("must have `result_check: none`"), "{error}");
521 }
522}