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    // The path is joined onto the repository's Sloop directory, so anything
104    // that could escape it is refused rather than resolved.
105    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    // An unstated quorum is unanimity: a panel whose rule was never written
136    // down must not silently be the most permissive one it could have been.
137    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/// One reviewer's report, as the aggregation reads it back from evidence.
174#[derive(Debug, Clone, PartialEq, Eq)]
175pub struct ReviewerReport {
176    pub verdict: Verdict,
177    /// Absent only for a reviewer that never reported: it has no confidence to
178    /// record. Present reports default to [`Confidence::Medium`].
179    pub confidence: Option<Confidence>,
180    pub reason: String,
181}
182
183impl ReviewerReport {
184    /// What a reviewer that exited without reporting counts as. Silence is not
185    /// an abstention: a panel that could not be heard from has not approved
186    /// anything, so the seat is filled with a `Fail` and says why.
187    fn silent() -> Self {
188        Self {
189            verdict: Verdict::Fail,
190            confidence: None,
191            reason: NO_VERDICT_REPORTED.to_owned(),
192        }
193    }
194}
195
196/// A panel's derived reading: every seat filled, and the verdict the count
197/// over them yields.
198#[derive(Debug, Clone, PartialEq, Eq)]
199pub struct PanelOutcome {
200    pub verdict: Verdict,
201    /// One entry per seat, in reviewer order, silent seats included.
202    pub reports: Vec<ReviewerReport>,
203    /// The tally, in one clause fit to quote back to an agent.
204    pub reason: String,
205}
206
207/// Derives a panel's verdict from its reviewers' reports.
208///
209/// This is the whole aggregation, and it is deliberately dull: `Pass` iff at
210/// least `quorum` seats reported `Pass`. Confidence is carried through as
211/// evidence and never consulted; there is no veto and no weighting, so a panel
212/// behaves the same way every time and an operator can predict it from the
213/// config alone.
214///
215/// `reported` is indexed by reviewer; a `None` — or a short slice, which is
216/// what a stage abandoned part-way through its panel leaves — fills that seat
217/// with [`ReviewerReport::silent`]. Nothing here reads a clock, a process, or
218/// the store, so the aggregate is *derived at read time* rather than stored:
219/// a resumed run recomputes the identical verdict from the identical rows.
220pub 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    /// A rule nobody wrote down must not silently be the most permissive one
322    /// it could have been.
323    #[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    /// A panel spawns one process per seat, and the execution cap exists so
404    /// nobody discovers the total at runtime.
405    #[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        // Four stages whose panel seats three: the base walk costs seven
414        // executions and each of the three re-runs costs the same seven, for
415        // twenty-eight. Under the cap.
416        let bounded = parse("example", &flow("[{target: a}, {target: b}, {target: c}]"));
417        assert!(bounded.is_ok(), "{bounded:?}");
418
419        // Widen the same panel to five seats and every one of those four
420        // passes costs nine instead of seven — thirty-six in total, which the
421        // cap refuses at parse time rather than at the thirty-third spawn.
422        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    /// The whole aggregation, enumerated. Three states per seat — `Pass`,
433    /// `Fail`, and the silence that counts as a `Fail` — across every quorum a
434    /// two- and three-seat panel can name. Nothing here touches an LLM, a
435    /// clock, or the store, which is the point: the opinions are untrusted and
436    /// the procedure over them is not.
437    #[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                // Every assignment of the three states to the seats.
444                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                    // Every seat is accounted for, whether it spoke or not.
467                    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    /// Silence is not an abstention. A seat nobody heard from has approved
478    /// nothing, and says so in the words the rest of the system already uses
479    /// for an unreported verdict.
480    #[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        // A stage abandoned before its last seats ran leaves a short slice,
494        // which fills out the same way rather than shrinking the panel.
495        let truncated = aggregate(&panel, &[report(Verdict::Pass)]);
496        assert_eq!(truncated.verdict, Verdict::Fail);
497        assert_eq!(truncated.reports.len(), 3);
498    }
499
500    /// Confidence rides along as evidence and is never weighted: three
501    /// low-confidence passes beat two high-confidence fails, because the
502    /// quorum says so and nothing else does.
503    #[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    /// A panel is a check, never an action, and the merge stage keeps its
528    /// standing prohibition on being judged at all.
529    #[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}