Skip to main content

magi/
graft_report.rs

1//! What a graft round was asked to port and what the fixer says it did, for
2//! the report.
3//!
4//! New runs keep both in [`GraftRecord`]. An older run kept only the prompt and
5//! the CLI's output under `artifacts/`, so [`recover`] reads them back through
6//! the same per-CLI answer extraction a live call uses, never the raw event
7//! stream. The decisions ([`parse_prompt`], [`adoption_of`], [`standing`]) are
8//! pure; only [`recover`] touches the disk.
9//!
10//! The fixer's report is a claim. A strength counts as adopted only when the
11//! report names it by id or by its text; anything else is `unreported`, and a
12//! record with no report at all says `unknown`, never "applied".
13
14use std::path::Path;
15
16use serde::Serialize;
17
18use crate::config::AgentKind;
19use crate::run::{GraftAdoption, GraftRejection, GraftStrength, RunState};
20use crate::verdict::{self, FixReport};
21
22/// Largest artifact read back, and characters kept of prose.
23const MAX_ARTIFACT_BYTES: u64 = 4 * 1024 * 1024;
24const MAX_PROSE: usize = 20_000;
25
26/// A detail that could not be shown, and why.
27#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
28pub struct Unavailable {
29    /// Which detail: `requested`, `adoption`, `diff`.
30    pub what: &'static str,
31    /// Why, in words.
32    pub reason: String,
33}
34
35/// What was recovered for a historical record.
36#[derive(Debug, Clone, Default, PartialEq, Eq)]
37pub struct Recovered {
38    /// Strengths parsed from the stored prompt.
39    pub requested: Vec<GraftStrength>,
40    /// The fixer's report parsed from the stored output.
41    pub adoption: Option<GraftAdoption>,
42    /// What stayed unknown.
43    pub unavailable: Vec<Unavailable>,
44}
45
46/// Where one strength stands against the fixer's report.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum Standing {
49    /// The report names it as ported.
50    Adopted,
51    /// The report declines it, with the reason.
52    Rejected(String),
53    /// A report exists and does not mention it.
54    Unreported,
55}
56
57fn clip(s: &str) -> String {
58    s.chars().take(MAX_PROSE).collect()
59}
60
61/// The fixer's reply as a stored adoption, or why it holds none. The text
62/// before the report's fence is the reasoning.
63pub fn adoption_of(text: &str) -> (Option<GraftAdoption>, Option<String>) {
64    match verdict::extract_json::<FixReport>(text) {
65        Ok(r) => {
66            let reasoning = text
67                .rfind("```json")
68                .map_or(text, |i| &text[..i])
69                .trim()
70                .to_owned();
71            (
72                Some(GraftAdoption {
73                    addressed: r.addressed,
74                    rejected: r
75                        .rejected
76                        .into_iter()
77                        .map(|x| GraftRejection {
78                            id: x.id,
79                            why: x.why,
80                        })
81                        .collect(),
82                    notes: clip(&r.notes),
83                    reasoning: clip(&reasoning),
84                }),
85                None,
86            )
87        }
88        Err(e) => {
89            let why = e.to_string();
90            let first: String = why.lines().next().unwrap_or("").chars().take(200).collect();
91            (None, Some(format!("no usable report: {first}")))
92        }
93    }
94}
95
96/// The strengths listed in a graft prompt. Items without an `[A1]` tag (a
97/// prompt from before ids) get `A1`-style ids in order.
98pub fn parse_prompt(prompt: &str) -> Vec<GraftStrength> {
99    let mut out = Vec::new();
100    let mut label: Option<char> = None;
101    let mut n = 0;
102    for line in prompt.lines() {
103        if let Some(rest) = line.strip_prefix("## From candidate ") {
104            label = rest.chars().next();
105            n = 0;
106        } else if line.starts_with("# ") && !line.starts_with("# Strengths") {
107            label = None;
108        } else if let (Some(l), Some(item)) = (label, line.strip_prefix("- ")) {
109            n += 1;
110            let tagged = item
111                .strip_prefix('[')
112                .and_then(|r| r.split_once("] "))
113                .filter(|(id, _)| !id.is_empty() && id.chars().all(|c| c.is_ascii_alphanumeric()));
114            let (id, text) = match tagged {
115                Some((id, text)) => (id.to_owned(), text),
116                None => (format!("{l}{n}"), item),
117            };
118            out.push(GraftStrength {
119                id,
120                source: l,
121                text: text.trim().to_owned(),
122            });
123        }
124    }
125    out
126}
127
128fn squash(s: &str) -> String {
129    s.chars()
130        .filter(|c| c.is_alphanumeric())
131        .flat_map(char::to_lowercase)
132        .collect()
133}
134
135/// Does the fixer's entry refer to this strength, by id or by its text?
136fn refers(entry: &str, s: &GraftStrength) -> bool {
137    let first = entry
138        .trim_start_matches(['[', '(', ' ', '`'])
139        .split(|c: char| !c.is_alphanumeric())
140        .next()
141        .unwrap_or("");
142    if first.eq_ignore_ascii_case(&s.id) {
143        return true;
144    }
145    // A prompt from before ids tagged nothing, so a fixer answered with the
146    // candidate's bare label: that covers the strengths taken from it.
147    let bare = entry.trim_matches(|c: char| !c.is_alphanumeric());
148    let mut chars = bare.chars();
149    if let (Some(c), None) = (chars.next(), chars.next()) {
150        if c.eq_ignore_ascii_case(&s.source) {
151            return true;
152        }
153    }
154    let (e, t) = (squash(entry), squash(&s.text));
155    e.len() >= 12 && t.len() >= 12 && (e.contains(&t) || t.contains(&e))
156}
157
158/// Where `s` stands against the report. A rejection wins over an adoption of
159/// the same strength: the report contradicting itself must not read as done.
160pub fn standing(s: &GraftStrength, a: &GraftAdoption) -> Standing {
161    if let Some(r) = a.rejected.iter().find(|r| refers(&r.id, s)) {
162        return Standing::Rejected(r.why.clone());
163    }
164    if a.addressed.iter().any(|x| refers(x, s)) {
165        Standing::Adopted
166    } else {
167        Standing::Unreported
168    }
169}
170
171fn read_capped(path: &Path) -> Result<String, &'static str> {
172    let meta = std::fs::metadata(path).map_err(|_| "artifact_missing")?;
173    if meta.len() > MAX_ARTIFACT_BYTES {
174        return Err("unreadable_output");
175    }
176    std::fs::read_to_string(path).map_err(|_| "unreadable_output")
177}
178
179/// The pure half of [`recover`]: parse whatever text was found.
180pub fn details(
181    kind: Option<AgentKind>,
182    prompt: Result<&str, &'static str>,
183    out: Result<&str, &'static str>,
184) -> Recovered {
185    let mut rec = Recovered::default();
186    match prompt {
187        Ok(p) => {
188            rec.requested = parse_prompt(p);
189            if rec.requested.is_empty() {
190                rec.unavailable.push(Unavailable {
191                    what: "requested",
192                    reason: "the stored prompt lists no strengths".to_owned(),
193                });
194            }
195        }
196        Err(why) => rec.unavailable.push(Unavailable {
197            what: "requested",
198            reason: why.to_owned(),
199        }),
200    }
201    match (kind, out) {
202        (_, Err(why)) => rec.unavailable.push(Unavailable {
203            what: "adoption",
204            reason: why.to_owned(),
205        }),
206        (None, Ok(_)) => rec.unavailable.push(Unavailable {
207            what: "adoption",
208            reason: "the graft agent is no longer in the roster, so its output cannot be read"
209                .to_owned(),
210        }),
211        (Some(k), Ok(raw)) => {
212            let (adoption, err) = adoption_of(&crate::agent::answer_text(k, raw));
213            match adoption {
214                Some(a) => rec.adoption = Some(a),
215                None => rec.unavailable.push(Unavailable {
216                    what: "adoption",
217                    reason: err.unwrap_or_else(|| "unreadable_output".to_owned()),
218                }),
219            }
220        }
221    }
222    rec
223}
224
225/// Read back the pieces a record from before `requested` / `adoption` lacks.
226/// The output file is chosen only when it provably belongs to the agent the
227/// record names: the last graft handover's target, else the first attempt.
228pub fn recover(state: &RunState, artifacts: &Path) -> Recovered {
229    let Some(g) = &state.graft else {
230        return Recovered::default();
231    };
232    let need_requested = g.requested.is_empty();
233    let need_adoption = g.adoption.is_none() && g.adoption_error.is_none();
234    if !need_requested && !need_adoption {
235        return Recovered::default();
236    }
237    let last_to = state
238        .handovers
239        .iter()
240        .rfind(|h| h.node == "graft")
241        .map(|h| h.to.as_str());
242    let stem = match (&g.agent, last_to) {
243        (None, _) => None,
244        (Some(a), Some(to)) => (a == to).then(|| format!("graft-{a}")),
245        (Some(_), None) => Some("graft".to_owned()),
246    };
247    let prompt = read_capped(&artifacts.join("graft.prompt.md"));
248    let out = match &stem {
249        Some(s) => read_capped(&artifacts.join(format!("{s}.out"))),
250        None => Err("artifact_missing"),
251    };
252    let kind = g
253        .agent
254        .as_deref()
255        .and_then(|a| state.config.agents.iter().find(|s| s.id == a))
256        .map(|s| s.kind);
257    let mut rec = details(
258        kind,
259        prompt.as_deref().map_err(|e| *e),
260        out.as_deref().map_err(|e| *e),
261    );
262    if !need_requested {
263        rec.requested.clear();
264        rec.unavailable.retain(|u| u.what != "requested");
265    }
266    if !need_adoption {
267        rec.adoption = None;
268        rec.unavailable.retain(|u| u.what != "adoption");
269    }
270    rec
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    const PROMPT: &str = "# Graft the runner-up strengths\n\nx\n\n# Strengths to port\n\n## From candidate A (branch `b`, read-only)\n\n- [A1] Build Persona directly in the test\n- [A2] Cache the lookup\n\n## From candidate C (branch `c`, read-only)\n\n- plain legacy item\n\n# Rules\n\n- not a strength\n";
278
279    #[test]
280    fn prompt_items_get_ids_tagged_or_numbered() {
281        let v = parse_prompt(PROMPT);
282        let ids: Vec<_> = v.iter().map(|s| (s.id.as_str(), s.source)).collect();
283        assert_eq!(ids, [("A1", 'A'), ("A2", 'A'), ("C1", 'C')]);
284        assert_eq!(v[0].text, "Build Persona directly in the test");
285    }
286
287    #[test]
288    fn a_reply_without_a_report_holds_no_adoption() {
289        let (a, e) = adoption_of("I did some things.");
290        assert!(a.is_none());
291        assert!(e.unwrap().starts_with("no usable report"));
292    }
293
294    #[test]
295    fn requested_is_not_adopted_unless_the_report_says_so() {
296        let reply = "Ported the test change.\n\n```json\n{\"addressed\":[\"A1\"],\"rejected\":[{\"id\":\"A2\",\"why\":\"too big\"}],\"notes\":\"n\"}\n```";
297        let (a, _) = adoption_of(reply);
298        let a = a.unwrap();
299        assert_eq!(a.reasoning, "Ported the test change.");
300        let v = parse_prompt(PROMPT);
301        assert_eq!(standing(&v[0], &a), Standing::Adopted);
302        assert_eq!(standing(&v[1], &a), Standing::Rejected("too big".into()));
303        assert_eq!(standing(&v[2], &a), Standing::Unreported);
304    }
305
306    #[test]
307    fn a_strength_is_matched_by_its_text_when_the_id_is_missing() {
308        let a = GraftAdoption {
309            addressed: vec!["Build Persona directly in the test".to_owned()],
310            ..GraftAdoption::default()
311        };
312        let v = parse_prompt(PROMPT);
313        assert_eq!(standing(&v[0], &a), Standing::Adopted);
314        assert_eq!(standing(&v[1], &a), Standing::Unreported);
315    }
316
317    #[test]
318    fn a_bare_candidate_label_covers_that_sources_strengths() {
319        let a = GraftAdoption {
320            addressed: vec!["A".to_owned()],
321            ..GraftAdoption::default()
322        };
323        let v = parse_prompt(PROMPT);
324        assert_eq!(standing(&v[0], &a), Standing::Adopted);
325        assert_eq!(standing(&v[1], &a), Standing::Adopted);
326        assert_eq!(standing(&v[2], &a), Standing::Unreported);
327        let (a, _) = adoption_of(
328            "done\n\n```json\n{\"addressed\":[\"A\"],\"rejected\":[],\"notes\":\"\"}\n```",
329        );
330        assert_eq!(standing(&v[0], &a.unwrap()), Standing::Adopted);
331    }
332
333    #[test]
334    fn a_contradictory_report_does_not_read_as_adopted() {
335        let a = GraftAdoption {
336            addressed: vec!["A1".into()],
337            rejected: vec![GraftRejection {
338                id: "A1".into(),
339                why: "no".into(),
340            }],
341            ..GraftAdoption::default()
342        };
343        assert_eq!(
344            standing(&parse_prompt(PROMPT)[0], &a),
345            Standing::Rejected("no".into())
346        );
347    }
348
349    #[test]
350    fn details_extracts_the_answer_through_the_cli_shape_not_the_raw_stream() {
351        let inner = "why\n\n```json\n{\"addressed\":[\"A1\"],\"notes\":\"ok\"}\n```";
352        let out = serde_json::json!({"result": inner, "session_id": "s"}).to_string();
353        let r = details(Some(AgentKind::Claude), Ok(PROMPT), Ok(&out));
354        assert_eq!(r.requested.len(), 3);
355        assert_eq!(r.adoption.unwrap().addressed, ["A1"]);
356        assert!(r.unavailable.is_empty());
357    }
358
359    #[test]
360    fn missing_and_unreadable_pieces_are_named() {
361        let r = details(
362            Some(AgentKind::Claude),
363            Err("artifact_missing"),
364            Ok("not json at all"),
365        );
366        assert!(r.requested.is_empty());
367        let whats: Vec<_> = r.unavailable.iter().map(|u| u.what).collect();
368        assert_eq!(whats, ["requested", "adoption"]);
369        let r = details(None, Ok(PROMPT), Ok("x"));
370        assert!(r.adoption.is_none());
371        assert_eq!(r.unavailable[0].what, "adoption");
372    }
373    fn legacy_state(agent: &str, handover_to: Option<&str>) -> RunState {
374        crate::run::pin_test_home();
375        let mut s = RunState::new(
376            std::path::PathBuf::from("/repo/magi"),
377            "main".to_owned(),
378            "0123456789abcdef".to_owned(),
379            "task".to_owned(),
380            crate::config::Config::default(),
381        );
382        s.graft = Some(crate::run::GraftRecord {
383            pre: "p".into(),
384            post: Some("q".into()),
385            lines: Some(1),
386            limit: Some(40),
387            agent: Some(agent.into()),
388            outcome: crate::run::GraftOutcome::Applied,
389            requested: Vec::new(),
390            adoption: None,
391            adoption_error: None,
392        });
393        if let Some(to) = handover_to {
394            s.handovers.push(crate::run::Handover {
395                at: jiff::Timestamp::now(),
396                node: "graft".into(),
397                seat: "graft".into(),
398                from: "x".into(),
399                to: to.into(),
400                reason: "r".into(),
401            });
402        }
403        s
404    }
405
406    #[test]
407    fn recover_reads_the_handover_target_output_and_nothing_else() {
408        let dir = tempfile::tempdir().unwrap();
409        std::fs::write(dir.path().join("graft.prompt.md"), PROMPT).unwrap();
410        let inner = "ok\n\n```json\n{\"addressed\":[\"A1\"],\"notes\":\"n\"}\n```";
411        std::fs::write(
412            dir.path().join("graft-astra.out"),
413            serde_json::json!({"result": inner}).to_string(),
414        )
415        .unwrap();
416        // The first attempt's output must not be taken for the final agent's.
417        std::fs::write(dir.path().join("graft.out"), "{\"result\":\"stale\"}").unwrap();
418        let mut s = legacy_state("astra", Some("astra"));
419        s.config.agents =
420            vec![toml::from_str("id = 'astra'\nkind = 'claude'").expect("agent spec")];
421        let r = recover(&s, dir.path());
422        assert_eq!(r.requested.len(), 3);
423        assert_eq!(r.adoption.unwrap().addressed, ["A1"]);
424    }
425
426    #[test]
427    fn recover_refuses_an_output_it_cannot_attribute() {
428        let dir = tempfile::tempdir().unwrap();
429        std::fs::write(dir.path().join("graft.prompt.md"), PROMPT).unwrap();
430        std::fs::write(dir.path().join("graft.out"), "{}").unwrap();
431        // The record names `sol` but the last handover went to `astra`.
432        let s = legacy_state("sol", Some("astra"));
433        let r = recover(&s, dir.path());
434        assert!(r.adoption.is_none());
435        assert!(r.unavailable.iter().any(|u| u.what == "adoption"));
436        // No artifacts at all.
437        let empty = tempfile::tempdir().unwrap();
438        let r = recover(&legacy_state("sol", None), empty.path());
439        assert_eq!(r.unavailable.len(), 2);
440        assert_eq!(r.unavailable[0].reason, "artifact_missing");
441    }
442}