Skip to main content

bamboo_engine/runtime/
guardian_state.rs

1//! Durable per-session guardian-review state for the adversarial completion gate.
2//!
3//! The guardian feature is a sibling of the goal loop ([`crate::runtime::goal_state`]):
4//! when the main agent would otherwise complete the run, the runtime can spawn a
5//! read-only **guardian child** to adversarially review the final diff against
6//! the active task's completion criteria before the run is allowed to stop. This
7//! module owns the durable record that ties a single review pass together.
8//!
9//! Like `goal_state`, it lives in `session.metadata` under
10//! [`GUARDIAN_STATE_METADATA_KEY`] as a single JSON value (the established
11//! Bamboo pattern for structured, session-scoped state), so it round-trips
12//! through the normal session save/load path with no new storage entity.
13//!
14//! The review budget mirrors the goal loop's `continuation_count`: a count-based
15//! cap ([`GuardianState::review_count`] checked against a configured max via
16//! [`GuardianState::budget_exhausted`]) so a pathological review → fix → review
17//! cycle cannot run unbounded. The cap value itself lives with the caller (the
18//! terminal gate), mirroring how `continuation_count` is enforced in `gold.rs`
19//! rather than in `goal_state.rs`.
20
21use std::collections::BTreeSet;
22
23use bamboo_agent_core::Session;
24use chrono::Utc;
25use serde::{Deserialize, Serialize};
26
27/// Session metadata key holding the serialized [`GuardianState`] JSON blob.
28pub const GUARDIAN_STATE_METADATA_KEY: &str = "guardian.state";
29
30/// Upper bound on retained findings on a single verdict, so a runaway reviewer
31/// cannot grow the persisted blob without limit. The newest findings are kept.
32const MAX_FINDINGS: usize = 50;
33
34/// Lifecycle phase of the current guardian review pass.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(rename_all = "snake_case")]
37pub enum GuardianPhase {
38    /// No review is outstanding for the current terminal point.
39    None,
40    /// A guardian child has been spawned and a verdict is awaited.
41    Pending,
42    /// A verdict has been recorded for the current terminal point.
43    Reviewed,
44}
45
46impl GuardianPhase {
47    pub fn as_str(self) -> &'static str {
48        match self {
49            Self::None => "none",
50            Self::Pending => "pending",
51            Self::Reviewed => "reviewed",
52        }
53    }
54
55    /// Whether a guardian child is currently in flight.
56    pub fn is_pending(self) -> bool {
57        matches!(self, Self::Pending)
58    }
59
60    /// Whether a verdict has already been recorded for this pass.
61    pub fn is_reviewed(self) -> bool {
62        matches!(self, Self::Reviewed)
63    }
64}
65
66/// A single guardian review verdict, parsed from the reviewer child's final
67/// message (see the G5 verdict parser). The JSON shape the reviewer is asked to
68/// emit matches this struct's serde representation.
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct GuardianVerdict {
71    /// Whether the guardian approves stopping the run.
72    pub approve: bool,
73    /// The reviewer's short rationale, if provided.
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub summary: Option<String>,
76    /// Concrete findings the guardian wants addressed before approval.
77    #[serde(default, skip_serializing_if = "Vec::is_empty")]
78    pub findings: Vec<String>,
79}
80
81impl GuardianVerdict {
82    /// Build an approving verdict with no findings.
83    pub fn approved() -> Self {
84        Self {
85            approve: true,
86            summary: None,
87            findings: Vec::new(),
88        }
89    }
90
91    /// Build a rejecting verdict carrying the given findings, trimmed to the
92    /// newest [`MAX_FINDINGS`].
93    pub fn rejected(findings: Vec<String>) -> Self {
94        Self {
95            approve: false,
96            summary: None,
97            findings: trim_findings(findings),
98        }
99    }
100
101    /// Attach a reviewer summary (builder style).
102    pub fn with_summary(mut self, summary: impl Into<String>) -> Self {
103        self.summary = Some(summary.into());
104        self
105    }
106
107    /// Normalize a freshly-parsed verdict: clamp findings to [`MAX_FINDINGS`].
108    pub fn normalized(mut self) -> Self {
109        self.findings = trim_findings(std::mem::take(&mut self.findings));
110        self
111    }
112}
113
114/// Keep only the newest [`MAX_FINDINGS`] findings.
115fn trim_findings(mut findings: Vec<String>) -> Vec<String> {
116    if findings.len() > MAX_FINDINGS {
117        let overflow = findings.len() - MAX_FINDINGS;
118        findings.drain(0..overflow);
119    }
120    findings
121}
122
123/// Durable guardian record persisted in `session.metadata`.
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct GuardianState {
126    /// Lifecycle phase of the current review pass.
127    pub phase: GuardianPhase,
128    /// The session id of the in-flight (or most recent) guardian child, if any.
129    /// Used by the completion coordinator to correlate a completing child with
130    /// the review this parent actually dispatched.
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub guardian_child_id: Option<String>,
133    /// The most recent guardian verdict, if a review has completed.
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub last_verdict: Option<GuardianVerdict>,
136    /// The round at which the last verdict was recorded. Observability/diagnostics
137    /// only — NOT currently read by the gate.
138    ///
139    /// KNOWN v1 SCOPE: the gate does not yet re-review work produced *after* an
140    /// approval (e.g. during a Gold goal-continuation) or across top-level user
141    /// turns, and the review budget is session-cumulative. A robust staleness
142    /// re-review needs a cross-turn-monotonic "new work" signal (the per-run
143    /// round counter resets each turn) plus a per-turn budget reset; deferred
144    /// until the feature is enabled in production.
145    #[serde(default)]
146    pub last_reviewed_at_round: u32,
147    /// How many guardian review passes have fired this session (the budget).
148    #[serde(default)]
149    pub review_count: u32,
150    pub created_at: String,
151    pub updated_at: String,
152}
153
154impl GuardianState {
155    fn new() -> Self {
156        let now = Utc::now().to_rfc3339();
157        Self {
158            phase: GuardianPhase::None,
159            guardian_child_id: None,
160            last_verdict: None,
161            last_reviewed_at_round: 0,
162            review_count: 0,
163            created_at: now.clone(),
164            updated_at: now,
165        }
166    }
167
168    /// Record that a guardian child has been spawned (move to `Pending`), and
169    /// charge the review budget.
170    pub fn record_spawn(&mut self, child_id: impl Into<String>) {
171        self.guardian_child_id = Some(child_id.into());
172        self.phase = GuardianPhase::Pending;
173        self.review_count = self.review_count.saturating_add(1);
174    }
175
176    /// Record a completed guardian verdict (move to `Reviewed`).
177    pub fn record_verdict(&mut self, verdict: GuardianVerdict, round: u32) {
178        self.last_verdict = Some(verdict.normalized());
179        self.last_reviewed_at_round = round;
180        self.phase = GuardianPhase::Reviewed;
181    }
182
183    /// Clear the in-flight review pass (after it has been acted upon), keeping
184    /// the budget count and the last verdict as the durable record.
185    pub fn clear(&mut self) {
186        self.phase = GuardianPhase::None;
187        self.guardian_child_id = None;
188    }
189
190    /// Whether the review budget is spent, mirroring the goal loop's
191    /// `continuation_count >= max_auto_continuations` gate.
192    pub fn budget_exhausted(&self, max_reviews: u32) -> bool {
193        self.review_count >= max_reviews
194    }
195
196    /// Whether the most recent verdict approved stopping the run.
197    pub fn last_approved(&self) -> bool {
198        self.last_verdict
199            .as_ref()
200            .is_some_and(|verdict| verdict.approve)
201    }
202}
203
204/// Read the persisted guardian state, if present and parseable.
205pub fn read_guardian_state(session: &Session) -> Option<GuardianState> {
206    let raw = session.metadata.get(GUARDIAN_STATE_METADATA_KEY)?;
207    serde_json::from_str::<GuardianState>(raw).ok()
208}
209
210/// Persist the guardian state into `session.metadata` (touching `updated_at`).
211pub fn write_guardian_state(session: &mut Session, mut state: GuardianState) {
212    state.updated_at = Utc::now().to_rfc3339();
213    match serde_json::to_string(&state) {
214        Ok(json) => {
215            session
216                .metadata
217                .insert(GUARDIAN_STATE_METADATA_KEY.to_string(), json);
218        }
219        Err(error) => {
220            // Serializing a plain data struct effectively never fails, but if it
221            // ever does, the on-disk guardian state would silently go stale —
222            // log loudly rather than swallow it (mirrors write_goal_state).
223            tracing::warn!(
224                "failed to serialize guardian state for session {}: {error}",
225                session.id
226            );
227        }
228    }
229}
230
231/// Read the existing guardian state, or create a fresh one.
232pub fn ensure_guardian_state(session: &Session) -> GuardianState {
233    read_guardian_state(session).unwrap_or_else(GuardianState::new)
234}
235
236/// Session metadata key holding the run's serialized [`GuardianConfig`].
237///
238/// The terminal gate persists the config here at first spawn so the resumed run
239/// — driven by the completion coordinator, which has no original request — can
240/// re-inject the guardian config and keep the review → fix → re-review loop
241/// active across the suspend/resume boundary.
242pub const GUARDIAN_CONFIG_METADATA_KEY: &str = "guardian.config";
243
244/// Persist the run's [`crate::runtime::config::GuardianConfig`] into the session.
245pub fn write_guardian_config(
246    session: &mut Session,
247    config: &crate::runtime::config::GuardianConfig,
248) {
249    if let Ok(json) = serde_json::to_string(config) {
250        session
251            .metadata
252            .insert(GUARDIAN_CONFIG_METADATA_KEY.to_string(), json);
253    }
254}
255
256/// Read the run's persisted [`crate::runtime::config::GuardianConfig`], if any.
257pub fn read_guardian_config(session: &Session) -> Option<crate::runtime::config::GuardianConfig> {
258    let raw = session.metadata.get(GUARDIAN_CONFIG_METADATA_KEY)?;
259    serde_json::from_str(raw).ok()
260}
261
262/// The adversarial-review rubric handed to the guardian child as its task brief.
263///
264/// The terminal gate appends the run's concrete completion criteria and goal
265/// after this template (see the guardian gate in the runner). The reviewer runs
266/// as a real read-only sub-agent: it fetches the diff and runs tests *itself*
267/// via its Bash/Read/Grep tools (so the engine never needs an in-process git),
268/// and emits a single JSON verdict as its final message.
269pub const GUARDIAN_REVIEW_RUBRIC: &str = r#"You are an adversarial code reviewer (Guardian). Another agent claims its task is complete. Independently VERIFY the work and decide whether the run may stop.
270
271Verify, do not trust:
272- Run `git diff` and `git status` in the workspace to see exactly what changed.
273- Read the changed files and the surrounding code to judge correctness.
274- If the task implies behavior, run the relevant tests or build (e.g. `cargo test`, `npm test`) and confirm they pass.
275- Check every completion criterion below against real evidence, not the agent's claims.
276
277Be skeptical. Flag real bugs, missed requirements, broken or skipped tests, and unmet criteria. You are READ-ONLY: do not modify files.
278
279Emit your verdict as your FINAL message and ONLY as a single JSON object (no prose around it):
280{"approve": <true|false>, "summary": "<one-line rationale>", "findings": ["<concrete issue>", "..."]}
281Set approve=true ONLY if the work is correct and every criterion is met; otherwise approve=false with concrete, actionable findings."#;
282
283/// The denylist of tool names disabled for a read-only guardian reviewer.
284///
285/// A DENYLIST matched by EXACT `ToolSchema.function.name` (see the worker's
286/// `tool_schemas` retain). The reviewer keeps read/search/shell tools (Read,
287/// Grep, Glob, GetFileInfo, Bash + its companions) so it can fetch the diff and
288/// run tests, but loses every file-mutating, escalation, web, and interaction
289/// tool. Names not registered in a given build are simply never matched, so the
290/// list is safe to keep conservative and forward-looking.
291pub fn guardian_read_only_disabled_tools() -> BTreeSet<String> {
292    [
293        // File mutation.
294        "Edit",
295        "Write",
296        "NotebookEdit",
297        "apply_patch",
298        "MultiEdit",
299        // Escalation / spawning / persistent side effects.
300        "Task",
301        "SubAgent",
302        "DeployAgent",
303        "AskAgent",
304        "scheduler",
305        "sub_session_manager",
306        "session_note",
307        "memory_note",
308        // Plan-mode / interaction / permissions.
309        "EnterPlanMode",
310        "ExitPlanMode",
311        "request_permissions",
312        "conclusion_with_options",
313        // Arbitrary execution surfaces beyond Bash, and web.
314        "SlashCommand",
315        "js_repl",
316        "Workspace",
317        "WebFetch",
318        "WebSearch",
319    ]
320    .into_iter()
321    .map(String::from)
322    .collect()
323}
324
325/// Parse a [`GuardianVerdict`] from the reviewer child's final message.
326///
327/// No schema is enforced on the child's output, so the reviewer is asked (by the
328/// rubric) to emit a single JSON object. This is tolerant: it accepts a bare
329/// object, a ```json fenced block, or an object embedded in surrounding prose
330/// (first `{` … last `}`). Returns the normalized verdict, or an error string
331/// when no parseable JSON object is found.
332pub fn parse_guardian_verdict(text: &str) -> Result<GuardianVerdict, String> {
333    let trimmed = text.trim();
334    if let Ok(verdict) = serde_json::from_str::<GuardianVerdict>(trimmed) {
335        return Ok(verdict.normalized());
336    }
337    let unfenced = strip_code_fence(trimmed);
338    if let Ok(verdict) = serde_json::from_str::<GuardianVerdict>(unfenced.trim()) {
339        return Ok(verdict.normalized());
340    }
341    // Try each balanced top-level `{...}` object, preferring the LAST one (the
342    // verdict normally follows any prose / examples / config snippets the
343    // reviewer printed). A string-aware, brace-balanced scan avoids the
344    // first-`{`-to-last-`}` over-capture that turned an embedded valid verdict
345    // into a parse error (and thus a wrongful synthetic reject).
346    for candidate in balanced_json_objects(unfenced).into_iter().rev() {
347        if let Ok(verdict) = serde_json::from_str::<GuardianVerdict>(candidate) {
348            return Ok(verdict.normalized());
349        }
350    }
351    Err(format!(
352        "no parseable guardian verdict JSON in reviewer output ({} chars)",
353        trimmed.len()
354    ))
355}
356
357/// Strip a single leading ```/```json fence and its trailing ``` if present.
358fn strip_code_fence(text: &str) -> &str {
359    let trimmed = text.trim();
360    let Some(rest) = trimmed.strip_prefix("```") else {
361        return trimmed;
362    };
363    // Drop the rest of the fence line (e.g. ```json) up to the first newline.
364    let after_lang = rest.find('\n').map_or("", |idx| &rest[idx + 1..]);
365    after_lang
366        .trim_end()
367        .strip_suffix("```")
368        .unwrap_or(after_lang)
369}
370
371/// All top-level brace-balanced `{...}` spans in `text`, in source order.
372///
373/// String-aware (braces inside JSON string literals don't affect depth) and
374/// nesting-aware, so a reviewer message like `config {a:1}; verdict {"approve":
375/// true}` yields TWO candidates rather than the single over-wide first-`{`..
376/// last-`}` slice that fails to parse.
377fn balanced_json_objects(text: &str) -> Vec<&str> {
378    let bytes = text.as_bytes();
379    let mut objects = Vec::new();
380    let mut depth = 0usize;
381    let mut start: Option<usize> = None;
382    let mut in_string = false;
383    let mut escaped = false;
384    for (i, &b) in bytes.iter().enumerate() {
385        if in_string {
386            if escaped {
387                escaped = false;
388            } else if b == b'\\' {
389                escaped = true;
390            } else if b == b'"' {
391                in_string = false;
392            }
393            continue;
394        }
395        match b {
396            b'"' => in_string = true,
397            b'{' => {
398                if depth == 0 {
399                    start = Some(i);
400                }
401                depth += 1;
402            }
403            b'}' if depth > 0 => {
404                depth -= 1;
405                if depth == 0 {
406                    if let Some(s) = start.take() {
407                        objects.push(&text[s..=i]);
408                    }
409                }
410            }
411            _ => {}
412        }
413    }
414    objects
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420    use bamboo_agent_core::Session;
421
422    #[test]
423    fn round_trips_through_metadata() {
424        let mut session = Session::new("s1", "model");
425        let mut state = GuardianState::new();
426        state.record_spawn("guardian-child-1");
427        state.record_verdict(
428            GuardianVerdict::rejected(vec!["missing test".to_string()]).with_summary("one bug"),
429            7,
430        );
431
432        write_guardian_state(&mut session, state);
433        let loaded = read_guardian_state(&session).expect("state persists");
434
435        assert_eq!(loaded.phase, GuardianPhase::Reviewed);
436        assert_eq!(
437            loaded.guardian_child_id.as_deref(),
438            Some("guardian-child-1")
439        );
440        assert_eq!(loaded.review_count, 1);
441        assert_eq!(loaded.last_reviewed_at_round, 7);
442        let verdict = loaded.last_verdict.expect("verdict persisted");
443        assert!(!verdict.approve);
444        assert_eq!(verdict.summary.as_deref(), Some("one bug"));
445        assert_eq!(verdict.findings, vec!["missing test".to_string()]);
446    }
447
448    #[test]
449    fn ensure_creates_fresh_when_absent() {
450        let session = Session::new("s1", "model");
451        let state = ensure_guardian_state(&session);
452        assert_eq!(state.phase, GuardianPhase::None);
453        assert_eq!(state.review_count, 0);
454        assert!(state.guardian_child_id.is_none());
455    }
456
457    #[test]
458    fn budget_gate_mirrors_continuation_count() {
459        let mut state = GuardianState::new();
460        assert!(!state.budget_exhausted(2));
461        state.record_spawn("c1"); // review_count = 1
462        assert!(!state.budget_exhausted(2));
463        state.clear();
464        state.record_spawn("c2"); // review_count = 2
465        assert!(state.budget_exhausted(2));
466    }
467
468    #[test]
469    fn clear_keeps_budget_and_verdict() {
470        let mut state = GuardianState::new();
471        state.record_spawn("c1");
472        state.record_verdict(GuardianVerdict::approved(), 3);
473        state.clear();
474        assert_eq!(state.phase, GuardianPhase::None);
475        assert!(state.guardian_child_id.is_none());
476        // Budget + last verdict survive the clear (they are the record).
477        assert_eq!(state.review_count, 1);
478        assert!(state.last_approved());
479    }
480
481    #[test]
482    fn rejected_trims_findings_to_newest() {
483        let findings: Vec<String> = (0..(MAX_FINDINGS + 10)).map(|i| format!("f{i}")).collect();
484        let verdict = GuardianVerdict::rejected(findings);
485        assert_eq!(verdict.findings.len(), MAX_FINDINGS);
486        // Oldest dropped; newest kept.
487        assert_eq!(
488            verdict.findings.last().unwrap(),
489            &format!("f{}", MAX_FINDINGS + 9)
490        );
491    }
492
493    #[test]
494    fn missing_optional_fields_parse() {
495        // The reviewer may emit a minimal verdict; summary/findings default.
496        let verdict: GuardianVerdict =
497            serde_json::from_str(r#"{"approve": true}"#).expect("minimal verdict parses");
498        assert!(verdict.approve);
499        assert!(verdict.summary.is_none());
500        assert!(verdict.findings.is_empty());
501    }
502
503    #[test]
504    fn parse_verdict_bare_object() {
505        let v =
506            parse_guardian_verdict(r#"{"approve": false, "summary": "bug", "findings": ["x"]}"#)
507                .expect("parses");
508        assert!(!v.approve);
509        assert_eq!(v.summary.as_deref(), Some("bug"));
510        assert_eq!(v.findings, vec!["x".to_string()]);
511    }
512
513    #[test]
514    fn parse_verdict_fenced_and_embedded() {
515        let fenced = "```json\n{\"approve\": true}\n```";
516        assert!(
517            parse_guardian_verdict(fenced)
518                .expect("fenced parses")
519                .approve
520        );
521        let embedded =
522            "Here is my verdict:\n{\"approve\": false, \"findings\": [\"nope\"]}\nThanks.";
523        let v = parse_guardian_verdict(embedded).expect("embedded parses");
524        assert!(!v.approve);
525        assert_eq!(v.findings, vec!["nope".to_string()]);
526    }
527
528    #[test]
529    fn parse_verdict_rejects_garbage() {
530        assert!(parse_guardian_verdict("no json here at all").is_err());
531    }
532
533    #[test]
534    fn parse_verdict_picks_trailing_object_after_prose_braces() {
535        // A reviewer that prints a config/example object before the real verdict
536        // must not be mis-parsed into a synthetic reject (the old first-{..last-}
537        // slice failed here).
538        let text = "I inspected config {timeout: 30} then ran the suite.\n\
539                    Verdict: {\"approve\": true, \"summary\": \"ok\"}";
540        let v = parse_guardian_verdict(text).expect("parses the trailing verdict");
541        assert!(v.approve);
542        assert_eq!(v.summary.as_deref(), Some("ok"));
543    }
544
545    #[test]
546    fn parse_verdict_is_string_aware_for_braces_in_findings() {
547        // Braces inside a JSON string must not break brace-balancing, and the
548        // object must still be found when embedded in prose.
549        let text = "note: {\"approve\": false, \"findings\": [\"foo() { x }\"]}";
550        let v = parse_guardian_verdict(text).expect("parses despite braces in the string");
551        assert!(!v.approve);
552        assert_eq!(v.findings, vec!["foo() { x }".to_string()]);
553    }
554
555    #[test]
556    fn read_only_denylist_blocks_mutation_keeps_read() {
557        let denied = guardian_read_only_disabled_tools();
558        for tool in ["Edit", "Write", "SubAgent", "WebFetch", "Task", "js_repl"] {
559            assert!(denied.contains(tool), "{tool} should be denied");
560        }
561        for tool in ["Read", "Grep", "Bash", "Glob", "GetFileInfo"] {
562            assert!(!denied.contains(tool), "{tool} should remain allowed");
563        }
564    }
565}