1use std::collections::{HashMap, HashSet};
17
18use locode_protocol::{ContentBlock, Message, ResultChunk, Role};
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
22pub struct RepairStats {
23 pub synthesized: usize,
25 pub deduped: usize,
27}
28
29impl RepairStats {
30 #[must_use]
32 pub fn is_noop(self) -> bool {
33 self.synthesized == 0 && self.deduped == 0
34 }
35}
36
37const DANGLING_RESULT_TEXT: &str =
39 "tool result missing: this call was not completed (synthesized to keep the transcript valid)";
40
41pub 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
53fn dedup_duplicate_results(messages: &mut Vec<Message>) -> usize {
56 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 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 messages.retain(|m| !m.content.is_empty());
93 removed
94}
95
96fn 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 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"), ];
193 let stats = repair_pairing(&mut messages);
194 assert_eq!(stats.synthesized, 1);
195 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}