1use std::collections::BTreeSet;
22
23use bamboo_agent_core::Session;
24use chrono::Utc;
25use serde::{Deserialize, Serialize};
26
27pub const GUARDIAN_STATE_METADATA_KEY: &str = "guardian.state";
29
30const MAX_FINDINGS: usize = 50;
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(rename_all = "snake_case")]
37pub enum GuardianPhase {
38 None,
40 Pending,
42 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 pub fn is_pending(self) -> bool {
57 matches!(self, Self::Pending)
58 }
59
60 pub fn is_reviewed(self) -> bool {
62 matches!(self, Self::Reviewed)
63 }
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct GuardianVerdict {
71 pub approve: bool,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub summary: Option<String>,
76 #[serde(default, skip_serializing_if = "Vec::is_empty")]
78 pub findings: Vec<String>,
79}
80
81impl GuardianVerdict {
82 pub fn approved() -> Self {
84 Self {
85 approve: true,
86 summary: None,
87 findings: Vec::new(),
88 }
89 }
90
91 pub fn rejected(findings: Vec<String>) -> Self {
94 Self {
95 approve: false,
96 summary: None,
97 findings: trim_findings(findings),
98 }
99 }
100
101 pub fn with_summary(mut self, summary: impl Into<String>) -> Self {
103 self.summary = Some(summary.into());
104 self
105 }
106
107 pub fn normalized(mut self) -> Self {
109 self.findings = trim_findings(std::mem::take(&mut self.findings));
110 self
111 }
112}
113
114fn 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#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct GuardianState {
126 pub phase: GuardianPhase,
128 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub guardian_child_id: Option<String>,
133 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub last_verdict: Option<GuardianVerdict>,
136 #[serde(default)]
146 pub last_reviewed_at_round: u32,
147 #[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 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 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 pub fn clear(&mut self) {
186 self.phase = GuardianPhase::None;
187 self.guardian_child_id = None;
188 }
189
190 pub fn budget_exhausted(&self, max_reviews: u32) -> bool {
193 self.review_count >= max_reviews
194 }
195
196 pub fn last_approved(&self) -> bool {
198 self.last_verdict
199 .as_ref()
200 .is_some_and(|verdict| verdict.approve)
201 }
202}
203
204pub 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
210pub 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 tracing::warn!(
224 "failed to serialize guardian state for session {}: {error}",
225 session.id
226 );
227 }
228 }
229}
230
231pub fn ensure_guardian_state(session: &Session) -> GuardianState {
233 read_guardian_state(session).unwrap_or_else(GuardianState::new)
234}
235
236pub const GUARDIAN_CONFIG_METADATA_KEY: &str = "guardian.config";
243
244pub 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
256pub 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
262pub 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
283pub fn guardian_read_only_disabled_tools() -> BTreeSet<String> {
292 [
293 "Edit",
295 "Write",
296 "NotebookEdit",
297 "apply_patch",
298 "MultiEdit",
299 "Task",
301 "SubAgent",
302 "DeployAgent",
303 "AskAgent",
304 "scheduler",
305 "sub_session_manager",
306 "session_note",
307 "memory_note",
308 "EnterPlanMode",
310 "ExitPlanMode",
311 "request_permissions",
312 "conclusion_with_options",
313 "SlashCommand",
315 "js_repl",
316 "Workspace",
317 "WebFetch",
318 "WebSearch",
319 ]
320 .into_iter()
321 .map(String::from)
322 .collect()
323}
324
325pub 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 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
357fn strip_code_fence(text: &str) -> &str {
359 let trimmed = text.trim();
360 let Some(rest) = trimmed.strip_prefix("```") else {
361 return trimmed;
362 };
363 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
371fn 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"); assert!(!state.budget_exhausted(2));
463 state.clear();
464 state.record_spawn("c2"); 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 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 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 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 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 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}