use std::collections::{HashMap, HashSet};
use crate::models::{ContentBlock, Message};
const CRASH_REPAIR_CONTENT: &str =
"Tool call interrupted by process exit; terminal status: crashed_and_repaired.";
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct ToolRepairReceipt {
pub(crate) repaired_call_ids: Vec<String>,
pub(crate) duplicate_result_ids: Vec<String>,
pub(crate) orphan_result_ids: Vec<String>,
}
impl ToolRepairReceipt {
#[must_use]
pub(crate) fn is_empty(&self) -> bool {
self.repaired_call_ids.is_empty()
&& self.duplicate_result_ids.is_empty()
&& self.orphan_result_ids.is_empty()
}
fn visible_message(&self) -> String {
format!(
"[tool_history_repair] Repaired {} crashed tool call(s); quarantined {} duplicate and {} orphan terminal result(s).",
self.repaired_call_ids.len(),
self.duplicate_result_ids.len(),
self.orphan_result_ids.len(),
)
}
}
pub(crate) fn repair_tool_call_pairs(messages: &mut Vec<Message>) -> ToolRepairReceipt {
repair_tool_call_pairs_inner(messages, true)
}
pub(crate) fn repair_tool_call_pairs_for_provider(
messages: &mut Vec<Message>,
) -> ToolRepairReceipt {
repair_tool_call_pairs_inner(messages, false)
}
fn repair_tool_call_pairs_inner(
messages: &mut Vec<Message>,
append_visible_receipt: bool,
) -> ToolRepairReceipt {
let mut call_message_by_id = HashMap::new();
let mut call_ids_in_order = Vec::new();
for (message_index, message) in messages.iter().enumerate() {
if message.role != "assistant" {
continue;
}
for block in &message.content {
if let ContentBlock::ToolUse { id, .. } = block
&& !call_message_by_id.contains_key(id)
{
call_message_by_id.insert(id.clone(), message_index);
call_ids_in_order.push(id.clone());
}
}
}
let mut retained_results = HashSet::new();
let mut duplicate_result_ids = Vec::new();
let mut orphan_result_ids = Vec::new();
let mut keep_results = HashSet::new();
let mut result_ordinal = 0usize;
let mut latest_assistant_message = None;
for (message_index, message) in messages.iter().enumerate() {
if message.role == "assistant" {
latest_assistant_message = Some(message_index);
}
for block in &message.content {
let ContentBlock::ToolResult { tool_use_id, .. } = block else {
continue;
};
let ordinal = result_ordinal;
result_ordinal = result_ordinal.saturating_add(1);
let follows_known_call =
call_message_by_id
.get(tool_use_id)
.is_some_and(|call_index| {
*call_index < message_index && latest_assistant_message == Some(*call_index)
});
if !follows_known_call {
orphan_result_ids.push(tool_use_id.clone());
} else if !retained_results.insert(tool_use_id.clone()) {
duplicate_result_ids.push(tool_use_id.clone());
} else {
keep_results.insert(ordinal);
}
}
}
let repaired_call_ids: Vec<_> = call_ids_in_order
.into_iter()
.filter(|id| !retained_results.contains(id))
.collect();
let repaired_set: HashSet<_> = repaired_call_ids.iter().cloned().collect();
let receipt = ToolRepairReceipt {
repaired_call_ids,
duplicate_result_ids,
orphan_result_ids,
};
if receipt.is_empty() {
return receipt;
}
let original = std::mem::take(messages);
let mut rebuilt = Vec::with_capacity(
original
.len()
.saturating_add(receipt.repaired_call_ids.len()),
);
let mut seen_result_ordinal = 0usize;
for message in original {
let missing_after_message: Vec<_> = if message.role == "assistant" {
message
.content
.iter()
.filter_map(|block| match block {
ContentBlock::ToolUse { id, .. } if repaired_set.contains(id) => {
Some(id.clone())
}
_ => None,
})
.collect()
} else {
Vec::new()
};
let mut filtered = message;
filtered.content.retain(|block| {
if matches!(block, ContentBlock::ToolResult { .. }) {
let keep = keep_results.contains(&seen_result_ordinal);
seen_result_ordinal = seen_result_ordinal.saturating_add(1);
keep
} else {
true
}
});
if !filtered.content.is_empty() {
rebuilt.push(filtered);
}
if !missing_after_message.is_empty() {
rebuilt.push(Message {
role: "user".to_string(),
content: missing_after_message
.into_iter()
.map(|tool_use_id| ContentBlock::ToolResult {
tool_use_id,
content: CRASH_REPAIR_CONTENT.to_string(),
is_error: Some(true),
content_blocks: None,
})
.collect(),
});
}
}
if append_visible_receipt {
rebuilt.push(Message {
role: "assistant".to_string(),
content: vec![ContentBlock::Text {
text: receipt.visible_message(),
cache_control: None,
}],
});
}
*messages = rebuilt;
receipt
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
fn tool_call(id: &str) -> Message {
Message {
role: "assistant".to_string(),
content: vec![ContentBlock::ToolUse {
id: id.to_string(),
name: "read_file".to_string(),
input: json!({"path": "README.md"}),
caller: None,
}],
}
}
fn tool_result(id: &str, content: &str) -> Message {
Message {
role: "user".to_string(),
content: vec![ContentBlock::ToolResult {
tool_use_id: id.to_string(),
content: content.to_string(),
is_error: None,
content_blocks: None,
}],
}
}
fn text(role: &str, content: &str) -> Message {
Message {
role: role.to_string(),
content: vec![ContentBlock::Text {
text: content.to_string(),
cache_control: None,
}],
}
}
#[test]
fn well_formed_history_is_unchanged() {
let mut messages = vec![tool_call("call-1"), tool_result("call-1", "ok")];
let before = messages.clone();
let receipt = repair_tool_call_pairs(&mut messages);
assert!(receipt.is_empty());
assert_eq!(messages, before);
}
#[test]
fn repairs_dangling_calls_beside_their_assistant_message() {
let mut messages = vec![
tool_call("call-1"),
text("assistant", "later assistant text"),
];
let receipt = repair_tool_call_pairs(&mut messages);
assert_eq!(receipt.repaired_call_ids, vec!["call-1"]);
assert_eq!(messages[1].role, "user");
assert!(matches!(
&messages[1].content[0],
ContentBlock::ToolResult {
tool_use_id,
content,
is_error: Some(true),
..
} if tool_use_id == "call-1" && content.contains("crashed_and_repaired")
));
assert_eq!(messages.last().expect("receipt").role, "assistant");
}
#[test]
fn provider_repair_never_appends_an_assistant_prefill_receipt() {
let mut messages = vec![tool_call("call-1")];
let receipt = repair_tool_call_pairs_for_provider(&mut messages);
assert_eq!(receipt.repaired_call_ids, vec!["call-1"]);
assert_eq!(messages.last().expect("synthetic result").role, "user");
assert!(!messages.iter().any(|message| {
message.content.iter().any(|block| {
matches!(
block,
ContentBlock::Text { text, .. }
if text.contains("[tool_history_repair]")
)
})
}));
}
#[test]
fn quarantines_orphan_and_duplicate_results_without_losing_other_blocks() {
let mut mixed_result = tool_result("call-1", "duplicate");
mixed_result.content.push(ContentBlock::Text {
text: "keep me".to_string(),
cache_control: None,
});
let mut messages = vec![
tool_result("orphan", "bad"),
tool_call("call-1"),
tool_result("call-1", "first"),
mixed_result,
];
let receipt = repair_tool_call_pairs(&mut messages);
assert_eq!(receipt.orphan_result_ids, vec!["orphan"]);
assert_eq!(receipt.duplicate_result_ids, vec!["call-1"]);
let result_contents: Vec<_> = messages
.iter()
.flat_map(|message| &message.content)
.filter_map(|block| match block {
ContentBlock::ToolResult { content, .. } => Some(content.as_str()),
_ => None,
})
.collect();
assert_eq!(result_contents, vec!["first"]);
assert!(messages.iter().any(|message| {
message
.content
.iter()
.any(|block| matches!(block, ContentBlock::Text { text, .. } if text == "keep me"))
}));
}
#[test]
fn repair_is_idempotent() {
let mut messages = vec![tool_call("call-1"), tool_result("orphan", "bad")];
let first = repair_tool_call_pairs(&mut messages);
let after_first = messages.clone();
let second = repair_tool_call_pairs(&mut messages);
assert!(!first.is_empty());
assert!(second.is_empty());
assert_eq!(messages, after_first);
}
#[test]
fn result_preceding_its_call_is_orphaned_and_call_is_repaired() {
let mut messages = vec![tool_result("call-1", "too early"), tool_call("call-1")];
let receipt = repair_tool_call_pairs(&mut messages);
assert_eq!(receipt.orphan_result_ids, vec!["call-1"]);
assert_eq!(receipt.repaired_call_ids, vec!["call-1"]);
assert!(messages.iter().any(|message| {
message.content.iter().any(|block| {
matches!(
block,
ContentBlock::ToolResult { content, .. }
if content.contains("crashed_and_repaired")
)
})
}));
}
#[test]
fn result_after_a_later_assistant_turn_is_quarantined_as_too_late() {
let mut messages = vec![
tool_call("call-1"),
text("assistant", "a later model turn"),
tool_result("call-1", "too late"),
];
let receipt = repair_tool_call_pairs(&mut messages);
assert_eq!(receipt.orphan_result_ids, vec!["call-1"]);
assert_eq!(receipt.repaired_call_ids, vec!["call-1"]);
assert!(!messages.iter().any(|message| {
message.content.iter().any(|block| {
matches!(block, ContentBlock::ToolResult { content, .. } if content == "too late")
})
}));
}
}