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