Skip to main content

car_multi/patterns/
adversarial_review.rs

1//! AdversarialReview — fresh agent reviews work against a spec.
2//!
3//! Key property: the reviewer gets NO prior context from the author.
4//! It receives only the work output and the acceptance criteria, then
5//! evaluates pass/fail with evidence (file:line references).
6//!
7//! Inspired by metaswarm's 4-phase execution loop where adversarial
8//! reviewers are ALWAYS fresh Task() instances — never teammates,
9//! never resumed, never given prior context.
10
11use 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/// Severity used for the synthetic finding emitted when the reviewer's
21/// response can't be parsed into a verdict. Distinct from a real
22/// pass/fail so consumers can treat it as "not adversarially verified"
23/// rather than approve or reject. (car#359)
24pub const SEVERITY_INCONCLUSIVE: &str = "inconclusive";
25
26/// Cap on how much of an unparseable reviewer response is carried as
27/// evidence. The raw response was previously dumped verbatim, producing
28/// multi-KB findings (car#359).
29const INCONCLUSIVE_EVIDENCE_CAP: usize = 1024;
30
31/// A single review criterion with pass/fail and evidence.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct ReviewFinding {
34    pub criterion: String,
35    pub passed: bool,
36    pub evidence: String,
37    pub severity: String, // "blocker", "major", "minor", "info", "inconclusive"
38}
39
40/// Result of an adversarial review.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct AdversarialReviewResult {
43    /// The task/spec being reviewed against.
44    pub spec: String,
45    /// Overall pass/fail.
46    pub passed: bool,
47    /// Per-criterion findings.
48    pub findings: Vec<ReviewFinding>,
49    /// The reviewer's raw output.
50    pub reviewer_output: AgentOutput,
51    /// Number of blockers found.
52    pub blocker_count: usize,
53    /// True when the reviewer's response could not be parsed into a
54    /// verdict. In this state `passed` is forced to `false` (fail-closed)
55    /// and `findings` holds a single [`SEVERITY_INCONCLUSIVE`] finding.
56    /// Consumers should treat an inconclusive result as "not
57    /// adversarially verified" — surface it, don't silently approve or
58    /// reject on it. (car#359)
59    pub inconclusive: bool,
60}
61
62/// Configuration for adversarial review.
63pub struct AdversarialReview {
64    /// The reviewer agent spec. Must be a different agent from the author.
65    pub reviewer: AgentSpec,
66    /// The acceptance criteria / spec to review against.
67    pub criteria: Vec<String>,
68    /// Whether blockers auto-fail the review.
69    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    /// Run an adversarial review of the given work output.
82    ///
83    /// The reviewer is always a fresh agent with NO context from the author.
84    /// It only sees: the work output and the acceptance criteria.
85    #[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        // Budget gate the reviewer. The review is a single agent and its whole
136        // purpose; if the budget can't afford it, surface that rather than
137        // returning a misleading empty pass/fail.
138        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        // Parse the review response
156        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            // The review couldn't be parsed into a verdict. Don't guess —
161            // an unverified review is not a pass. (car#359)
162            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    /// Parse a reviewer response into findings.
180    ///
181    /// Returns `inconclusive: true` when no structured verdict could be
182    /// extracted — the previous behavior of guessing pass/fail from the
183    /// substring `"pass"` fails open (review prompts and model chatter
184    /// routinely contain "passes"), so an unparseable review is reported
185    /// as inconclusive instead, for the caller to treat as unverified.
186    /// (car#359)
187    fn parse_findings(response: &str) -> ParsedReview {
188        // Try to extract JSON
189        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                // No usable per-criterion findings, but an explicit
218                // top-level `passed` boolean is still a conclusive verdict —
219                // honor it (a reviewer that finds nothing wrong legitimately
220                // returns `{"passed": true, "findings": []}`) rather than
221                // discarding the one piece of structure the prompt asked for.
222                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                // Parseable JSON but neither well-formed findings nor a
239                // top-level verdict — as unverified as no JSON at all.
240            }
241        }
242        // Fallback: the response carries no extractable verdict. Emit a
243        // single inconclusive finding (fail-closed) with a bounded slice
244        // of the raw response for diagnosis, never the whole thing.
245        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            // Slice on a char boundary to avoid panicking mid-codepoint.
251            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
272/// Outcome of [`AdversarialReview::parse_findings`]: the extracted
273/// findings plus whether the response was unparseable (car#359).
274struct 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        // The old behavior guessed pass from the substring "pass"; a
306        // garbage review echoing "passes criterion" must NOT read as a pass.
307        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        // Prefix + capped slice + suffix — never the full multi-KB dump.
321        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        // 3-byte chars: the cap (1024) is not a multiple of 3, so byte 1024
328        // lands mid-codepoint — exercises the char-boundary walk-back, which
329        // must not panic (slicing mid-codepoint would).
330        let response = "中".repeat(INCONCLUSIVE_EVIDENCE_CAP);
331        let parsed = AdversarialReview::parse_findings(&response);
332        assert!(parsed.inconclusive);
333        // Bounded (prefix + ≤cap slice + suffix) and truncated; valid UTF-8
334        // by construction since `evidence` is a `String`.
335        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        // A reviewer that finds nothing wrong legitimately returns this —
342        // it must NOT be marked inconclusive (car#359 review follow-up).
343        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        // No usable findings AND no top-level boolean — genuinely unverified.
365        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    /// Reviewer that always returns the same fixed answer, to drive
379    /// `run()` end-to-end and pin the gate-level fail-closed behavior.
380    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        // Garbage that echoes "passes" — the old substring heuristic would
408        // have flipped this to passed=true.
409        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}