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- 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
283pub fn read_only_child_disabled_tools() -> BTreeSet<String> {
292 [
293 "Edit",
295 "Write",
296 "NotebookEdit",
297 "apply_patch",
298 "MultiEdit",
299 "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 "EnterPlanMode",
322 "ExitPlanMode",
323 "request_permissions",
324 "conclusion_with_options",
325 "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
343pub fn guardian_read_only_disabled_tools() -> BTreeSet<String> {
346 read_only_child_disabled_tools()
347}
348
349pub 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 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
381fn strip_code_fence(text: &str) -> &str {
383 let trimmed = text.trim();
384 let Some(rest) = trimmed.strip_prefix("```") else {
385 return trimmed;
386 };
387 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
395fn 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"); assert!(!state.budget_exhausted(2));
487 state.clear();
488 state.record_spawn("c2"); 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 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 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 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 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 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}