Skip to main content

sloop/flow/
panel.rs

1//! Panels: several independent reviewers judging one stage, and the pure
2//! count over their reports that decides it. The whole slice lives here —
3//! the types, the parse-time validation of a `panel:` block, and the
4//! aggregation the driver derives at read time — because the walk does not
5//! know panels exist and the grammar only needs the one entry point.
6
7use std::path::Path;
8
9use serde::{Deserialize, Serialize};
10
11use super::walk::Verdict;
12
13/// The directory a panel's `prompt` path is resolved against. Panel prompts
14/// are committed files like every other piece of flow configuration, so the
15/// path is repository-relative under the Sloop directory rather than absolute
16/// or relative to whatever the daemon's working directory happens to be.
17pub const PANEL_PROMPT_ROOT: &str = ".agents/sloop";
18
19/// The reason a reviewer that never reported is credited with.
20pub const NO_VERDICT_REPORTED: &str = "no verdict reported";
21
22/// A panel smaller than this is not a panel, and one larger than this costs
23/// more tokens than any v1 quorum rule can justify.
24pub const MIN_PANEL_REVIEWERS: usize = 2;
25pub const MAX_PANEL_REVIEWERS: usize = 5;
26
27/// A panel of independent reviewers.
28///
29/// One agentic reviewer is one uncalibrated opinion. A panel buys independence
30/// with tokens: each reviewer examines the run alone and reports, and the stage
31/// verdict is a *deterministic count* over the reports rather than anything a
32/// reviewer decided. The opinions stay untrusted; the procedure over them is
33/// kernel code, which is why [`aggregate`] lives here beside the walk and is
34/// tested without an LLM anywhere near it.
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36pub struct Panel {
37    /// The prompt file every reviewer is handed, relative to
38    /// [`PANEL_PROMPT_ROOT`]. One prompt for the whole panel: per-reviewer
39    /// prompts would make the reports incomparable.
40    pub prompt: String,
41    pub reviewers: Vec<Reviewer>,
42    /// How many `Pass` reports the stage needs. `1..=reviewers.len()`.
43    pub quorum: u32,
44}
45
46/// One seat on a panel. Only the target and its model/effort vary: the point
47/// of a panel is decorrelated failure modes, and the cheapest way to buy them
48/// is to seat different vendors.
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50pub struct Reviewer {
51    /// An entry under `agent.targets` in config.yaml. Existence is checked
52    /// where the configured targets are known (see `config.rs`).
53    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/// How sure a reviewer says it is. Recorded evidence only: v1 aggregation
61/// counts reports and never weights them, so a confident wrong reviewer
62/// outvotes nobody. Floats are deliberately absent — a scalar invites a
63/// weighting rule that has not been designed.
64#[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
92/// Validates a panel block's own shape. Reviewer targets are checked later,
93/// where the configured agent targets are known (see `config.rs`); everything
94/// that can be decided from the flow file alone is decided here, so a flow
95/// whose panel could never run is refused at parse time rather than at the
96/// moment it would have spawned five agents.
97pub(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/// One reviewer's report, as the aggregation reads it back from evidence.
170#[derive(Debug, Clone, PartialEq, Eq)]
171pub struct ReviewerReport {
172    pub verdict: Verdict,
173    /// Absent only for a reviewer that never reported: it has no confidence to
174    /// record. Present reports default to [`Confidence::Medium`].
175    pub confidence: Option<Confidence>,
176    pub reason: String,
177}
178
179impl ReviewerReport {
180    /// What a reviewer that exited without reporting counts as. Silence is not
181    /// an abstention: a panel that could not be heard from has not approved
182    /// anything, so the seat is filled with a `Fail` and says why.
183    fn silent() -> Self {
184        Self {
185            verdict: Verdict::Fail,
186            confidence: None,
187            reason: NO_VERDICT_REPORTED.to_owned(),
188        }
189    }
190}
191
192/// A panel's derived reading: every seat filled, and the verdict the count
193/// over them yields.
194#[derive(Debug, Clone, PartialEq, Eq)]
195pub struct PanelOutcome {
196    pub verdict: Verdict,
197    /// One entry per seat, in reviewer order, silent seats included.
198    pub reports: Vec<ReviewerReport>,
199    /// The tally, in one clause fit to quote back to an agent.
200    pub reason: String,
201}
202
203/// Derives a panel's verdict from its reviewers' reports.
204///
205/// This is the whole aggregation, and it is deliberately dull: `Pass` iff at
206/// least `quorum` seats reported `Pass`. Confidence is carried through as
207/// evidence and never consulted; there is no veto and no weighting, so a panel
208/// behaves the same way every time and an operator can predict it from the
209/// config alone.
210///
211/// `reported` is indexed by reviewer; a `None` — or a short slice, which is
212/// what a stage abandoned part-way through its panel leaves — fills that seat
213/// with [`ReviewerReport::silent`]. Nothing here reads a clock, a process, or
214/// the store, so the aggregate is *derived at read time* rather than stored:
215/// a resumed run recomputes the identical verdict from the identical rows.
216pub 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    /// A rule nobody wrote down must not silently be the most permissive one
318    /// it could have been.
319    #[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    /// A panel spawns one process per seat, and the execution cap exists so
400    /// nobody discovers the total at runtime.
401    #[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    /// The whole aggregation, enumerated. Three states per seat — `Pass`,
423    /// `Fail`, and the silence that counts as a `Fail` — across every quorum a
424    /// two- and three-seat panel can name. Nothing here touches an LLM, a
425    /// clock, or the store, which is the point: the opinions are untrusted and
426    /// the procedure over them is not.
427    #[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    /// Silence is not an abstention. A seat nobody heard from has approved
466    /// nothing, and says so in the words the rest of the system already uses
467    /// for an unreported verdict.
468    #[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    /// Confidence rides along as evidence and is never weighted: three
487    /// low-confidence passes beat two high-confidence fails, because the
488    /// quorum says so and nothing else does.
489    #[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    /// A panel is a check, never an action, and the merge stage keeps its
514    /// standing prohibition on being judged at all.
515    #[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}