use crate::driver_registry::{LlmMessage, LlmMessageContent, LlmMessageRole};
use crate::message::{ContentPart, Message};
use std::collections::HashSet;
pub fn retain_complete_message_tool_exchanges(
messages: &[Message],
allow_unmatched_results: bool,
) -> Vec<Message> {
let result_ids: HashSet<String> = messages
.iter()
.flat_map(|message| message.content.iter())
.filter_map(|part| match part {
ContentPart::ToolResult(result) => Some(result.tool_call_id.clone()),
_ => None,
})
.collect();
let calls_filtered: Vec<Message> = messages
.iter()
.filter_map(|message| {
let mut message = message.clone();
let had_calls = message
.content
.iter()
.any(|part| matches!(part, ContentPart::ToolCall(_)));
message.content.retain(|part| match part {
ContentPart::ToolCall(call) => result_ids.contains(call.id.as_str()),
_ => true,
});
(!had_calls || message_has_visible_content(&message)).then_some(message)
})
.collect();
let call_ids: HashSet<String> = calls_filtered
.iter()
.flat_map(|message| message.content.iter())
.filter_map(|part| match part {
ContentPart::ToolCall(call) => Some(call.id.clone()),
_ => None,
})
.collect();
calls_filtered
.iter()
.filter_map(|message| {
let mut message = message.clone();
let had_results = message
.content
.iter()
.any(|part| matches!(part, ContentPart::ToolResult(_)));
message.content.retain(|part| match part {
ContentPart::ToolResult(result) => {
call_ids.contains(result.tool_call_id.as_str()) || allow_unmatched_results
}
_ => true,
});
(!had_results || message_has_visible_content(&message)).then_some(message)
})
.collect()
}
pub fn retain_complete_llm_tool_exchanges(messages: Vec<LlmMessage>) -> Vec<LlmMessage> {
retain_complete_llm_tool_exchanges_for_request(messages, false)
}
pub fn retain_complete_llm_tool_exchanges_for_request(
messages: Vec<LlmMessage>,
allow_unmatched_results: bool,
) -> Vec<LlmMessage> {
let result_ids: HashSet<String> = messages
.iter()
.filter(|message| message.role == LlmMessageRole::Tool)
.filter_map(|message| message.tool_call_id.clone())
.collect();
let calls_filtered: Vec<LlmMessage> = messages
.into_iter()
.filter_map(|mut message| {
let had_calls = message.tool_calls.is_some();
if let Some(calls) = &mut message.tool_calls {
calls.retain(|call| result_ids.contains(call.id.as_str()));
if calls.is_empty() {
message.tool_calls = None;
}
}
(!had_calls || llm_message_has_visible_content(&message)).then_some(message)
})
.collect();
let call_ids: HashSet<String> = calls_filtered
.iter()
.flat_map(|message| message.tool_calls.iter().flatten())
.map(|call| call.id.clone())
.collect();
calls_filtered
.into_iter()
.filter(|message| {
message.role != LlmMessageRole::Tool
|| message
.tool_call_id
.as_deref()
.is_some_and(|id| call_ids.contains(id) || allow_unmatched_results)
})
.collect()
}
fn message_has_visible_content(message: &Message) -> bool {
message.content.iter().any(|part| match part {
ContentPart::Text(text) => !text.text.is_empty(),
ContentPart::Image(_) | ContentPart::ImageFile(_) => true,
ContentPart::ToolCall(_) | ContentPart::ToolResult(_) => true,
ContentPart::Reasoning(_) => false,
})
}
fn llm_message_has_visible_content(message: &LlmMessage) -> bool {
let has_content = match &message.content {
LlmMessageContent::Text(text) => !text.is_empty(),
LlmMessageContent::Parts(parts) => !parts.is_empty(),
};
has_content || message.tool_calls.is_some() || !message.reasoning.is_empty()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::driver_registry::{LlmMessageContent, LlmMessageRole};
use crate::tool_types::ToolCall;
use serde_json::json;
fn assistant_batch() -> LlmMessage {
LlmMessage {
role: LlmMessageRole::Assistant,
content: LlmMessageContent::Text(String::new()),
tool_calls: Some(vec![
ToolCall {
id: "call_skill".to_string(),
name: "activate_skill".to_string(),
arguments: json!({}),
},
ToolCall {
id: "call_bash".to_string(),
name: "bash".to_string(),
arguments: json!({}),
},
]),
tool_call_id: None,
phase: None,
reasoning: Vec::new(),
configuration_update: None,
}
}
fn tool_result(id: &str) -> LlmMessage {
LlmMessage {
role: LlmMessageRole::Tool,
content: LlmMessageContent::Text("result".to_string()),
tool_calls: None,
tool_call_id: Some(id.to_string()),
phase: None,
reasoning: Vec::new(),
configuration_update: None,
}
}
fn assert_text_messages(actual: &[LlmMessage], expected: &[LlmMessage]) {
assert_eq!(actual.len(), expected.len());
for (actual, expected) in actual.iter().zip(expected) {
assert_eq!(actual.role, expected.role);
match (&actual.content, &expected.content) {
(LlmMessageContent::Text(actual), LlmMessageContent::Text(expected)) => {
assert_eq!(actual, expected);
}
_ => panic!("expected text messages"),
}
assert_eq!(
serde_json::to_value(&actual.tool_calls).unwrap(),
serde_json::to_value(&expected.tool_calls).unwrap()
);
assert_eq!(actual.tool_call_id, expected.tool_call_id);
assert_eq!(actual.phase, expected.phase);
assert_eq!(actual.reasoning, expected.reasoning);
assert_eq!(actual.configuration_update, expected.configuration_update);
}
}
#[test]
fn llm_reduction_prunes_only_the_unmatched_parallel_call() {
let mut batch = assistant_batch();
batch.content = LlmMessageContent::Text("Keep this text".into());
batch.configuration_update = Some(everruns_provider::model::ReasoningEffort::High);
batch.phase = Some(everruns_provider::execution_phase::ExecutionPhase::Commentary);
batch
.reasoning
.push(everruns_provider::reasoning::ReasoningContentPart::opaque(
"test-provider",
));
batch.tool_calls.as_mut().unwrap()[0].arguments = json!({"name":"ops"});
let result = tool_result("call_skill");
let reduced = retain_complete_llm_tool_exchanges(vec![
batch.clone(),
result.clone(),
tool_result("orphan"),
]);
batch.tool_calls.as_mut().unwrap().truncate(1);
assert_text_messages(&reduced, &[batch, result]);
}
#[test]
fn stateless_and_stateful_llm_reduction_handle_orphans_without_inventing_calls() {
let result = tool_result("call_bash");
assert!(retain_complete_llm_tool_exchanges(vec![result.clone()]).is_empty());
assert_text_messages(
&retain_complete_llm_tool_exchanges_for_request(vec![result.clone()], true),
&[result],
);
for allow in [false, true] {
let mut invalid = tool_result("ignored");
invalid.tool_call_id = None;
assert!(
retain_complete_llm_tool_exchanges_for_request(vec![invalid], allow).is_empty()
);
assert!(
retain_complete_llm_tool_exchanges_for_request(vec![assistant_batch()], allow)
.is_empty()
);
}
}
#[test]
fn removing_all_llm_calls_preserves_independent_visible_text() {
let mut batch = assistant_batch();
batch.content = LlmMessageContent::Text("Keep α".into());
batch.configuration_update = Some(everruns_provider::model::ReasoningEffort::Low);
let mut expected = batch.clone();
expected.tool_calls = None;
assert_text_messages(
&retain_complete_llm_tool_exchanges(vec![batch]),
&[expected],
);
}
#[test]
fn message_reduction_allows_stateful_result_deltas_only_when_requested() {
let result = Message::tool_result(
"call_bash",
Some(json!({"output":"done","exit_code":0})),
None,
);
let before = serde_json::to_value(&result).unwrap();
assert!(
retain_complete_message_tool_exchanges(std::slice::from_ref(&result), false).is_empty()
);
assert_eq!(
serde_json::to_value(retain_complete_message_tool_exchanges(
std::slice::from_ref(&result),
true
))
.unwrap(),
json!([before])
);
assert_eq!(serde_json::to_value(&result).unwrap(), before);
}
#[test]
fn message_reduction_preserves_matched_parts_and_does_not_mutate_source_history() {
let calls = assistant_batch().tool_calls.unwrap();
let batch = Message::assistant_with_tools("Visible α", calls);
let matched = Message::tool_result(
"call_skill",
Some(json!({"skill":"ops","instructions":"Body"})),
None,
);
let orphan = Message::tool_result("orphan", Some(json!("orphan result")), None);
let source = vec![
Message::user("Question"),
batch.clone(),
matched.clone(),
orphan.clone(),
];
let before = serde_json::to_value(&source).unwrap();
let mut expected_batch = batch;
expected_batch
.content
.retain(|part| !matches!(part,ContentPart::ToolCall(call) if call.id=="call_bash"));
for allow in [false, true] {
let mut expected = vec![source[0].clone(), expected_batch.clone(), matched.clone()];
if allow {
expected.push(orphan.clone());
}
assert_eq!(
serde_json::to_value(retain_complete_message_tool_exchanges(&source, allow))
.unwrap(),
serde_json::to_value(expected).unwrap()
);
}
assert_eq!(serde_json::to_value(&source).unwrap(), before);
let orphan_call = Message::assistant_with_tools(
"",
vec![ToolCall {
id: "missing".into(),
name: "run".into(),
arguments: json!({}),
}],
);
assert!(retain_complete_message_tool_exchanges(&[orphan_call], true).is_empty());
}
}