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 inspects the named source via dedicated
267/// Read/Glob/Grep tools, evaluates already-recorded diff/test evidence, and
268/// 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- Inspect the implementer's exact changed-file/diff evidence. If it is absent or insufficient, reject with the exact evidence still needed.
273- Read the named changed files and the surrounding code with the dedicated read/search tools to judge correctness.
274- Inspect the implementer's exact test/build evidence. Do not invoke Bash, shells, builds, tests, package managers, repository programs, or executable helpers: ambient command resolution is not a hard read-only boundary. If required evidence is absent or insufficient, reject with the exact verification still needed.
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 any runtime-enforced read-only child.
284///
285/// A DENYLIST matched by EXACT `ToolSchema.function.name` (see the worker's
286/// `tool_schemas` retain). The child keeps only dedicated read/search surfaces
287/// (Read, Grep, Glob, GetFileInfo) for workspace inspection and loses every
288/// shell, file-mutating, escalation, persistence, web, and interaction tool.
289/// Names not registered in a given build are simply never matched, so the list
290/// is safe to keep conservative and forward-looking.
291pub fn read_only_child_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        "Plan",
302        "SubAgent",
303        "ask_agent",
304        "cluster",
305        "deploy_agent",
306        "DeployAgent",
307        "AskAgent",
308        "Project",
309        "scheduler",
310        "sub_session_manager",
311        "session_control",
312        "session_note",
313        "memory_note",
314        "memory",
315        "ledger",
316        "load_skill",
317        "notify",
318        "update_goal",
319        "workflow_run",
320        // Plan-mode / interaction / permissions.
321        "EnterPlanMode",
322        "ExitPlanMode",
323        "request_permissions",
324        "conclusion_with_options",
325        // Ambient/arbitrary execution surfaces, and web.
326        "exec_command",
327        "write_stdin",
328        "Bash",
329        "BashInput",
330        "BashOutput",
331        "KillShell",
332        "SlashCommand",
333        "js_repl",
334        "Workspace",
335        "WebFetch",
336        "WebSearch",
337    ]
338    .into_iter()
339    .map(String::from)
340    .collect()
341}
342
343/// Compatibility wrapper for the existing Guardian terminal gate and
344/// downstream callers that imported the former Guardian-specific name.
345pub fn guardian_read_only_disabled_tools() -> BTreeSet<String> {
346    read_only_child_disabled_tools()
347}
348
349/// Parse a [`GuardianVerdict`] from the reviewer child's final message.
350///
351/// No schema is enforced on the child's output, so the reviewer is asked (by the
352/// rubric) to emit a single JSON object. This is tolerant: it accepts a bare
353/// object, a ```json fenced block, or an object embedded in surrounding prose
354/// (first `{` … last `}`). Returns the normalized verdict, or an error string
355/// when no parseable JSON object is found.
356pub fn parse_guardian_verdict(text: &str) -> Result<GuardianVerdict, String> {
357    let trimmed = text.trim();
358    if let Ok(verdict) = serde_json::from_str::<GuardianVerdict>(trimmed) {
359        return Ok(verdict.normalized());
360    }
361    let unfenced = strip_code_fence(trimmed);
362    if let Ok(verdict) = serde_json::from_str::<GuardianVerdict>(unfenced.trim()) {
363        return Ok(verdict.normalized());
364    }
365    // Try each balanced top-level `{...}` object, preferring the LAST one (the
366    // verdict normally follows any prose / examples / config snippets the
367    // reviewer printed). A string-aware, brace-balanced scan avoids the
368    // first-`{`-to-last-`}` over-capture that turned an embedded valid verdict
369    // into a parse error (and thus a wrongful synthetic reject).
370    for candidate in balanced_json_objects(unfenced).into_iter().rev() {
371        if let Ok(verdict) = serde_json::from_str::<GuardianVerdict>(candidate) {
372            return Ok(verdict.normalized());
373        }
374    }
375    Err(format!(
376        "no parseable guardian verdict JSON in reviewer output ({} chars)",
377        trimmed.len()
378    ))
379}
380
381/// Strip a single leading ```/```json fence and its trailing ``` if present.
382fn strip_code_fence(text: &str) -> &str {
383    let trimmed = text.trim();
384    let Some(rest) = trimmed.strip_prefix("```") else {
385        return trimmed;
386    };
387    // Drop the rest of the fence line (e.g. ```json) up to the first newline.
388    let after_lang = rest.find('\n').map_or("", |idx| &rest[idx + 1..]);
389    after_lang
390        .trim_end()
391        .strip_suffix("```")
392        .unwrap_or(after_lang)
393}
394
395/// All top-level brace-balanced `{...}` spans in `text`, in source order.
396///
397/// String-aware (braces inside JSON string literals don't affect depth) and
398/// nesting-aware, so a reviewer message like `config {a:1}; verdict {"approve":
399/// true}` yields TWO candidates rather than the single over-wide first-`{`..
400/// last-`}` slice that fails to parse.
401fn balanced_json_objects(text: &str) -> Vec<&str> {
402    let bytes = text.as_bytes();
403    let mut objects = Vec::new();
404    let mut depth = 0usize;
405    let mut start: Option<usize> = None;
406    let mut in_string = false;
407    let mut escaped = false;
408    for (i, &b) in bytes.iter().enumerate() {
409        if in_string {
410            if escaped {
411                escaped = false;
412            } else if b == b'\\' {
413                escaped = true;
414            } else if b == b'"' {
415                in_string = false;
416            }
417            continue;
418        }
419        match b {
420            b'"' => in_string = true,
421            b'{' => {
422                if depth == 0 {
423                    start = Some(i);
424                }
425                depth += 1;
426            }
427            b'}' if depth > 0 => {
428                depth -= 1;
429                if depth == 0 {
430                    if let Some(s) = start.take() {
431                        objects.push(&text[s..=i]);
432                    }
433                }
434            }
435            _ => {}
436        }
437    }
438    objects
439}
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444    use bamboo_agent_core::Session;
445
446    #[test]
447    fn round_trips_through_metadata() {
448        let mut session = Session::new("s1", "model");
449        let mut state = GuardianState::new();
450        state.record_spawn("guardian-child-1");
451        state.record_verdict(
452            GuardianVerdict::rejected(vec!["missing test".to_string()]).with_summary("one bug"),
453            7,
454        );
455
456        write_guardian_state(&mut session, state);
457        let loaded = read_guardian_state(&session).expect("state persists");
458
459        assert_eq!(loaded.phase, GuardianPhase::Reviewed);
460        assert_eq!(
461            loaded.guardian_child_id.as_deref(),
462            Some("guardian-child-1")
463        );
464        assert_eq!(loaded.review_count, 1);
465        assert_eq!(loaded.last_reviewed_at_round, 7);
466        let verdict = loaded.last_verdict.expect("verdict persisted");
467        assert!(!verdict.approve);
468        assert_eq!(verdict.summary.as_deref(), Some("one bug"));
469        assert_eq!(verdict.findings, vec!["missing test".to_string()]);
470    }
471
472    #[test]
473    fn ensure_creates_fresh_when_absent() {
474        let session = Session::new("s1", "model");
475        let state = ensure_guardian_state(&session);
476        assert_eq!(state.phase, GuardianPhase::None);
477        assert_eq!(state.review_count, 0);
478        assert!(state.guardian_child_id.is_none());
479    }
480
481    #[test]
482    fn budget_gate_mirrors_continuation_count() {
483        let mut state = GuardianState::new();
484        assert!(!state.budget_exhausted(2));
485        state.record_spawn("c1"); // review_count = 1
486        assert!(!state.budget_exhausted(2));
487        state.clear();
488        state.record_spawn("c2"); // review_count = 2
489        assert!(state.budget_exhausted(2));
490    }
491
492    #[test]
493    fn clear_keeps_budget_and_verdict() {
494        let mut state = GuardianState::new();
495        state.record_spawn("c1");
496        state.record_verdict(GuardianVerdict::approved(), 3);
497        state.clear();
498        assert_eq!(state.phase, GuardianPhase::None);
499        assert!(state.guardian_child_id.is_none());
500        // Budget + last verdict survive the clear (they are the record).
501        assert_eq!(state.review_count, 1);
502        assert!(state.last_approved());
503    }
504
505    #[test]
506    fn rejected_trims_findings_to_newest() {
507        let findings: Vec<String> = (0..(MAX_FINDINGS + 10)).map(|i| format!("f{i}")).collect();
508        let verdict = GuardianVerdict::rejected(findings);
509        assert_eq!(verdict.findings.len(), MAX_FINDINGS);
510        // Oldest dropped; newest kept.
511        assert_eq!(
512            verdict.findings.last().unwrap(),
513            &format!("f{}", MAX_FINDINGS + 9)
514        );
515    }
516
517    #[test]
518    fn missing_optional_fields_parse() {
519        // The reviewer may emit a minimal verdict; summary/findings default.
520        let verdict: GuardianVerdict =
521            serde_json::from_str(r#"{"approve": true}"#).expect("minimal verdict parses");
522        assert!(verdict.approve);
523        assert!(verdict.summary.is_none());
524        assert!(verdict.findings.is_empty());
525    }
526
527    #[test]
528    fn parse_verdict_bare_object() {
529        let v =
530            parse_guardian_verdict(r#"{"approve": false, "summary": "bug", "findings": ["x"]}"#)
531                .expect("parses");
532        assert!(!v.approve);
533        assert_eq!(v.summary.as_deref(), Some("bug"));
534        assert_eq!(v.findings, vec!["x".to_string()]);
535    }
536
537    #[test]
538    fn parse_verdict_fenced_and_embedded() {
539        let fenced = "```json\n{\"approve\": true}\n```";
540        assert!(
541            parse_guardian_verdict(fenced)
542                .expect("fenced parses")
543                .approve
544        );
545        let embedded =
546            "Here is my verdict:\n{\"approve\": false, \"findings\": [\"nope\"]}\nThanks.";
547        let v = parse_guardian_verdict(embedded).expect("embedded parses");
548        assert!(!v.approve);
549        assert_eq!(v.findings, vec!["nope".to_string()]);
550    }
551
552    #[test]
553    fn parse_verdict_rejects_garbage() {
554        assert!(parse_guardian_verdict("no json here at all").is_err());
555    }
556
557    #[test]
558    fn parse_verdict_picks_trailing_object_after_prose_braces() {
559        // A reviewer that prints a config/example object before the real verdict
560        // must not be mis-parsed into a synthetic reject (the old first-{..last-}
561        // slice failed here).
562        let text = "I inspected config {timeout: 30} then ran the suite.\n\
563                    Verdict: {\"approve\": true, \"summary\": \"ok\"}";
564        let v = parse_guardian_verdict(text).expect("parses the trailing verdict");
565        assert!(v.approve);
566        assert_eq!(v.summary.as_deref(), Some("ok"));
567    }
568
569    #[test]
570    fn parse_verdict_is_string_aware_for_braces_in_findings() {
571        // Braces inside a JSON string must not break brace-balancing, and the
572        // object must still be found when embedded in prose.
573        let text = "note: {\"approve\": false, \"findings\": [\"foo() { x }\"]}";
574        let v = parse_guardian_verdict(text).expect("parses despite braces in the string");
575        assert!(!v.approve);
576        assert_eq!(v.findings, vec!["foo() { x }".to_string()]);
577    }
578
579    #[test]
580    fn read_only_denylist_blocks_mutation_keeps_read() {
581        let denied = read_only_child_disabled_tools();
582        for tool in [
583            "Edit",
584            "Write",
585            "Plan",
586            "SubAgent",
587            "Project",
588            "session_control",
589            "memory",
590            "ledger",
591            "workflow_run",
592            "exec_command",
593            "write_stdin",
594            "Bash",
595            "BashOutput",
596            "KillShell",
597            "WebFetch",
598            "Task",
599            "js_repl",
600        ] {
601            assert!(denied.contains(tool), "{tool} should be denied");
602        }
603        for tool in ["Read", "Grep", "Glob", "GetFileInfo"] {
604            assert!(!denied.contains(tool), "{tool} should remain allowed");
605        }
606    }
607
608    #[test]
609    fn guardian_rubric_requests_only_non_executable_verification() {
610        assert!(GUARDIAN_REVIEW_RUBRIC.contains("dedicated read/search tools"));
611        assert!(GUARDIAN_REVIEW_RUBRIC.contains("Do not invoke Bash"));
612        assert!(!GUARDIAN_REVIEW_RUBRIC.contains("`cargo test`"));
613        assert!(!GUARDIAN_REVIEW_RUBRIC.contains("`git status`"));
614    }
615}