fix(compaction): add emergency fallback when compact exceeds budget (fixes #436)

This commit is contained in:
James Peter
2025-11-27 08:11:10 +10:00
parent a58904aa68
commit 4ae7d110a6
+119 -3
View File
@@ -268,13 +268,33 @@ pub(super) async fn perform_compaction(
continue;
}
// Compaction cannot fit even with minimal input. Apply emergency
// fallback to prevent infinite retry loops: reset history to just
// initial context with a warning message.
tracing::error!(
"Compaction failed: context overflow even with minimal input. \
Applying emergency fallback history to prevent retry loop."
);
let emergency_message = "⚠️ Compaction failed: The conversation history is too large \
to compact within the model's context limits. The history has been reset to prevent \
further errors. Please start a new session or manually reduce context by clearing history.";
let event = sess.make_event(
&sub_id,
EventMsg::Error(ErrorEvent {
message: e.to_string(),
message: emergency_message.to_string(),
}),
);
sess.send_event(event).await;
// Apply emergency fallback: reset history to minimal state
let initial_context = sess.build_initial_context(turn_context.as_ref());
let emergency_history = build_emergency_compacted_history(initial_context, emergency_message);
sess.replace_history(emergency_history);
// Still return error to signal compaction didn't complete normally,
// but history has been reset to prevent retry loops
return Err(e);
}
Err(e) => {
@@ -397,14 +417,40 @@ async fn run_compact_task_inner_inline(
continue;
}
// Compaction cannot fit even with minimal input. Return an emergency
// fallback history with just the initial context and a clear warning.
// This prevents infinite loops where compaction repeatedly fails and
// the system keeps retrying with the same oversized input.
tracing::error!(
"Compaction failed: context overflow even with minimal input. \
Returning emergency fallback history to prevent retry loop."
);
let emergency_message = "⚠️ Compaction failed: The conversation history is too large \
to compact within the model's context limits. The history has been reset to prevent \
further errors. Please start a new session or manually reduce context by clearing history.";
let event = sess.make_event(
&sub_id,
EventMsg::Error(ErrorEvent {
message: e.to_string(),
message: emergency_message.to_string(),
}),
);
sess.send_event(event).await;
return Vec::new();
// Return minimal emergency history: just initial context + warning message
let initial_context = sess.build_initial_context(turn_context.as_ref());
let emergency_history = build_emergency_compacted_history(initial_context, emergency_message);
// Update session history with emergency fallback
{
let mut state = sess.state.lock().unwrap();
state.history = crate::conversation_history::ConversationHistory::new();
state.history.record_items(emergency_history.iter());
state.token_usage_info = None;
}
return emergency_history;
}
Err(e) => {
if retries < max_retries {
@@ -654,6 +700,24 @@ pub(crate) fn build_compacted_history(
history
}
/// Build an emergency fallback history when compaction fails catastrophically.
/// This returns just the initial context plus a warning message, ensuring the
/// session can continue without hitting infinite retry loops.
pub(crate) fn build_emergency_compacted_history(
initial_context: Vec<ResponseItem>,
warning_message: &str,
) -> Vec<ResponseItem> {
let mut history = initial_context;
history.push(ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: warning_message.to_string(),
}],
});
history
}
async fn drain_to_completed(
sess: &Session,
turn_context: &TurnContext,
@@ -928,6 +992,58 @@ mod tests {
assert!(bridge_text.len() < big.len());
}
#[test]
fn build_emergency_compacted_history_creates_minimal_history() {
let initial_context = vec![
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "<user_instructions>test</user_instructions>".to_string(),
}],
},
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "<ENVIRONMENT_CONTEXT>cwd=/tmp</ENVIRONMENT_CONTEXT>".to_string(),
}],
},
];
let warning = "Emergency fallback";
let history = build_emergency_compacted_history(initial_context.clone(), warning);
assert_eq!(history.len(), initial_context.len() + 1);
if let ResponseItem::Message { role, content, .. } = &history[history.len() - 1] {
assert_eq!(role, "user");
let text = content_items_to_text(content).unwrap();
assert_eq!(text, warning);
} else {
panic!("Expected warning message");
}
assert_eq!(history[0], initial_context[0]);
assert_eq!(history[1], initial_context[1]);
}
#[test]
fn build_emergency_compacted_history_with_empty_context() {
let warning = "Emergency fallback";
let history = build_emergency_compacted_history(Vec::new(), warning);
assert_eq!(history.len(), 1);
if let ResponseItem::Message { role, content, .. } = &history[0] {
assert_eq!(role, "user");
let text = content_items_to_text(content).unwrap();
assert_eq!(text, warning);
} else {
panic!("Expected warning message");
}
}
#[test]
fn compaction_checkpoint_warning_event_has_copy() {
match compaction_checkpoint_warning_event() {