Skip to main content

locode_provider/
repair.rs

1//! Pre-send transcript hygiene: pairing repair + duplicate-result dedup (ADR-0004).
2//!
3//! Providers reject the **entire** request if a `tool_use` has no matching
4//! `tool_result`, or if a `tool_result` is duplicated. ADR-0004 makes this a single
5//! function the provider layer runs unconditionally before every send, rather than
6//! scattering checks. It lives here (not in `locode-protocol`, which is types-only)
7//! because it is a provider-layer concern: the engine — which depends on
8//! `locode-provider` — calls it before each sample, and each wire calls it before
9//! serializing (Task 12). Adapted from Grok Build's `repair_dangling_tool_calls` +
10//! `dedup_duplicate_tool_results`.
11//!
12//! Our transcript nests `ToolUse` inside an `Assistant` message and `ToolResult`
13//! inside the following `User` message(s), so the port scans messages rather than a
14//! flat item list.
15
16use std::collections::{HashMap, HashSet};
17
18use locode_protocol::{ContentBlock, Message, ResultChunk, Role};
19
20/// What a [`repair_pairing`] pass changed.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
22pub struct RepairStats {
23    /// Synthetic `is_error` results added for dangling `tool_use` blocks.
24    pub synthesized: usize,
25    /// Duplicate `tool_result` blocks removed (kept the last per id).
26    pub deduped: usize,
27}
28
29impl RepairStats {
30    /// Whether the pass left the transcript unchanged.
31    #[must_use]
32    pub fn is_noop(self) -> bool {
33        self.synthesized == 0 && self.deduped == 0
34    }
35}
36
37/// The text a synthesized result carries for a `tool_use` that never got a result.
38const DANGLING_RESULT_TEXT: &str =
39    "tool result missing: this call was not completed (synthesized to keep the transcript valid)";
40
41/// Make `messages` valid to send: dedup duplicate `tool_result`s, then synthesize
42/// `is_error` results for any dangling `tool_use`. Idempotent — a valid transcript
43/// passes through unchanged.
44pub fn repair_pairing(messages: &mut Vec<Message>) -> RepairStats {
45    let deduped = dedup_duplicate_results(messages);
46    let synthesized = repair_dangling(messages);
47    RepairStats {
48        synthesized,
49        deduped,
50    }
51}
52
53/// Remove duplicate `tool_result` blocks, keeping the **last** per `tool_use_id`
54/// (Grok's `dedup_duplicate_tool_results` semantics). Drops any message left empty.
55fn dedup_duplicate_results(messages: &mut Vec<Message>) -> usize {
56    // The winning (message, block) position for each id is its last occurrence.
57    let mut last: HashMap<&str, (usize, usize)> = HashMap::new();
58    for (mi, message) in messages.iter().enumerate() {
59        for (bi, block) in message.content.iter().enumerate() {
60            if let ContentBlock::ToolResult { tool_use_id, .. } = block {
61                last.insert(tool_use_id.as_str(), (mi, bi));
62            }
63        }
64    }
65    // Nothing repeats if every id's count is 1; cheap early exit.
66    if last.len()
67        == messages
68            .iter()
69            .flat_map(|m| &m.content)
70            .filter(|b| matches!(b, ContentBlock::ToolResult { .. }))
71            .count()
72    {
73        return 0;
74    }
75
76    let winners: HashSet<(usize, usize)> = last.into_values().collect();
77    let mut removed = 0;
78    for (mi, message) in messages.iter_mut().enumerate() {
79        let mut bi = 0;
80        message.content.retain(|block| {
81            let here = bi;
82            bi += 1;
83            let is_result = matches!(block, ContentBlock::ToolResult { .. });
84            let keep = !is_result || winners.contains(&(mi, here));
85            if !keep {
86                removed += 1;
87            }
88            keep
89        });
90    }
91    // A message emptied by dedup would itself be rejected by the wire — drop it.
92    messages.retain(|m| !m.content.is_empty());
93    removed
94}
95
96/// Synthesize an `is_error` `tool_result` for every `tool_use` with no result,
97/// placing it in the message immediately after the emitting assistant turn.
98fn repair_dangling(messages: &mut Vec<Message>) -> usize {
99    let answered: HashSet<String> = messages
100        .iter()
101        .flat_map(|m| &m.content)
102        .filter_map(|b| match b {
103            ContentBlock::ToolResult { tool_use_id, .. } => Some(tool_use_id.clone()),
104            _ => None,
105        })
106        .collect();
107
108    let mut synthesized = 0;
109    let mut i = 0;
110    while i < messages.len() {
111        if messages[i].role == Role::Assistant {
112            let dangling: Vec<String> = messages[i]
113                .content
114                .iter()
115                .filter_map(|b| match b {
116                    ContentBlock::ToolUse { id, .. } if !answered.contains(id) => Some(id.clone()),
117                    _ => None,
118                })
119                .collect();
120
121            if !dangling.is_empty() {
122                synthesized += dangling.len();
123                let synth: Vec<ContentBlock> = dangling
124                    .into_iter()
125                    .map(|id| ContentBlock::ToolResult {
126                        tool_use_id: id,
127                        content: vec![ResultChunk::Text {
128                            text: DANGLING_RESULT_TEXT.to_owned(),
129                        }],
130                        is_error: true,
131                    })
132                    .collect();
133
134                // Anthropic wants the results in the User turn right after the
135                // assistant turn: reuse it if present, else insert a fresh one.
136                if messages.get(i + 1).is_some_and(|m| m.role == Role::User) {
137                    let existing = std::mem::take(&mut messages[i + 1].content);
138                    let mut merged = synth;
139                    merged.extend(existing);
140                    messages[i + 1].content = merged;
141                } else {
142                    messages.insert(
143                        i + 1,
144                        Message {
145                            role: Role::User,
146                            content: synth,
147                        },
148                    );
149                }
150            }
151        }
152        i += 1;
153    }
154    synthesized
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use serde_json::json;
161
162    fn assistant_tool_use(id: &str) -> Message {
163        Message {
164            role: Role::Assistant,
165            content: vec![ContentBlock::ToolUse {
166                id: id.into(),
167                name: "echo".into(),
168                input: json!({}),
169            }],
170        }
171    }
172
173    fn user_result(id: &str, text: &str) -> Message {
174        Message {
175            role: Role::User,
176            content: vec![ContentBlock::ToolResult {
177                tool_use_id: id.into(),
178                content: vec![ResultChunk::Text { text: text.into() }],
179                is_error: false,
180            }],
181        }
182    }
183
184    #[test]
185    fn dangling_tool_use_gets_synthetic_result() {
186        let mut messages = vec![
187            Message {
188                role: Role::User,
189                content: vec![ContentBlock::Text { text: "go".into() }],
190            },
191            assistant_tool_use("c1"), // no following result
192        ];
193        let stats = repair_pairing(&mut messages);
194        assert_eq!(stats.synthesized, 1);
195        // A trailing User message with an is_error result for c1 was inserted.
196        let last = messages.last().expect("a message");
197        assert_eq!(last.role, Role::User);
198        assert!(matches!(
199            last.content.first(),
200            Some(ContentBlock::ToolResult { tool_use_id, is_error: true, .. }) if tool_use_id == "c1"
201        ));
202    }
203
204    #[test]
205    fn duplicate_results_keep_the_last() {
206        let mut messages = vec![
207            assistant_tool_use("c1"),
208            Message {
209                role: Role::User,
210                content: vec![
211                    ContentBlock::ToolResult {
212                        tool_use_id: "c1".into(),
213                        content: vec![ResultChunk::Text {
214                            text: "first".into(),
215                        }],
216                        is_error: false,
217                    },
218                    ContentBlock::ToolResult {
219                        tool_use_id: "c1".into(),
220                        content: vec![ResultChunk::Text {
221                            text: "second".into(),
222                        }],
223                        is_error: false,
224                    },
225                ],
226            },
227        ];
228        let stats = repair_pairing(&mut messages);
229        assert_eq!(stats.deduped, 1);
230        assert_eq!(stats.synthesized, 0);
231        let results: Vec<_> = messages
232            .iter()
233            .flat_map(|m| &m.content)
234            .filter(|b| matches!(b, ContentBlock::ToolResult { .. }))
235            .collect();
236        assert_eq!(results.len(), 1, "only the last result should survive");
237        assert!(matches!(
238            results[0],
239            ContentBlock::ToolResult { content, .. }
240                if matches!(content.first(), Some(ResultChunk::Text { text }) if text == "second")
241        ));
242    }
243
244    #[test]
245    fn valid_transcript_is_unchanged() {
246        let mut messages = vec![assistant_tool_use("c1"), user_result("c1", "ok")];
247        let before = messages.clone();
248        let stats = repair_pairing(&mut messages);
249        assert!(stats.is_noop());
250        assert_eq!(messages, before, "a paired transcript must pass through");
251    }
252
253    #[test]
254    fn repair_is_idempotent() {
255        let mut messages = vec![assistant_tool_use("c1")];
256        let first = repair_pairing(&mut messages);
257        assert_eq!(first.synthesized, 1);
258        let second = repair_pairing(&mut messages);
259        assert!(second.is_noop(), "second pass must find nothing to fix");
260    }
261}