Skip to main content

magi/
verdict.rs

1//! Structured answers extracted from free-form agent output.
2//!
3//! Agents are asked to end with a fenced `json` block. They mostly do, and
4//! sometimes they narrate afterwards, emit two blocks, or wrap the object in
5//! prose. [`extract_json`] therefore scans for *every* balanced top-level
6//! object in the text and returns the last one that deserializes into the type
7//! the caller wants, rather than trusting a single regex to find the right one.
8use std::collections::BTreeMap;
9
10use anyhow::{Result, bail};
11use serde::{Deserialize, Serialize};
12
13/// A judge's independent ranking of the candidates.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct Ranking {
16    /// Labels, best first. Must be a permutation of the presented labels.
17    pub ranking: Vec<String>,
18    /// Per-label justification.
19    #[serde(default)]
20    pub reasons: BTreeMap<String, String>,
21    /// Self-reported confidence, 1-5.
22    #[serde(default)]
23    pub confidence: Option<u8>,
24}
25
26impl Ranking {
27    /// The judge's first choice.
28    pub fn top(&self) -> Option<&str> {
29        self.ranking.first().map(String::as_str)
30    }
31
32    /// Reject a ranking that is not a permutation of `labels`, so a malformed
33    /// verdict is retried instead of silently skewing the tally.
34    pub fn validate(&self, labels: &[char]) -> Result<()> {
35        let mut got: Vec<char> = self
36            .ranking
37            .iter()
38            .filter_map(|s| s.trim().chars().next())
39            .map(|c| c.to_ascii_uppercase())
40            .collect();
41        got.sort_unstable();
42        got.dedup();
43        let mut want: Vec<char> = labels.to_vec();
44        want.sort_unstable();
45        if got != want {
46            bail!(
47                "ranking {:?} is not a permutation of the candidate labels {:?}",
48                self.ranking,
49                labels
50            );
51        }
52        Ok(())
53    }
54
55    /// Normalise labels to single uppercase characters.
56    pub fn normalized(&self) -> Vec<char> {
57        self.ranking
58            .iter()
59            .filter_map(|s| s.trim().chars().next())
60            .map(|c| c.to_ascii_uppercase())
61            .collect()
62    }
63}
64
65/// A judge's final vote, collected privately.
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct FinalVote {
68    /// The winning label, in this judge's view.
69    pub vote: String,
70    /// Why.
71    #[serde(default)]
72    pub reason: String,
73}
74
75impl FinalVote {
76    /// The voted label as a single uppercase char.
77    pub fn label(&self) -> Option<char> {
78        self.vote
79            .trim()
80            .chars()
81            .next()
82            .map(|c| c.to_ascii_uppercase())
83    }
84}
85
86/// How bad a review finding is.
87#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
88#[serde(rename_all = "lowercase")]
89pub enum Severity {
90    /// Cosmetic.
91    Nit,
92    /// Should fix, does not block.
93    Minor,
94    /// Should fix before merge.
95    Major,
96    /// Must fix before merge.
97    Blocker,
98}
99
100impl Severity {
101    /// Does this finding hold the merge?
102    pub fn blocks(self) -> bool {
103        matches!(self, Self::Major | Self::Blocker)
104    }
105}
106
107/// One review finding.
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct Finding {
110    /// Assigned by magi after parsing, e.g. `R1-1-2`. Never trusted from the
111    /// agent, because the fixer's adoption report is keyed by it.
112    #[serde(default)]
113    pub id: String,
114    /// How bad.
115    pub severity: Severity,
116    /// File it concerns.
117    #[serde(default)]
118    pub file: Option<String>,
119    /// Line it concerns.
120    #[serde(default)]
121    pub line: Option<u32>,
122    /// One-line summary.
123    pub title: String,
124    /// The argument.
125    #[serde(default)]
126    pub detail: String,
127}
128
129/// A reviewer's report.
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct Review {
132    /// Findings, worst first is conventional but not required.
133    #[serde(default)]
134    pub findings: Vec<Finding>,
135    /// Reviewer's overall verdict prose.
136    #[serde(default)]
137    pub summary: String,
138}
139
140/// The fixer's response to a round of findings.
141#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct FixReport {
143    /// Finding ids that were acted on.
144    #[serde(default)]
145    pub addressed: Vec<String>,
146    /// Finding ids that were deliberately not acted on, with the reason.
147    #[serde(default)]
148    pub rejected: Vec<Rejection>,
149    /// What changed.
150    #[serde(default)]
151    pub notes: String,
152}
153
154/// A finding the fixer declined, and why.
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct Rejection {
157    /// Finding id.
158    pub id: String,
159    /// Argument for not acting.
160    #[serde(default)]
161    pub why: String,
162}
163
164/// A judge's position during deliberation.
165#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct Position {
167    /// Where the judge currently stands.
168    #[serde(default)]
169    pub tentative: Option<String>,
170}
171
172/// One advisor's independent design proposal, gathered by
173/// [`crate::advise`] between a `magi plan` interview and the task file it
174/// files.
175///
176/// No patch and no plan of tool calls - a design proposal is cheap exactly
177/// because it is a few paragraphs an agent can write without touching the
178/// repository, which is what makes running three of them, on every task,
179/// affordable in a way three full implementations were not.
180#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct Proposal {
182    /// What to do and how, in the advisor's own words.
183    pub approach: String,
184    /// The one tradeoff this design turns on.
185    pub key_tradeoff: String,
186    /// What could go wrong.
187    #[serde(default)]
188    pub risks: Vec<String>,
189    /// Files or modules the design touches.
190    #[serde(default)]
191    pub touches: Vec<String>,
192    /// Why this earns its complexity over the obvious first draft - the
193    /// question that keeps a proposal from being a restatement of the task.
194    pub why_not_naive: String,
195}
196
197impl Proposal {
198    /// Reject a proposal with an empty field that matters, so a malformed or
199    /// lazy answer is retried instead of silently thinning the panel down to
200    /// prose nobody can act on.
201    pub fn validate(&self) -> Result<()> {
202        for (field, value) in [
203            ("approach", &self.approach),
204            ("key_tradeoff", &self.key_tradeoff),
205            ("why_not_naive", &self.why_not_naive),
206        ] {
207            if value.trim().is_empty() {
208                bail!("`{field}` is empty");
209            }
210        }
211        Ok(())
212    }
213}
214
215/// Extract the last balanced JSON object in `text` that parses as `T`.
216///
217/// Handles fenced blocks, trailing prose, and multiple objects. Strings and
218/// escapes are tracked so a `}` inside a string literal does not close an
219/// object early.
220pub fn extract_json<T: serde::de::DeserializeOwned>(text: &str) -> Result<T> {
221    let bytes = text.as_bytes();
222    let mut spans: Vec<(usize, usize)> = Vec::new();
223    let mut i = 0usize;
224    while i < bytes.len() {
225        if bytes[i] != b'{' {
226            i += 1;
227            continue;
228        }
229        let mut depth = 0usize;
230        let mut in_str = false;
231        let mut escaped = false;
232        let mut j = i;
233        while j < bytes.len() {
234            let c = bytes[j];
235            if in_str {
236                if escaped {
237                    escaped = false;
238                } else if c == b'\\' {
239                    escaped = true;
240                } else if c == b'"' {
241                    in_str = false;
242                }
243            } else {
244                match c {
245                    b'"' => in_str = true,
246                    b'{' => depth += 1,
247                    b'}' => {
248                        depth -= 1;
249                        if depth == 0 {
250                            spans.push((i, j + 1));
251                            break;
252                        }
253                    }
254                    _ => {}
255                }
256            }
257            j += 1;
258        }
259        // Skip past this object's opening brace either way; a truncated object
260        // must not make the scan quadratic on long transcripts.
261        i = if depth == 0 && j < bytes.len() {
262            j + 1
263        } else {
264            i + 1
265        };
266    }
267
268    let mut last_err = None;
269    for (start, end) in spans.iter().rev() {
270        match serde_json::from_str::<T>(&text[*start..*end]) {
271            Ok(v) => return Ok(v),
272            Err(e) => last_err = Some(e),
273        }
274    }
275    match last_err {
276        Some(e) => bail!("no JSON object in the reply matched the expected shape: {e}"),
277        None => bail!("the reply contained no JSON object"),
278    }
279}
280
281/// Pull the text after a `## <heading>` marker, to the end or the next heading.
282///
283/// Used for the prose sections agents are asked to emit alongside their JSON.
284pub fn section(text: &str, heading: &str) -> Option<String> {
285    let want = heading.to_ascii_lowercase();
286    let mut out: Option<String> = None;
287    for line in text.lines() {
288        let trimmed = line.trim();
289        if let Some(rest) = trimmed.strip_prefix("##") {
290            let name = rest.trim_start_matches('#').trim().to_ascii_lowercase();
291            if name == want {
292                out = Some(String::new());
293                continue;
294            }
295            if out.is_some() {
296                break;
297            }
298            continue;
299        }
300        if let Some(buf) = out.as_mut() {
301            buf.push_str(line);
302            buf.push('\n');
303        }
304    }
305    out.map(|s| s.trim().to_owned()).filter(|s| !s.is_empty())
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311
312    #[test]
313    fn fenced_block_is_found() {
314        let text = "Here is my verdict.\n\n```json\n{\"ranking\":[\"B\",\"A\"]}\n```\n";
315        let r: Ranking = extract_json(text).unwrap();
316        assert_eq!(r.top(), Some("B"));
317    }
318
319    #[test]
320    fn last_matching_object_wins_over_an_earlier_example() {
321        let text = concat!(
322            "The format is {\"ranking\":[\"X\"]} for illustration.\n",
323            "```json\n{\"ranking\":[\"C\",\"A\",\"B\"],\"confidence\":4}\n```\n",
324            "Happy to elaborate.\n"
325        );
326        let r: Ranking = extract_json(text).unwrap();
327        assert_eq!(r.normalized(), ['C', 'A', 'B']);
328        assert_eq!(r.confidence, Some(4));
329    }
330
331    #[test]
332    fn braces_inside_strings_do_not_close_the_object() {
333        let text = r#"{"ranking":["A"],"reasons":{"A":"uses format!(\"{}\", x) safely}"}}"#;
334        let r: Ranking = extract_json(text).unwrap();
335        assert_eq!(r.top(), Some("A"));
336        assert!(r.reasons["A"].contains("format!"));
337    }
338
339    #[test]
340    fn objects_of_the_wrong_shape_are_skipped() {
341        let text = concat!(
342            "```json\n{\"ranking\":[\"A\",\"B\"]}\n```\n",
343            "and some telemetry: {\"tokens\":123}\n"
344        );
345        let r: Ranking = extract_json(text).unwrap();
346        assert_eq!(r.normalized(), ['A', 'B']);
347    }
348
349    #[test]
350    fn no_json_is_an_error_not_a_default() {
351        let err = extract_json::<Ranking>("I decline to produce JSON.").unwrap_err();
352        assert!(err.to_string().contains("no JSON object"));
353    }
354
355    #[test]
356    fn truncated_object_does_not_hang() {
357        let err = extract_json::<Ranking>("{\"ranking\": [\"A\"").unwrap_err();
358        assert!(err.to_string().contains("no JSON object"));
359    }
360
361    #[test]
362    fn ranking_validation_rejects_a_non_permutation() {
363        let r = Ranking {
364            ranking: vec!["A".to_owned(), "A".to_owned()],
365            reasons: BTreeMap::new(),
366            confidence: None,
367        };
368        assert!(r.validate(&['A', 'B', 'C']).is_err());
369
370        let r = Ranking {
371            ranking: vec!["c".to_owned(), "B".to_owned(), "A".to_owned()],
372            reasons: BTreeMap::new(),
373            confidence: None,
374        };
375        r.validate(&['A', 'B', 'C']).expect("case is normalised");
376        assert_eq!(r.normalized(), ['C', 'B', 'A']);
377    }
378
379    #[test]
380    fn final_vote_label_is_normalised() {
381        let v: FinalVote = extract_json(r#"{"vote":" b ","reason":"tests"}"#).unwrap();
382        assert_eq!(v.label(), Some('B'));
383    }
384
385    #[test]
386    fn severity_blocking_is_major_and_up() {
387        assert!(Severity::Blocker.blocks());
388        assert!(Severity::Major.blocks());
389        assert!(!Severity::Minor.blocks());
390        assert!(!Severity::Nit.blocks());
391        assert!(Severity::Blocker > Severity::Nit);
392    }
393
394    #[test]
395    fn review_parses_with_optional_fields_missing() {
396        let r: Review = extract_json(
397            r#"{"findings":[{"severity":"blocker","title":"panics on empty input"}]}"#,
398        )
399        .unwrap();
400        assert_eq!(r.findings.len(), 1);
401        assert!(r.findings[0].file.is_none());
402        assert_eq!(r.findings[0].id, "");
403    }
404
405    #[test]
406    fn fix_report_parses_rejections() {
407        let f: FixReport = extract_json(
408            r#"{"addressed":["R1-1-1"],"rejected":[{"id":"R1-2-1","why":"not reachable"}]}"#,
409        )
410        .unwrap();
411        assert_eq!(f.addressed, ["R1-1-1"]);
412        assert_eq!(f.rejected[0].id, "R1-2-1");
413    }
414
415    #[test]
416    fn sections_are_sliced_by_heading() {
417        let text = "## SUMMARY\nchanged the retry loop.\nadded a test.\n\n## NOTES\nignore me\n";
418        assert_eq!(
419            section(text, "summary").unwrap(),
420            "changed the retry loop.\nadded a test."
421        );
422        assert_eq!(section(text, "notes").unwrap(), "ignore me");
423        assert!(section(text, "missing").is_none());
424    }
425
426    fn proposal() -> Proposal {
427        Proposal {
428            approach: "extract a helper".to_owned(),
429            key_tradeoff: "one more indirection for less duplication".to_owned(),
430            risks: vec!["callers must agree on the new signature".to_owned()],
431            touches: vec!["src/config.rs".to_owned()],
432            why_not_naive: "the naive copy-paste drifts the next time a field is added".to_owned(),
433        }
434    }
435
436    #[test]
437    fn a_complete_proposal_validates() {
438        assert!(proposal().validate().is_ok());
439    }
440
441    #[test]
442    fn a_proposal_missing_why_not_naive_is_rejected() {
443        let mut p = proposal();
444        p.why_not_naive = "   ".to_owned();
445        let err = p.validate().expect_err("must be rejected").to_string();
446        assert!(err.contains("why_not_naive"), "{err}");
447    }
448
449    #[test]
450    fn a_proposal_parses_from_a_fenced_json_block_with_no_risks_or_touches_given() {
451        let text = "```json\n{\"approach\":\"a\",\"key_tradeoff\":\"b\",\
452                     \"why_not_naive\":\"c\"}\n```\n";
453        let p: Proposal = extract_json(text).unwrap();
454        assert!(p.validate().is_ok());
455        assert!(p.risks.is_empty());
456        assert!(p.touches.is_empty());
457    }
458}