1use 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
20pub const SEVERITY_INCONCLUSIVE: &str = "inconclusive";
25
26const INCONCLUSIVE_EVIDENCE_CAP: usize = 1024;
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct ReviewFinding {
34 pub criterion: String,
35 pub passed: bool,
36 pub evidence: String,
37 pub severity: String, }
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct AdversarialReviewResult {
43 pub spec: String,
45 pub passed: bool,
47 pub findings: Vec<ReviewFinding>,
49 pub reviewer_output: AgentOutput,
51 pub blocker_count: usize,
53 pub inconclusive: bool,
60}
61
62pub struct AdversarialReview {
64 pub reviewer: AgentSpec,
66 pub criteria: Vec<String>,
68 pub fail_on_blockers: bool,
70}
71
72impl AdversarialReview {
73 pub fn new(reviewer: AgentSpec, criteria: Vec<String>) -> Self {
74 Self {
75 reviewer,
76 criteria,
77 fail_on_blockers: true,
78 }
79 }
80
81 #[instrument(name = "multi.adversarial_review", skip_all)]
86 pub async fn run(
87 &self,
88 work_output: &str,
89 runner: &Arc<dyn AgentRunner>,
90 infra: &SharedInfra,
91 ) -> Result<AdversarialReviewResult, MultiError> {
92 let criteria_text = self
93 .criteria
94 .iter()
95 .enumerate()
96 .map(|(i, c)| format!("{}. {}", i + 1, c))
97 .collect::<Vec<_>>()
98 .join("\n");
99
100 let review_task = format!(
101 r#"You are an adversarial reviewer. Your job is to find problems.
102
103## Acceptance Criteria
104{criteria}
105
106## Work Output to Review
107{work}
108
109## Instructions
110Evaluate the work output against EACH acceptance criterion above.
111For each criterion, determine if it PASSES or FAILS. Provide specific evidence
112(file paths, line numbers, code snippets, or direct quotes from the output).
113
114Be strict. If a criterion is ambiguous, assume it should be fully met.
115Flag anything suspicious as a "blocker" or "major" finding.
116
117Respond with a JSON object:
118```json
119{{
120 "passed": true/false,
121 "findings": [
122 {{
123 "criterion": "criterion text",
124 "passed": true/false,
125 "evidence": "specific evidence with file:line references",
126 "severity": "blocker|major|minor|info"
127 }}
128 ]
129}}
130```"#,
131 criteria = criteria_text,
132 work = work_output,
133 );
134
135 infra
139 .begin_agent()
140 .map_err(|e| MultiError::BudgetExhausted(e.to_string()))?;
141
142 let mailbox = Mailbox::default();
143 let rt = infra.make_runtime();
144 let output = runner
145 .run(&self.reviewer, &review_task, &rt, &mailbox)
146 .await
147 .map_err(|e| {
148 MultiError::AgentFailed(
149 self.reviewer.name.clone(),
150 format!("adversarial review failed: {}", e),
151 )
152 })?;
153 infra.record_output(&output);
154
155 let parsed = Self::parse_findings(&output.answer);
157 let findings = parsed.findings;
158 let blocker_count = findings.iter().filter(|f| f.severity == "blocker").count();
159 let passed = if parsed.inconclusive {
160 false
163 } else if self.fail_on_blockers {
164 blocker_count == 0 && findings.iter().all(|f| f.passed || f.severity != "major")
165 } else {
166 findings.iter().filter(|f| f.passed).count() > findings.len() / 2
167 };
168
169 Ok(AdversarialReviewResult {
170 spec: criteria_text,
171 passed,
172 findings,
173 reviewer_output: output,
174 blocker_count,
175 inconclusive: parsed.inconclusive,
176 })
177 }
178
179 fn parse_findings(response: &str) -> ParsedReview {
188 if let Some(json_str) = car_ir::json_extract::extract_json_object(response) {
190 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&json_str) {
191 let findings: Vec<ReviewFinding> = parsed
192 .get("findings")
193 .and_then(|f| f.as_array())
194 .map(|arr| {
195 arr.iter()
196 .filter_map(|f| {
197 Some(ReviewFinding {
198 criterion: f.get("criterion")?.as_str()?.to_string(),
199 passed: f.get("passed")?.as_bool()?,
200 evidence: f.get("evidence")?.as_str()?.to_string(),
201 severity: f
202 .get("severity")
203 .and_then(|s| s.as_str())
204 .unwrap_or("major")
205 .to_string(),
206 })
207 })
208 .collect()
209 })
210 .unwrap_or_default();
211 if !findings.is_empty() {
212 return ParsedReview {
213 findings,
214 inconclusive: false,
215 };
216 }
217 if let Some(passed) = parsed.get("passed").and_then(|p| p.as_bool()) {
223 return ParsedReview {
224 findings: vec![ReviewFinding {
225 criterion: "overall".to_string(),
226 passed,
227 evidence: if passed {
228 "reviewer reported a clean pass with no per-criterion findings"
229 } else {
230 "reviewer reported failure with no per-criterion findings"
231 }
232 .to_string(),
233 severity: if passed { "info" } else { "major" }.to_string(),
234 }],
235 inconclusive: false,
236 };
237 }
238 }
241 }
242 let mut evidence = String::from(
246 "reviewer response could not be parsed into a verdict; treat as not verified. raw: ",
247 );
248 let raw = response.trim();
249 if raw.len() > INCONCLUSIVE_EVIDENCE_CAP {
250 let mut end = INCONCLUSIVE_EVIDENCE_CAP;
252 while end > 0 && !raw.is_char_boundary(end) {
253 end -= 1;
254 }
255 evidence.push_str(&raw[..end]);
256 evidence.push_str("… [truncated]");
257 } else {
258 evidence.push_str(raw);
259 }
260 ParsedReview {
261 findings: vec![ReviewFinding {
262 criterion: "overall".to_string(),
263 passed: false,
264 evidence,
265 severity: SEVERITY_INCONCLUSIVE.to_string(),
266 }],
267 inconclusive: true,
268 }
269 }
270}
271
272struct ParsedReview {
275 findings: Vec<ReviewFinding>,
276 inconclusive: bool,
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282 use car_engine::Runtime;
283
284 #[test]
285 fn parse_findings_from_json() {
286 let response = r#"```json
287{
288 "passed": false,
289 "findings": [
290 {"criterion": "all tests pass", "passed": true, "evidence": "cargo test: 50 passed", "severity": "info"},
291 {"criterion": "no hardcoded secrets", "passed": false, "evidence": "src/config.rs:42 contains API key", "severity": "blocker"}
292 ]
293}
294```"#;
295 let parsed = AdversarialReview::parse_findings(response);
296 assert!(!parsed.inconclusive);
297 assert_eq!(parsed.findings.len(), 2);
298 assert!(parsed.findings[0].passed);
299 assert!(!parsed.findings[1].passed);
300 assert_eq!(parsed.findings[1].severity, "blocker");
301 }
302
303 #[test]
304 fn unparseable_response_is_inconclusive_not_a_pass() {
305 let response = "Sure! This work clearly passes criterion 1 and looks great.";
308 let parsed = AdversarialReview::parse_findings(response);
309 assert!(parsed.inconclusive, "no JSON verdict => inconclusive");
310 assert_eq!(parsed.findings.len(), 1);
311 assert!(!parsed.findings[0].passed, "must fail closed, not open");
312 assert_eq!(parsed.findings[0].severity, SEVERITY_INCONCLUSIVE);
313 }
314
315 #[test]
316 fn inconclusive_evidence_is_bounded() {
317 let response = "x".repeat(INCONCLUSIVE_EVIDENCE_CAP * 4);
318 let parsed = AdversarialReview::parse_findings(&response);
319 assert!(parsed.inconclusive);
320 assert!(parsed.findings[0].evidence.len() < INCONCLUSIVE_EVIDENCE_CAP + 128);
322 assert!(parsed.findings[0].evidence.ends_with("… [truncated]"));
323 }
324
325 #[test]
326 fn inconclusive_evidence_truncation_is_utf8_safe() {
327 let response = "中".repeat(INCONCLUSIVE_EVIDENCE_CAP);
331 let parsed = AdversarialReview::parse_findings(&response);
332 assert!(parsed.inconclusive);
333 assert!(parsed.findings[0].evidence.len() < INCONCLUSIVE_EVIDENCE_CAP + 128);
336 assert!(parsed.findings[0].evidence.ends_with("… [truncated]"));
337 }
338
339 #[test]
340 fn empty_findings_with_explicit_pass_is_a_conclusive_pass() {
341 let response = r#"{"passed": true, "findings": []}"#;
344 let parsed = AdversarialReview::parse_findings(response);
345 assert!(
346 !parsed.inconclusive,
347 "explicit top-level verdict is conclusive"
348 );
349 assert_eq!(parsed.findings.len(), 1);
350 assert!(parsed.findings[0].passed);
351 assert_eq!(parsed.findings[0].severity, "info");
352 }
353
354 #[test]
355 fn empty_findings_with_explicit_fail_is_a_conclusive_fail() {
356 let response = r#"{"passed": false, "findings": []}"#;
357 let parsed = AdversarialReview::parse_findings(response);
358 assert!(!parsed.inconclusive);
359 assert!(!parsed.findings[0].passed);
360 }
361
362 #[test]
363 fn empty_findings_without_verdict_is_inconclusive() {
364 let response = r#"{"summary": "looks fine", "findings": []}"#;
366 let parsed = AdversarialReview::parse_findings(response);
367 assert!(parsed.inconclusive);
368 assert_eq!(parsed.findings[0].severity, SEVERITY_INCONCLUSIVE);
369 }
370
371 #[test]
372 fn no_findings_key_and_no_verdict_is_inconclusive() {
373 let response = r#"{"note": "I reviewed it"}"#;
374 let parsed = AdversarialReview::parse_findings(response);
375 assert!(parsed.inconclusive);
376 }
377
378 struct FixedReviewer(&'static str);
381
382 #[async_trait::async_trait]
383 impl AgentRunner for FixedReviewer {
384 async fn run(
385 &self,
386 spec: &AgentSpec,
387 _task: &str,
388 _runtime: &Runtime,
389 _mailbox: &Mailbox,
390 ) -> Result<AgentOutput, MultiError> {
391 Ok(AgentOutput {
392 name: spec.name.clone(),
393 answer: self.0.to_string(),
394 turns: 1,
395 tool_calls: 0,
396 duration_ms: 1.0,
397 error: None,
398 outcome: None,
399 tokens: None,
400 tools_used: Vec::new(),
401 })
402 }
403 }
404
405 #[tokio::test]
406 async fn run_forces_fail_closed_on_inconclusive() {
407 let runner: Arc<dyn AgentRunner> =
410 Arc::new(FixedReviewer("Looks great, this passes everything!"));
411 let infra = SharedInfra::new();
412 let r = AdversarialReview::new(
413 AgentSpec::new("reviewer", "review it"),
414 vec!["criterion one".to_string()],
415 )
416 .run("some work output", &runner, &infra)
417 .await
418 .unwrap();
419 assert!(r.inconclusive, "unparseable review must be inconclusive");
420 assert!(!r.passed, "inconclusive must fail closed at the gate");
421 }
422}