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 == "..") {
106 return Err(format!(
107 "stage `{stage}` panel prompt must be a relative path under `{PANEL_PROMPT_ROOT}` \
108 without `..`"
109 ));
110 }
111 let reviewers = raw.reviewers.unwrap_or_default();
112 if !(MIN_PANEL_REVIEWERS..=MAX_PANEL_REVIEWERS).contains(&reviewers.len()) {
113 return Err(format!(
114 "stage `{stage}` panel must define between {MIN_PANEL_REVIEWERS} and \
115 {MAX_PANEL_REVIEWERS} reviewers; found {}",
116 reviewers.len()
117 ));
118 }
119 let reviewers = reviewers
120 .into_iter()
121 .map(|reviewer| {
122 let target = reviewer.target.trim().to_owned();
123 if target.is_empty() {
124 return Err(format!(
125 "stage `{stage}` panel reviewer must name a non-empty `target`"
126 ));
127 }
128 Ok(Reviewer {
129 target,
130 model: reviewer.model,
131 effort: reviewer.effort,
132 })
133 })
134 .collect::<Result<Vec<_>, _>>()?;
135 let quorum = raw
138 .require
139 .and_then(|require| require.quorum)
140 .unwrap_or(reviewers.len() as u32);
141 if quorum == 0 || quorum as usize > reviewers.len() {
142 return Err(format!(
143 "stage `{stage}` panel quorum must be between 1 and {}; found {quorum}",
144 reviewers.len()
145 ));
146 }
147 Ok(Panel {
148 prompt,
149 reviewers,
150 quorum,
151 })
152}
153
154#[derive(Debug, Deserialize)]
155pub(super) struct RawPanel {
156 prompt: Option<String>,
157 reviewers: Option<Vec<RawReviewer>>,
158 require: Option<RawRequire>,
159}
160
161#[derive(Debug, Deserialize)]
162struct RawReviewer {
163 target: String,
164 model: Option<String>,
165 effort: Option<String>,
166}
167
168#[derive(Debug, Deserialize)]
169struct RawRequire {
170 quorum: Option<u32>,
171}
172
173#[derive(Debug, Clone, PartialEq, Eq)]
175pub struct ReviewerReport {
176 pub verdict: Verdict,
177 pub confidence: Option<Confidence>,
180 pub reason: String,
181}
182
183impl ReviewerReport {
184 fn silent() -> Self {
188 Self {
189 verdict: Verdict::Fail,
190 confidence: None,
191 reason: NO_VERDICT_REPORTED.to_owned(),
192 }
193 }
194}
195
196#[derive(Debug, Clone, PartialEq, Eq)]
199pub struct PanelOutcome {
200 pub verdict: Verdict,
201 pub reports: Vec<ReviewerReport>,
203 pub reason: String,
205}
206
207pub fn aggregate(panel: &Panel, reported: &[Option<ReviewerReport>]) -> PanelOutcome {
221 let reports: Vec<ReviewerReport> = (0..panel.reviewers.len())
222 .map(|seat| {
223 reported
224 .get(seat)
225 .cloned()
226 .flatten()
227 .unwrap_or_else(ReviewerReport::silent)
228 })
229 .collect();
230 let passed = reports
231 .iter()
232 .filter(|report| report.verdict == Verdict::Pass)
233 .count();
234 let verdict = if passed as u64 >= u64::from(panel.quorum) {
235 Verdict::Pass
236 } else {
237 Verdict::Fail
238 };
239 PanelOutcome {
240 verdict,
241 reason: format!(
242 "panel: {passed} of {} reviewers passed, quorum {}",
243 reports.len(),
244 panel.quorum,
245 ),
246 reports,
247 }
248}
249
250#[cfg(test)]
251mod tests {
252 use crate::flow::{
253 Check, Confidence, Flow, NO_VERDICT_REPORTED, Panel, Reviewer, ReviewerReport, Verdict,
254 aggregate, parse,
255 };
256
257 fn error(yaml: &str) -> String {
258 parse("example", yaml).unwrap_err()
259 }
260
261 fn panel_yaml(reviewers: &str, require: &str) -> String {
262 format!(
263 "- name: build\n action: agent\n result_check:\n panel:\n prompt: prompts/review.md\n reviewers: {reviewers}\n{require}"
264 )
265 }
266
267 fn panel_of(seats: usize, quorum: u32) -> Panel {
268 Panel {
269 prompt: "prompts/review.md".into(),
270 reviewers: (0..seats)
271 .map(|seat| Reviewer {
272 target: format!("target{seat}"),
273 model: None,
274 effort: None,
275 })
276 .collect(),
277 quorum,
278 }
279 }
280
281 fn report(verdict: Verdict) -> Option<ReviewerReport> {
282 Some(ReviewerReport {
283 verdict,
284 confidence: Some(Confidence::Medium),
285 reason: "considered".into(),
286 })
287 }
288
289 #[test]
290 fn a_panel_parses_with_its_seats_and_quorum() {
291 let flow = parse(
292 "example",
293 &panel_yaml(
294 "[{ target: claude }, { target: codex, model: gpt, effort: high }]",
295 " require: { quorum: 1 }\n",
296 ),
297 )
298 .unwrap();
299
300 assert_eq!(
301 flow.stages[0].result_check,
302 Check::Panel(Panel {
303 prompt: "prompts/review.md".into(),
304 reviewers: vec![
305 Reviewer {
306 target: "claude".into(),
307 model: None,
308 effort: None,
309 },
310 Reviewer {
311 target: "codex".into(),
312 model: Some("gpt".into()),
313 effort: Some("high".into()),
314 },
315 ],
316 quorum: 1,
317 })
318 );
319 }
320
321 #[test]
324 fn an_unstated_quorum_is_unanimity() {
325 let flow = parse(
326 "example",
327 &panel_yaml("[{ target: a }, { target: b }, { target: c }]", ""),
328 )
329 .unwrap();
330
331 let Check::Panel(panel) = &flow.stages[0].result_check else {
332 panic!("expected a panel");
333 };
334 assert_eq!(panel.quorum, 3);
335 }
336
337 #[test]
338 fn a_panel_survives_a_snapshot_round_trip() {
339 let flow = parse(
340 "example",
341 &panel_yaml(
342 "[{ target: claude }, { target: codex }]",
343 " require: { quorum: 2 }\n",
344 ),
345 )
346 .unwrap();
347
348 let snapshot = serde_json::to_string(&flow).unwrap();
349 assert_eq!(serde_json::from_str::<Flow>(&snapshot).unwrap(), flow);
350 }
351
352 #[test]
353 fn a_panel_must_seat_between_two_and_five_reviewers() {
354 for reviewers in [
355 "[]",
356 "[{ target: a }]",
357 "[{target: a}, {target: b}, {target: c}, {target: d}, {target: e}, {target: f}]",
358 ] {
359 let error = error(&panel_yaml(reviewers, ""));
360 assert!(
361 error.contains("panel must define between 2 and 5 reviewers"),
362 "{error}"
363 );
364 }
365 }
366
367 #[test]
368 fn a_panel_quorum_must_fit_its_seats() {
369 for quorum in ["0", "3"] {
370 let error = error(&panel_yaml(
371 "[{ target: a }, { target: b }]",
372 &format!(" require: {{ quorum: {quorum} }}\n"),
373 ));
374 assert!(error.contains("stage `build`"), "{error}");
375 assert!(
376 error.contains("panel quorum must be between 1 and 2"),
377 "{error}"
378 );
379 }
380 }
381
382 #[test]
383 fn a_panel_prompt_must_be_a_relative_path_inside_the_sloop_directory() {
384 let missing = error(
385 "- name: build\n action: agent\n result_check:\n panel:\n reviewers: [{target: a}, {target: b}]\n",
386 );
387 assert!(
388 missing.contains("panel must define a non-empty `prompt`"),
389 "{missing}"
390 );
391
392 for prompt in ["/etc/passwd", "../../secrets.md"] {
393 let escaping = error(&format!(
394 "- name: build\n action: agent\n result_check:\n panel:\n prompt: {prompt}\n reviewers: [{{target: a}}, {{target: b}}]\n",
395 ));
396 assert!(
397 escaping.contains("must be a relative path under `.agents/sloop`"),
398 "{escaping}"
399 );
400 }
401 }
402
403 #[test]
406 fn panel_seats_count_towards_the_worst_case_execution_budget() {
407 let flow = |seats: &str| {
408 format!(
409 "- 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",
410 )
411 };
412
413 let bounded = parse("example", &flow("[{target: a}, {target: b}, {target: c}]"));
417 assert!(bounded.is_ok(), "{bounded:?}");
418
419 let error = error(&flow(
423 "[{target: a}, {target: b}, {target: c}, {target: d}, {target: e}]",
424 ));
425 assert!(
426 error.contains("at most 32 stages in the worst case"),
427 "{error}"
428 );
429 assert!(error.contains("imply 36"), "{error}");
430 }
431
432 #[test]
438 fn aggregation_is_a_pass_count_against_the_quorum() {
439 let states = [report(Verdict::Pass), report(Verdict::Fail), None];
440 for seats in [2usize, 3] {
441 for quorum in 1..=seats as u32 {
442 let panel = panel_of(seats, quorum);
443 for combination in 0..states.len().pow(seats as u32) {
445 let reported: Vec<Option<ReviewerReport>> = (0..seats)
446 .map(|seat| states[combination / states.len().pow(seat as u32) % 3].clone())
447 .collect();
448 let passes = reported
449 .iter()
450 .filter(|report| {
451 report.as_ref().map(|report| report.verdict) == Some(Verdict::Pass)
452 })
453 .count();
454
455 let outcome = aggregate(&panel, &reported);
456
457 let expected = if passes as u32 >= quorum {
458 Verdict::Pass
459 } else {
460 Verdict::Fail
461 };
462 assert_eq!(
463 outcome.verdict, expected,
464 "seats {seats}, quorum {quorum}, reports {reported:?}"
465 );
466 assert_eq!(outcome.reports.len(), seats);
468 assert_eq!(
469 outcome.reason,
470 format!("panel: {passes} of {seats} reviewers passed, quorum {quorum}")
471 );
472 }
473 }
474 }
475 }
476
477 #[test]
481 fn a_silent_reviewer_fills_its_seat_with_a_fail() {
482 let panel = panel_of(3, 2);
483
484 let outcome = aggregate(
485 &panel,
486 &[report(Verdict::Pass), None, report(Verdict::Pass)],
487 );
488 assert_eq!(outcome.verdict, Verdict::Pass);
489 assert_eq!(outcome.reports[1].verdict, Verdict::Fail);
490 assert_eq!(outcome.reports[1].confidence, None);
491 assert_eq!(outcome.reports[1].reason, NO_VERDICT_REPORTED);
492
493 let truncated = aggregate(&panel, &[report(Verdict::Pass)]);
496 assert_eq!(truncated.verdict, Verdict::Fail);
497 assert_eq!(truncated.reports.len(), 3);
498 }
499
500 #[test]
504 fn confidence_is_recorded_but_never_weighted() {
505 let panel = panel_of(3, 2);
506 let sure = |verdict, confidence| {
507 Some(ReviewerReport {
508 verdict,
509 confidence: Some(confidence),
510 reason: "considered".into(),
511 })
512 };
513
514 let outcome = aggregate(
515 &panel,
516 &[
517 sure(Verdict::Pass, Confidence::Low),
518 sure(Verdict::Pass, Confidence::Low),
519 sure(Verdict::Fail, Confidence::High),
520 ],
521 );
522
523 assert_eq!(outcome.verdict, Verdict::Pass);
524 assert_eq!(outcome.reports[2].confidence, Some(Confidence::High));
525 }
526
527 #[test]
530 fn a_panel_may_not_judge_a_merge_stage() {
531 let error = error(
532 "- { 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",
533 );
534 assert!(error.contains("must have `result_check: none`"), "{error}");
535 }
536}