返回 CodeWhale
tool_history_repair.rs
根目录 / crates / tui / src / tool_history_repair.rs
1 //! Deterministic integrity repair for persisted model-visible tool history.
2 //!
3 //! Session JSON predates a durable per-call journal, so process exit can leave
4 //! a `tool_use` without its terminal `tool_result`. Provider APIs reject that
5 //! shape. This module repairs the existing message format without changing its
6 //! schema and returns a bounded diagnostic receipt for every mutation.
7
8 use std::collections::{HashMap, HashSet};
9
10 use crate::models::{ContentBlock, Message};
11
12 const CRASH_REPAIR_CONTENT: &str =
13 "Tool call interrupted by process exit; terminal status: crashed_and_repaired.";
14
15 #[derive(Debug, Clone, Default, PartialEq, Eq)]
16 pub(crate) struct ToolRepairReceipt {
17 pub(crate) repaired_call_ids: Vec<String>,
18 pub(crate) duplicate_result_ids: Vec<String>,
19 pub(crate) orphan_result_ids: Vec<String>,
20 }
21
22 impl ToolRepairReceipt {
23 #[must_use]
24 pub(crate) fn is_empty(&self) -> bool {
25 self.repaired_call_ids.is_empty()
26 && self.duplicate_result_ids.is_empty()
27 && self.orphan_result_ids.is_empty()
28 }
29
30 fn visible_message(&self) -> String {
31 format!(
32 "[tool_history_repair] Repaired {} crashed tool call(s); quarantined {} duplicate and {} orphan terminal result(s).",
33 self.repaired_call_ids.len(),
34 self.duplicate_result_ids.len(),
35 self.orphan_result_ids.len(),
36 )
37 }
38 }
39
40 /// Repair tool-use/result integrity in place.
41 ///
42 /// The first terminal result after a known call and before the next assistant
43 /// turn is retained. Results that precede their call, arrive after a later
44 /// assistant turn, reference no call, or repeat a retained result are
45 /// quarantined by removing them from model-visible history. Every dangling
46 /// call receives a synthetic error result directly after its assistant call
47 /// message. A visible system receipt makes the repair apparent after resume.
48 pub(crate) fn repair_tool_call_pairs(messages: &mut Vec<Message>) -> ToolRepairReceipt {
49 repair_tool_call_pairs_inner(messages, true)
50 }
51
52 /// Repair an ephemeral provider request without appending a trailing receipt.
53 ///
54 /// Anthropic-style APIs interpret a final assistant message as a completion
55 /// prefill. Pair repair must therefore leave the synthetic user tool result as
56 /// the request tail; the durable session-facing path owns the visible receipt.
57 pub(crate) fn repair_tool_call_pairs_for_provider(
58 messages: &mut Vec<Message>,
59 ) -> ToolRepairReceipt {
60 repair_tool_call_pairs_inner(messages, false)
61 }
62
63 fn repair_tool_call_pairs_inner(
64 messages: &mut Vec<Message>,
65 append_visible_receipt: bool,
66 ) -> ToolRepairReceipt {
67 let mut call_message_by_id = HashMap::new();
68 let mut call_ids_in_order = Vec::new();
69 for (message_index, message) in messages.iter().enumerate() {
70 if message.role != "assistant" && message.role != crate::models::INTERRUPTED_ASSISTANT_ROLE
71 {
72 continue;
73 }
74 for block in &message.content {
75 if let ContentBlock::ToolUse { id, .. } = block
76 && !call_message_by_id.contains_key(id)
77 {
78 call_message_by_id.insert(id.clone(), message_index);
79 call_ids_in_order.push(id.clone());
80 }
81 }
82 }
83
84 let mut retained_results = HashSet::new();
85 let mut duplicate_result_ids = Vec::new();
86 let mut orphan_result_ids = Vec::new();
87 let mut keep_results = HashSet::new();
88 let mut result_ordinal = 0usize;
89 let mut latest_assistant_message = None;
90
91 for (message_index, message) in messages.iter().enumerate() {
92 if message.role == "assistant" || message.role == crate::models::INTERRUPTED_ASSISTANT_ROLE
93 {
94 latest_assistant_message = Some(message_index);
95 }
96 for block in &message.content {
97 let ContentBlock::ToolResult { tool_use_id, .. } = block else {
98 continue;
99 };
100 let ordinal = result_ordinal;
101 result_ordinal = result_ordinal.saturating_add(1);
102
103 let follows_known_call =
104 call_message_by_id
105 .get(tool_use_id)
106 .is_some_and(|call_index| {
107 *call_index < message_index && latest_assistant_message == Some(*call_index)
108 });
109 if !follows_known_call {
110 orphan_result_ids.push(tool_use_id.clone());
111 } else if !retained_results.insert(tool_use_id.clone()) {
112 duplicate_result_ids.push(tool_use_id.clone());
113 } else {
114 keep_results.insert(ordinal);
115 }
116 }
117 }
118
119 let repaired_call_ids: Vec<_> = call_ids_in_order
120 .into_iter()
121 .filter(|id| !retained_results.contains(id))
122 .collect();
123 let repaired_set: HashSet<_> = repaired_call_ids.iter().cloned().collect();
124
125 let receipt = ToolRepairReceipt {
126 repaired_call_ids,
127 duplicate_result_ids,
128 orphan_result_ids,
129 };
130 if receipt.is_empty() {
131 return receipt;
132 }
133
134 let original = std::mem::take(messages);
135 let mut rebuilt = Vec::with_capacity(
136 original
137 .len()
138 .saturating_add(receipt.repaired_call_ids.len()),
139 );
140 let mut seen_result_ordinal = 0usize;
141
142 for message in original {
143 let missing_after_message: Vec<_> = if message.role == "assistant"
144 || message.role == crate::models::INTERRUPTED_ASSISTANT_ROLE
145 {
146 message
147 .content
148 .iter()
149 .filter_map(|block| match block {
150 ContentBlock::ToolUse { id, .. } if repaired_set.contains(id) => {
151 Some(id.clone())
152 }
153 _ => None,
154 })
155 .collect()
156 } else {
157 Vec::new()
158 };
159
160 let mut filtered = message;
161 filtered.content.retain(|block| {
162 if matches!(block, ContentBlock::ToolResult { .. }) {
163 let keep = keep_results.contains(&seen_result_ordinal);
164 seen_result_ordinal = seen_result_ordinal.saturating_add(1);
165 keep
166 } else {
167 true
168 }
169 });
170 if !filtered.content.is_empty() {
171 rebuilt.push(filtered);
172 }
173
174 if !missing_after_message.is_empty() {
175 rebuilt.push(Message {
176 role: "user".to_string(),
177 content: missing_after_message
178 .into_iter()
179 .map(|tool_use_id| ContentBlock::ToolResult {
180 tool_use_id,
181 content: CRASH_REPAIR_CONTENT.to_string(),
182 is_error: Some(true),
183 content_blocks: None,
184 })
185 .collect(),
186 });
187 }
188 }
189
190 if append_visible_receipt {
191 rebuilt.push(Message {
192 role: "assistant".to_string(),
193 content: vec![ContentBlock::Text {
194 text: receipt.visible_message(),
195 cache_control: None,
196 }],
197 });
198 }
199 *messages = rebuilt;
200 receipt
201 }
202
203 #[cfg(test)]
204 mod tests {
205 use serde_json::json;
206
207 use super::*;
208
209 fn tool_call(id: &str) -> Message {
210 Message {
211 role: "assistant".to_string(),
212 content: vec![ContentBlock::ToolUse {
213 id: id.to_string(),
214 name: "read_file".to_string(),
215 input: json!({"path": "README.md"}),
216 caller: None,
217 }],
218 }
219 }
220
221 fn tool_result(id: &str, content: &str) -> Message {
222 Message {
223 role: "user".to_string(),
224 content: vec![ContentBlock::ToolResult {
225 tool_use_id: id.to_string(),
226 content: content.to_string(),
227 is_error: None,
228 content_blocks: None,
229 }],
230 }
231 }
232
233 fn text(role: &str, content: &str) -> Message {
234 Message {
235 role: role.to_string(),
236 content: vec![ContentBlock::Text {
237 text: content.to_string(),
238 cache_control: None,
239 }],
240 }
241 }
242
243 #[test]
244 fn well_formed_history_is_unchanged() {
245 let mut messages = vec![tool_call("call-1"), tool_result("call-1", "ok")];
246 let before = messages.clone();
247
248 let receipt = repair_tool_call_pairs(&mut messages);
249
250 assert!(receipt.is_empty());
251 assert_eq!(messages, before);
252 }
253
254 #[test]
255 fn repairs_dangling_calls_beside_their_assistant_message() {
256 let mut messages = vec![
257 tool_call("call-1"),
258 text("assistant", "later assistant text"),
259 ];
260
261 let receipt = repair_tool_call_pairs(&mut messages);
262
263 assert_eq!(receipt.repaired_call_ids, vec!["call-1"]);
264 assert_eq!(messages[1].role, "user");
265 assert!(matches!(
266 &messages[1].content[0],
267 ContentBlock::ToolResult {
268 tool_use_id,
269 content,
270 is_error: Some(true),
271 ..
272 } if tool_use_id == "call-1" && content.contains("crashed_and_repaired")
273 ));
274 assert_eq!(messages.last().expect("receipt").role, "assistant");
275 }
276
277 #[test]
278 fn provider_repair_never_appends_an_assistant_prefill_receipt() {
279 let mut messages = vec![tool_call("call-1")];
280
281 let receipt = repair_tool_call_pairs_for_provider(&mut messages);
282
283 assert_eq!(receipt.repaired_call_ids, vec!["call-1"]);
284 assert_eq!(messages.last().expect("synthetic result").role, "user");
285 assert!(!messages.iter().any(|message| {
286 message.content.iter().any(|block| {
287 matches!(
288 block,
289 ContentBlock::Text { text, .. }
290 if text.contains("[tool_history_repair]")
291 )
292 })
293 }));
294 }
295
296 #[test]
297 fn quarantines_orphan_and_duplicate_results_without_losing_other_blocks() {
298 let mut mixed_result = tool_result("call-1", "duplicate");
299 mixed_result.content.push(ContentBlock::Text {
300 text: "keep me".to_string(),
301 cache_control: None,
302 });
303 let mut messages = vec![
304 tool_result("orphan", "bad"),
305 tool_call("call-1"),
306 tool_result("call-1", "first"),
307 mixed_result,
308 ];
309
310 let receipt = repair_tool_call_pairs(&mut messages);
311
312 assert_eq!(receipt.orphan_result_ids, vec!["orphan"]);
313 assert_eq!(receipt.duplicate_result_ids, vec!["call-1"]);
314 let result_contents: Vec<_> = messages
315 .iter()
316 .flat_map(|message| &message.content)
317 .filter_map(|block| match block {
318 ContentBlock::ToolResult { content, .. } => Some(content.as_str()),
319 _ => None,
320 })
321 .collect();
322 assert_eq!(result_contents, vec!["first"]);
323 assert!(messages.iter().any(|message| {
324 message
325 .content
326 .iter()
327 .any(|block| matches!(block, ContentBlock::Text { text, .. } if text == "keep me"))
328 }));
329 }
330
331 #[test]
332 fn repair_is_idempotent() {
333 let mut messages = vec![tool_call("call-1"), tool_result("orphan", "bad")];
334
335 let first = repair_tool_call_pairs(&mut messages);
336 let after_first = messages.clone();
337 let second = repair_tool_call_pairs(&mut messages);
338
339 assert!(!first.is_empty());
340 assert!(second.is_empty());
341 assert_eq!(messages, after_first);
342 }
343
344 #[test]
345 fn result_preceding_its_call_is_orphaned_and_call_is_repaired() {
346 let mut messages = vec![tool_result("call-1", "too early"), tool_call("call-1")];
347
348 let receipt = repair_tool_call_pairs(&mut messages);
349
350 assert_eq!(receipt.orphan_result_ids, vec!["call-1"]);
351 assert_eq!(receipt.repaired_call_ids, vec!["call-1"]);
352 assert!(messages.iter().any(|message| {
353 message.content.iter().any(|block| {
354 matches!(
355 block,
356 ContentBlock::ToolResult { content, .. }
357 if content.contains("crashed_and_repaired")
358 )
359 })
360 }));
361 }
362
363 #[test]
364 fn result_after_a_later_assistant_turn_is_quarantined_as_too_late() {
365 let mut messages = vec![
366 tool_call("call-1"),
367 text("assistant", "a later model turn"),
368 tool_result("call-1", "too late"),
369 ];
370
371 let receipt = repair_tool_call_pairs(&mut messages);
372
373 assert_eq!(receipt.orphan_result_ids, vec!["call-1"]);
374 assert_eq!(receipt.repaired_call_ids, vec!["call-1"]);
375 assert!(!messages.iter().any(|message| {
376 message.content.iter().any(|block| {
377 matches!(block, ContentBlock::ToolResult { content, .. } if content == "too late")
378 })
379 }));
380 }
381 }
382
382 lines RUST