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