mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
fix: auto-compact and retry on ContextLengthExceeded (#315)
* fix: auto-compact and retry on ContextLengthExceeded in agentic loop When the LLM returns a context-length-exceeded error mid-turn, the dispatcher now automatically compacts the conversation history and retries once instead of propagating the raw error to the user. The compaction keeps all system messages (system prompt, skill context), the last user message, and all subsequent messages (current turn's tool calls and results), dropping older conversation history. A note is inserted to inform the LLM that earlier context was dropped. If the retry also fails, the original error is returned. Fixes nearai/ironclaw#260 Co-Authored-By: Claude Opus 4.6 <[email protected]> * Address Gemini/Copilot review feedback - Fix system message duplication: only collect system messages before the last User message to avoid duplicating nudges in the tail slice (Gemini + Copilot) - Only add compaction note when earlier history is actually dropped (Copilot) - Propagate actual retry error instead of masking with original (Copilot) - Fix else branch to preserve system messages when no User messages exist - Add test for nudge-after-user deduplication Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
7bc3d5507a
commit
6f21cfa680
+266
-1
@@ -212,7 +212,46 @@ impl Agent {
|
||||
);
|
||||
}
|
||||
|
||||
let output = reasoning.respond_with_tools(&context).await?;
|
||||
let output = match reasoning.respond_with_tools(&context).await {
|
||||
Ok(output) => output,
|
||||
Err(crate::error::LlmError::ContextLengthExceeded { used, limit }) => {
|
||||
tracing::warn!(
|
||||
used,
|
||||
limit,
|
||||
iteration,
|
||||
"Context length exceeded, compacting messages and retrying"
|
||||
);
|
||||
|
||||
// Compact: keep system messages + last user message + current turn
|
||||
context_messages = compact_messages_for_retry(&context_messages);
|
||||
|
||||
// Rebuild context with compacted messages
|
||||
let mut retry_context = ReasoningContext::new()
|
||||
.with_messages(context_messages.clone())
|
||||
.with_tools(if force_text {
|
||||
Vec::new()
|
||||
} else {
|
||||
context.available_tools.clone()
|
||||
})
|
||||
.with_metadata(context.metadata.clone());
|
||||
retry_context.force_text = force_text;
|
||||
|
||||
reasoning
|
||||
.respond_with_tools(&retry_context)
|
||||
.await
|
||||
.map_err(|retry_err| {
|
||||
tracing::error!(
|
||||
original_used = used,
|
||||
original_limit = limit,
|
||||
retry_error = %retry_err,
|
||||
"Retry after auto-compaction also failed"
|
||||
);
|
||||
// Propagate the actual retry error so callers see the real failure
|
||||
crate::error::Error::from(retry_err)
|
||||
})?
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
|
||||
// Record cost and track token usage
|
||||
let model_name = self.llm().active_model_name();
|
||||
@@ -791,6 +830,58 @@ pub(super) fn check_auth_required(
|
||||
Some((name, instructions))
|
||||
}
|
||||
|
||||
/// Compact messages for retry after a context-length-exceeded error.
|
||||
///
|
||||
/// Keeps all `System` messages (which carry the system prompt and instructions),
|
||||
/// finds the last `User` message, and retains it plus every subsequent message
|
||||
/// (the current turn's assistant tool calls and tool results). A short note is
|
||||
/// inserted so the LLM knows earlier history was dropped.
|
||||
fn compact_messages_for_retry(messages: &[ChatMessage]) -> Vec<ChatMessage> {
|
||||
use crate::llm::Role;
|
||||
|
||||
let mut compacted = Vec::new();
|
||||
|
||||
// Find the last User message index
|
||||
let last_user_idx = messages.iter().rposition(|m| m.role == Role::User);
|
||||
|
||||
if let Some(idx) = last_user_idx {
|
||||
// Keep System messages that appear BEFORE the last User message.
|
||||
// System messages after that point (e.g. nudges) are included in the
|
||||
// slice extension below, avoiding duplication.
|
||||
for msg in &messages[..idx] {
|
||||
if msg.role == Role::System {
|
||||
compacted.push(msg.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Only add a compaction note if there was earlier history that is being dropped
|
||||
if idx > 0 {
|
||||
compacted.push(ChatMessage::system(
|
||||
"[Note: Earlier conversation history was automatically compacted \
|
||||
to fit within the context window. The most recent exchange is preserved below.]",
|
||||
));
|
||||
}
|
||||
|
||||
// Keep the last User message and everything after it
|
||||
compacted.extend_from_slice(&messages[idx..]);
|
||||
} else {
|
||||
// No user messages found (shouldn't happen normally); keep everything,
|
||||
// with system messages first to preserve prompt ordering.
|
||||
for msg in messages {
|
||||
if msg.role == Role::System {
|
||||
compacted.push(msg.clone());
|
||||
}
|
||||
}
|
||||
for msg in messages {
|
||||
if msg.role != Role::System {
|
||||
compacted.push(msg.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
compacted
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
@@ -1150,4 +1241,178 @@ mod tests {
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// ---- compact_messages_for_retry tests ----
|
||||
|
||||
use super::compact_messages_for_retry;
|
||||
use crate::llm::{ChatMessage, Role};
|
||||
|
||||
#[test]
|
||||
fn test_compact_keeps_system_and_last_user_exchange() {
|
||||
let messages = vec![
|
||||
ChatMessage::system("You are a helpful assistant."),
|
||||
ChatMessage::user("First question"),
|
||||
ChatMessage::assistant("First answer"),
|
||||
ChatMessage::user("Second question"),
|
||||
ChatMessage::assistant("Second answer"),
|
||||
ChatMessage::user("Third question"),
|
||||
ChatMessage::assistant_with_tool_calls(
|
||||
None,
|
||||
vec![ToolCall {
|
||||
id: "call_1".to_string(),
|
||||
name: "echo".to_string(),
|
||||
arguments: serde_json::json!({"message": "hi"}),
|
||||
}],
|
||||
),
|
||||
ChatMessage::tool_result("call_1", "echo", "hi"),
|
||||
];
|
||||
|
||||
let compacted = compact_messages_for_retry(&messages);
|
||||
|
||||
// Should have: system prompt + compaction note + last user msg + tool call + tool result
|
||||
assert_eq!(compacted.len(), 5);
|
||||
assert_eq!(compacted[0].role, Role::System);
|
||||
assert_eq!(compacted[0].content, "You are a helpful assistant.");
|
||||
assert_eq!(compacted[1].role, Role::System); // compaction note
|
||||
assert!(compacted[1].content.contains("compacted"));
|
||||
assert_eq!(compacted[2].role, Role::User);
|
||||
assert_eq!(compacted[2].content, "Third question");
|
||||
assert_eq!(compacted[3].role, Role::Assistant); // tool call
|
||||
assert_eq!(compacted[4].role, Role::Tool); // tool result
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compact_preserves_multiple_system_messages() {
|
||||
let messages = vec![
|
||||
ChatMessage::system("System prompt"),
|
||||
ChatMessage::system("Skill context"),
|
||||
ChatMessage::user("Old question"),
|
||||
ChatMessage::assistant("Old answer"),
|
||||
ChatMessage::system("Nudge message"),
|
||||
ChatMessage::user("Current question"),
|
||||
];
|
||||
|
||||
let compacted = compact_messages_for_retry(&messages);
|
||||
|
||||
// 3 system messages + compaction note + last user message
|
||||
assert_eq!(compacted.len(), 5);
|
||||
assert_eq!(compacted[0].content, "System prompt");
|
||||
assert_eq!(compacted[1].content, "Skill context");
|
||||
assert_eq!(compacted[2].content, "Nudge message");
|
||||
assert!(compacted[3].content.contains("compacted")); // note
|
||||
assert_eq!(compacted[4].content, "Current question");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compact_single_user_message_keeps_everything() {
|
||||
let messages = vec![
|
||||
ChatMessage::system("System prompt"),
|
||||
ChatMessage::user("Only question"),
|
||||
];
|
||||
|
||||
let compacted = compact_messages_for_retry(&messages);
|
||||
|
||||
// system + compaction note + user
|
||||
assert_eq!(compacted.len(), 3);
|
||||
assert_eq!(compacted[0].content, "System prompt");
|
||||
assert!(compacted[1].content.contains("compacted"));
|
||||
assert_eq!(compacted[2].content, "Only question");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compact_no_user_messages_keeps_non_system() {
|
||||
let messages = vec![
|
||||
ChatMessage::system("System prompt"),
|
||||
ChatMessage::assistant("Stray assistant message"),
|
||||
];
|
||||
|
||||
let compacted = compact_messages_for_retry(&messages);
|
||||
|
||||
// system + assistant (no user message found, keeps all non-system)
|
||||
assert_eq!(compacted.len(), 2);
|
||||
assert_eq!(compacted[0].role, Role::System);
|
||||
assert_eq!(compacted[1].role, Role::Assistant);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compact_drops_old_history_but_keeps_current_turn_tools() {
|
||||
// Simulate a multi-turn conversation where the current turn has
|
||||
// multiple tool calls and results.
|
||||
let messages = vec![
|
||||
ChatMessage::system("System prompt"),
|
||||
ChatMessage::user("Question 1"),
|
||||
ChatMessage::assistant("Answer 1"),
|
||||
ChatMessage::user("Question 2"),
|
||||
ChatMessage::assistant("Answer 2"),
|
||||
ChatMessage::user("Question 3"),
|
||||
ChatMessage::assistant("Answer 3"),
|
||||
ChatMessage::user("Current question"),
|
||||
ChatMessage::assistant_with_tool_calls(
|
||||
None,
|
||||
vec![
|
||||
ToolCall {
|
||||
id: "c1".to_string(),
|
||||
name: "http".to_string(),
|
||||
arguments: serde_json::json!({}),
|
||||
},
|
||||
ToolCall {
|
||||
id: "c2".to_string(),
|
||||
name: "echo".to_string(),
|
||||
arguments: serde_json::json!({}),
|
||||
},
|
||||
],
|
||||
),
|
||||
ChatMessage::tool_result("c1", "http", "response data"),
|
||||
ChatMessage::tool_result("c2", "echo", "echoed"),
|
||||
];
|
||||
|
||||
let compacted = compact_messages_for_retry(&messages);
|
||||
|
||||
// system + note + user + assistant(tool_calls) + tool_result + tool_result
|
||||
assert_eq!(compacted.len(), 6);
|
||||
assert_eq!(compacted[0].content, "System prompt");
|
||||
assert!(compacted[1].content.contains("compacted"));
|
||||
assert_eq!(compacted[2].content, "Current question");
|
||||
assert!(compacted[3].tool_calls.is_some()); // assistant with tool calls
|
||||
assert_eq!(compacted[4].name.as_deref(), Some("http"));
|
||||
assert_eq!(compacted[5].name.as_deref(), Some("echo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compact_no_duplicate_system_after_last_user() {
|
||||
// A system nudge message injected AFTER the last user message must
|
||||
// not be duplicated — it should only appear once (via extend_from_slice).
|
||||
let messages = vec![
|
||||
ChatMessage::system("System prompt"),
|
||||
ChatMessage::user("Question"),
|
||||
ChatMessage::system("Nudge: wrap up"),
|
||||
ChatMessage::assistant_with_tool_calls(
|
||||
None,
|
||||
vec![ToolCall {
|
||||
id: "c1".to_string(),
|
||||
name: "echo".to_string(),
|
||||
arguments: serde_json::json!({}),
|
||||
}],
|
||||
),
|
||||
ChatMessage::tool_result("c1", "echo", "done"),
|
||||
];
|
||||
|
||||
let compacted = compact_messages_for_retry(&messages);
|
||||
|
||||
// system prompt + note + user + nudge + assistant + tool_result = 6
|
||||
assert_eq!(compacted.len(), 6);
|
||||
assert_eq!(compacted[0].content, "System prompt");
|
||||
assert!(compacted[1].content.contains("compacted"));
|
||||
assert_eq!(compacted[2].content, "Question");
|
||||
assert_eq!(compacted[3].content, "Nudge: wrap up"); // not duplicated
|
||||
assert_eq!(compacted[4].role, Role::Assistant);
|
||||
assert_eq!(compacted[5].role, Role::Tool);
|
||||
|
||||
// Verify "Nudge: wrap up" appears exactly once
|
||||
let nudge_count = compacted
|
||||
.iter()
|
||||
.filter(|m| m.content == "Nudge: wrap up")
|
||||
.count();
|
||||
assert_eq!(nudge_count, 1);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user