car_multi/patterns/
adversarial_review.rs1use crate::error::MultiError;
12use crate::mailbox::Mailbox;
13use crate::runner::AgentRunner;
14use crate::shared::SharedInfra;
15use crate::types::{AgentOutput, AgentSpec};
16use serde::{Deserialize, Serialize};
17use std::sync::Arc;
18use tracing::instrument;
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct ReviewFinding {
23 pub criterion: String,
24 pub passed: bool,
25 pub evidence: String,
26 pub severity: String, }
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct AdversarialReviewResult {
32 pub spec: String,
34 pub passed: bool,
36 pub findings: Vec<ReviewFinding>,
38 pub reviewer_output: AgentOutput,
40 pub blocker_count: usize,
42}
43
44pub struct AdversarialReview {
46 pub reviewer: AgentSpec,
48 pub criteria: Vec<String>,
50 pub fail_on_blockers: bool,
52}
53
54impl AdversarialReview {
55 pub fn new(reviewer: AgentSpec, criteria: Vec<String>) -> Self {
56 Self {
57 reviewer,
58 criteria,
59 fail_on_blockers: true,
60 }
61 }
62
63 #[instrument(name = "multi.adversarial_review", skip_all)]
68 pub async fn run(
69 &self,
70 work_output: &str,
71 runner: &Arc<dyn AgentRunner>,
72 infra: &SharedInfra,
73 ) -> Result<AdversarialReviewResult, MultiError> {
74 let criteria_text = self
75 .criteria
76 .iter()
77 .enumerate()
78 .map(|(i, c)| format!("{}. {}", i + 1, c))
79 .collect::<Vec<_>>()
80 .join("\n");
81
82 let review_task = format!(
83 r#"You are an adversarial reviewer. Your job is to find problems.
84
85## Acceptance Criteria
86{criteria}
87
88## Work Output to Review
89{work}
90
91## Instructions
92Evaluate the work output against EACH acceptance criterion above.
93For each criterion, determine if it PASSES or FAILS. Provide specific evidence
94(file paths, line numbers, code snippets, or direct quotes from the output).
95
96Be strict. If a criterion is ambiguous, assume it should be fully met.
97Flag anything suspicious as a "blocker" or "major" finding.
98
99Respond with a JSON object:
100```json
101{{
102 "passed": true/false,
103 "findings": [
104 {{
105 "criterion": "criterion text",
106 "passed": true/false,
107 "evidence": "specific evidence with file:line references",
108 "severity": "blocker|major|minor|info"
109 }}
110 ]
111}}
112```"#,
113 criteria = criteria_text,
114 work = work_output,
115 );
116
117 let mailbox = Mailbox::default();
118 let rt = infra.make_runtime();
119 let output = runner
120 .run(&self.reviewer, &review_task, &rt, &mailbox)
121 .await
122 .map_err(|e| {
123 MultiError::AgentFailed(
124 self.reviewer.name.clone(),
125 format!("adversarial review failed: {}", e),
126 )
127 })?;
128
129 let findings = Self::parse_findings(&output.answer);
131 let blocker_count = findings.iter().filter(|f| f.severity == "blocker").count();
132 let passed = if self.fail_on_blockers {
133 blocker_count == 0 && findings.iter().all(|f| f.passed || f.severity != "major")
134 } else {
135 findings.iter().filter(|f| f.passed).count() > findings.len() / 2
136 };
137
138 Ok(AdversarialReviewResult {
139 spec: criteria_text,
140 passed,
141 findings,
142 reviewer_output: output,
143 blocker_count,
144 })
145 }
146
147 fn parse_findings(response: &str) -> Vec<ReviewFinding> {
148 if let Some(json_str) = car_ir::json_extract::extract_json_object(response) {
150 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&json_str) {
151 if let Some(findings) = parsed.get("findings").and_then(|f| f.as_array()) {
152 return findings
153 .iter()
154 .filter_map(|f| {
155 Some(ReviewFinding {
156 criterion: f.get("criterion")?.as_str()?.to_string(),
157 passed: f.get("passed")?.as_bool()?,
158 evidence: f.get("evidence")?.as_str()?.to_string(),
159 severity: f
160 .get("severity")
161 .and_then(|s| s.as_str())
162 .unwrap_or("major")
163 .to_string(),
164 })
165 })
166 .collect();
167 }
168 }
169 }
170 vec![ReviewFinding {
172 criterion: "overall".to_string(),
173 passed: response.to_lowercase().contains("pass"),
174 evidence: response.to_string(),
175 severity: "major".to_string(),
176 }]
177 }
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183
184 #[test]
185 fn parse_findings_from_json() {
186 let response = r#"```json
187{
188 "passed": false,
189 "findings": [
190 {"criterion": "all tests pass", "passed": true, "evidence": "cargo test: 50 passed", "severity": "info"},
191 {"criterion": "no hardcoded secrets", "passed": false, "evidence": "src/config.rs:42 contains API key", "severity": "blocker"}
192 ]
193}
194```"#;
195 let findings = AdversarialReview::parse_findings(response);
196 assert_eq!(findings.len(), 2);
197 assert!(findings[0].passed);
198 assert!(!findings[1].passed);
199 assert_eq!(findings[1].severity, "blocker");
200 }
201
202 #[test]
203 fn parse_findings_fallback() {
204 let response = "This looks good overall. PASS.";
205 let findings = AdversarialReview::parse_findings(response);
206 assert_eq!(findings.len(), 1);
207 assert!(findings[0].passed);
208 }
209}