use std::collections::BTreeSet;
use bamboo_agent_core::Session;
use chrono::Utc;
use serde::{Deserialize, Serialize};
pub const GUARDIAN_STATE_METADATA_KEY: &str = "guardian.state";
const MAX_FINDINGS: usize = 50;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GuardianPhase {
None,
Pending,
Reviewed,
}
impl GuardianPhase {
pub fn as_str(self) -> &'static str {
match self {
Self::None => "none",
Self::Pending => "pending",
Self::Reviewed => "reviewed",
}
}
pub fn is_pending(self) -> bool {
matches!(self, Self::Pending)
}
pub fn is_reviewed(self) -> bool {
matches!(self, Self::Reviewed)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GuardianVerdict {
pub approve: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub findings: Vec<String>,
}
impl GuardianVerdict {
pub fn approved() -> Self {
Self {
approve: true,
summary: None,
findings: Vec::new(),
}
}
pub fn rejected(findings: Vec<String>) -> Self {
Self {
approve: false,
summary: None,
findings: trim_findings(findings),
}
}
pub fn with_summary(mut self, summary: impl Into<String>) -> Self {
self.summary = Some(summary.into());
self
}
pub fn normalized(mut self) -> Self {
self.findings = trim_findings(std::mem::take(&mut self.findings));
self
}
}
fn trim_findings(mut findings: Vec<String>) -> Vec<String> {
if findings.len() > MAX_FINDINGS {
let overflow = findings.len() - MAX_FINDINGS;
findings.drain(0..overflow);
}
findings
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GuardianState {
pub phase: GuardianPhase,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub guardian_child_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_verdict: Option<GuardianVerdict>,
#[serde(default)]
pub last_reviewed_at_round: u32,
#[serde(default)]
pub review_count: u32,
pub created_at: String,
pub updated_at: String,
}
impl GuardianState {
fn new() -> Self {
let now = Utc::now().to_rfc3339();
Self {
phase: GuardianPhase::None,
guardian_child_id: None,
last_verdict: None,
last_reviewed_at_round: 0,
review_count: 0,
created_at: now.clone(),
updated_at: now,
}
}
pub fn record_spawn(&mut self, child_id: impl Into<String>) {
self.guardian_child_id = Some(child_id.into());
self.phase = GuardianPhase::Pending;
self.review_count = self.review_count.saturating_add(1);
}
pub fn record_verdict(&mut self, verdict: GuardianVerdict, round: u32) {
self.last_verdict = Some(verdict.normalized());
self.last_reviewed_at_round = round;
self.phase = GuardianPhase::Reviewed;
}
pub fn clear(&mut self) {
self.phase = GuardianPhase::None;
self.guardian_child_id = None;
}
pub fn budget_exhausted(&self, max_reviews: u32) -> bool {
self.review_count >= max_reviews
}
pub fn last_approved(&self) -> bool {
self.last_verdict
.as_ref()
.is_some_and(|verdict| verdict.approve)
}
}
pub fn read_guardian_state(session: &Session) -> Option<GuardianState> {
let raw = session.metadata.get(GUARDIAN_STATE_METADATA_KEY)?;
serde_json::from_str::<GuardianState>(raw).ok()
}
pub fn write_guardian_state(session: &mut Session, mut state: GuardianState) {
state.updated_at = Utc::now().to_rfc3339();
match serde_json::to_string(&state) {
Ok(json) => {
session
.metadata
.insert(GUARDIAN_STATE_METADATA_KEY.to_string(), json);
}
Err(error) => {
tracing::warn!(
"failed to serialize guardian state for session {}: {error}",
session.id
);
}
}
}
pub fn ensure_guardian_state(session: &Session) -> GuardianState {
read_guardian_state(session).unwrap_or_else(GuardianState::new)
}
pub const GUARDIAN_CONFIG_METADATA_KEY: &str = "guardian.config";
pub fn write_guardian_config(
session: &mut Session,
config: &crate::runtime::config::GuardianConfig,
) {
if let Ok(json) = serde_json::to_string(config) {
session
.metadata
.insert(GUARDIAN_CONFIG_METADATA_KEY.to_string(), json);
}
}
pub fn read_guardian_config(session: &Session) -> Option<crate::runtime::config::GuardianConfig> {
let raw = session.metadata.get(GUARDIAN_CONFIG_METADATA_KEY)?;
serde_json::from_str(raw).ok()
}
pub 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.
Verify, do not trust:
- Run `git diff` and `git status` in the workspace to see exactly what changed.
- Read the changed files and the surrounding code to judge correctness.
- If the task implies behavior, run the relevant tests or build (e.g. `cargo test`, `npm test`) and confirm they pass.
- Check every completion criterion below against real evidence, not the agent's claims.
Be skeptical. Flag real bugs, missed requirements, broken or skipped tests, and unmet criteria. You are READ-ONLY: do not modify files.
Emit your verdict as your FINAL message and ONLY as a single JSON object (no prose around it):
{"approve": <true|false>, "summary": "<one-line rationale>", "findings": ["<concrete issue>", "..."]}
Set approve=true ONLY if the work is correct and every criterion is met; otherwise approve=false with concrete, actionable findings."#;
pub fn guardian_read_only_disabled_tools() -> BTreeSet<String> {
[
"Edit",
"Write",
"NotebookEdit",
"apply_patch",
"MultiEdit",
"Task",
"SubAgent",
"DeployAgent",
"AskAgent",
"scheduler",
"sub_session_manager",
"session_note",
"memory_note",
"EnterPlanMode",
"ExitPlanMode",
"request_permissions",
"conclusion_with_options",
"SlashCommand",
"js_repl",
"Workspace",
"WebFetch",
"WebSearch",
]
.into_iter()
.map(String::from)
.collect()
}
pub fn parse_guardian_verdict(text: &str) -> Result<GuardianVerdict, String> {
let trimmed = text.trim();
if let Ok(verdict) = serde_json::from_str::<GuardianVerdict>(trimmed) {
return Ok(verdict.normalized());
}
let unfenced = strip_code_fence(trimmed);
if let Ok(verdict) = serde_json::from_str::<GuardianVerdict>(unfenced.trim()) {
return Ok(verdict.normalized());
}
for candidate in balanced_json_objects(unfenced).into_iter().rev() {
if let Ok(verdict) = serde_json::from_str::<GuardianVerdict>(candidate) {
return Ok(verdict.normalized());
}
}
Err(format!(
"no parseable guardian verdict JSON in reviewer output ({} chars)",
trimmed.len()
))
}
fn strip_code_fence(text: &str) -> &str {
let trimmed = text.trim();
let Some(rest) = trimmed.strip_prefix("```") else {
return trimmed;
};
let after_lang = rest.find('\n').map_or("", |idx| &rest[idx + 1..]);
after_lang
.trim_end()
.strip_suffix("```")
.unwrap_or(after_lang)
}
fn balanced_json_objects(text: &str) -> Vec<&str> {
let bytes = text.as_bytes();
let mut objects = Vec::new();
let mut depth = 0usize;
let mut start: Option<usize> = None;
let mut in_string = false;
let mut escaped = false;
for (i, &b) in bytes.iter().enumerate() {
if in_string {
if escaped {
escaped = false;
} else if b == b'\\' {
escaped = true;
} else if b == b'"' {
in_string = false;
}
continue;
}
match b {
b'"' => in_string = true,
b'{' => {
if depth == 0 {
start = Some(i);
}
depth += 1;
}
b'}' if depth > 0 => {
depth -= 1;
if depth == 0 {
if let Some(s) = start.take() {
objects.push(&text[s..=i]);
}
}
}
_ => {}
}
}
objects
}
#[cfg(test)]
mod tests {
use super::*;
use bamboo_agent_core::Session;
#[test]
fn round_trips_through_metadata() {
let mut session = Session::new("s1", "model");
let mut state = GuardianState::new();
state.record_spawn("guardian-child-1");
state.record_verdict(
GuardianVerdict::rejected(vec!["missing test".to_string()]).with_summary("one bug"),
7,
);
write_guardian_state(&mut session, state);
let loaded = read_guardian_state(&session).expect("state persists");
assert_eq!(loaded.phase, GuardianPhase::Reviewed);
assert_eq!(
loaded.guardian_child_id.as_deref(),
Some("guardian-child-1")
);
assert_eq!(loaded.review_count, 1);
assert_eq!(loaded.last_reviewed_at_round, 7);
let verdict = loaded.last_verdict.expect("verdict persisted");
assert!(!verdict.approve);
assert_eq!(verdict.summary.as_deref(), Some("one bug"));
assert_eq!(verdict.findings, vec!["missing test".to_string()]);
}
#[test]
fn ensure_creates_fresh_when_absent() {
let session = Session::new("s1", "model");
let state = ensure_guardian_state(&session);
assert_eq!(state.phase, GuardianPhase::None);
assert_eq!(state.review_count, 0);
assert!(state.guardian_child_id.is_none());
}
#[test]
fn budget_gate_mirrors_continuation_count() {
let mut state = GuardianState::new();
assert!(!state.budget_exhausted(2));
state.record_spawn("c1"); assert!(!state.budget_exhausted(2));
state.clear();
state.record_spawn("c2"); assert!(state.budget_exhausted(2));
}
#[test]
fn clear_keeps_budget_and_verdict() {
let mut state = GuardianState::new();
state.record_spawn("c1");
state.record_verdict(GuardianVerdict::approved(), 3);
state.clear();
assert_eq!(state.phase, GuardianPhase::None);
assert!(state.guardian_child_id.is_none());
assert_eq!(state.review_count, 1);
assert!(state.last_approved());
}
#[test]
fn rejected_trims_findings_to_newest() {
let findings: Vec<String> = (0..(MAX_FINDINGS + 10)).map(|i| format!("f{i}")).collect();
let verdict = GuardianVerdict::rejected(findings);
assert_eq!(verdict.findings.len(), MAX_FINDINGS);
assert_eq!(
verdict.findings.last().unwrap(),
&format!("f{}", MAX_FINDINGS + 9)
);
}
#[test]
fn missing_optional_fields_parse() {
let verdict: GuardianVerdict =
serde_json::from_str(r#"{"approve": true}"#).expect("minimal verdict parses");
assert!(verdict.approve);
assert!(verdict.summary.is_none());
assert!(verdict.findings.is_empty());
}
#[test]
fn parse_verdict_bare_object() {
let v =
parse_guardian_verdict(r#"{"approve": false, "summary": "bug", "findings": ["x"]}"#)
.expect("parses");
assert!(!v.approve);
assert_eq!(v.summary.as_deref(), Some("bug"));
assert_eq!(v.findings, vec!["x".to_string()]);
}
#[test]
fn parse_verdict_fenced_and_embedded() {
let fenced = "```json\n{\"approve\": true}\n```";
assert!(
parse_guardian_verdict(fenced)
.expect("fenced parses")
.approve
);
let embedded =
"Here is my verdict:\n{\"approve\": false, \"findings\": [\"nope\"]}\nThanks.";
let v = parse_guardian_verdict(embedded).expect("embedded parses");
assert!(!v.approve);
assert_eq!(v.findings, vec!["nope".to_string()]);
}
#[test]
fn parse_verdict_rejects_garbage() {
assert!(parse_guardian_verdict("no json here at all").is_err());
}
#[test]
fn parse_verdict_picks_trailing_object_after_prose_braces() {
let text = "I inspected config {timeout: 30} then ran the suite.\n\
Verdict: {\"approve\": true, \"summary\": \"ok\"}";
let v = parse_guardian_verdict(text).expect("parses the trailing verdict");
assert!(v.approve);
assert_eq!(v.summary.as_deref(), Some("ok"));
}
#[test]
fn parse_verdict_is_string_aware_for_braces_in_findings() {
let text = "note: {\"approve\": false, \"findings\": [\"foo() { x }\"]}";
let v = parse_guardian_verdict(text).expect("parses despite braces in the string");
assert!(!v.approve);
assert_eq!(v.findings, vec!["foo() { x }".to_string()]);
}
#[test]
fn read_only_denylist_blocks_mutation_keeps_read() {
let denied = guardian_read_only_disabled_tools();
for tool in ["Edit", "Write", "SubAgent", "WebFetch", "Task", "js_repl"] {
assert!(denied.contains(tool), "{tool} should be denied");
}
for tool in ["Read", "Grep", "Bash", "Glob", "GetFileInfo"] {
assert!(!denied.contains(tool), "{tool} should remain allowed");
}
}
}