Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ resolver = "2"

[workspace.package]
edition = "2021"
version = "1.9.1"
version = "1.9.2"
authors = ["Block <ai-oss-tools@block.xyz>"]
license = "Apache-2.0"
repository = "https://github.com/block/goose"
Expand Down
187 changes: 77 additions & 110 deletions crates/goose/src/context_mgmt/auto_compact.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use crate::conversation::message::Message;
use crate::conversation::Conversation;
use crate::conversation::{fix_conversation, Conversation};
use crate::{
agents::Agent, config::Config, context_mgmt::get_messages_token_counts_async,
token_counter::create_async_token_counter,
Expand Down Expand Up @@ -135,21 +135,33 @@ pub async fn check_compaction_needed(
pub async fn perform_compaction(agent: &Agent, messages: &[Message]) -> Result<AutoCompactResult> {
info!("Performing message compaction");

// Fix the conversation to handle any issues (like trailing assistant messages with tool requests)
let conversation = Conversation::new_unvalidated(messages.to_vec());
let (fixed_conversation, pre_compact_issues) = fix_conversation(conversation);
if !pre_compact_issues.is_empty() {
debug!("Fixed issues before compaction: {:?}", pre_compact_issues);
}

let messages_to_process = fixed_conversation.messages().clone();

// Check if the most recent message is a user message
let (messages_to_compact, preserved_user_message) = if let Some(last_message) = messages.last()
{
if matches!(last_message.role, rmcp::model::Role::User) {
// Remove the last user message before compaction
(&messages[..messages.len() - 1], Some(last_message.clone()))
let (messages_to_compact, preserved_user_message) =
if let Some(last_message) = messages_to_process.last() {
if matches!(last_message.role, rmcp::model::Role::User) {
// Remove the last user message before compaction
(
&messages_to_process[..messages_to_process.len() - 1],
Some(last_message.clone()),
)
} else {
(messages_to_process.as_slice(), None)
}
} else {
(messages, None)
}
} else {
(messages, None)
};
(messages_to_process.as_slice(), None)
};

// Perform the compaction on messages excluding the preserved user message
let (mut compacted_messages, _, summarization_usage) =
let (mut compacted_messages, _token_counts, summarization_usage) =
agent.summarize_context(messages_to_compact).await?;

// Add back the preserved user message if it exists
Expand Down Expand Up @@ -207,35 +219,7 @@ pub async fn check_and_compact_messages(
check_result.usage_ratio * 100.0
);

// Check if the most recent message is a user message
let (messages_to_compact, preserved_user_message) = if let Some(last_message) = messages.last()
{
if matches!(last_message.role, rmcp::model::Role::User) {
// Remove the last user message before auto-compaction
(&messages[..messages.len() - 1], Some(last_message.clone()))
} else {
(messages, None)
}
} else {
(messages, None)
};

// Perform the compaction on messages excluding the preserved user message
// The summarize_context method already handles the visibility properly
let (mut summary_messages, _, summarization_usage) =
agent.summarize_context(messages_to_compact).await?;

// Add back the preserved user message if it exists
// (keeps default visibility: both true)
if let Some(user_message) = preserved_user_message {
summary_messages.push(user_message);
}

Ok(AutoCompactResult {
compacted: true,
messages: summary_messages,
summarization_usage,
})
perform_compaction(agent, messages).await
}

#[cfg(test)]
Expand Down Expand Up @@ -470,72 +454,10 @@ mod tests {
}

assert!(result.compacted);
assert!(result.summarization_usage.is_some());

// Verify that summarization usage contains token counts
if let Some(usage) = &result.summarization_usage {
assert!(usage.usage.total_tokens.is_some());
let after = usage.usage.total_tokens.unwrap_or(0) as usize;
assert!(
after > 0,
"Token count after compaction should be greater than 0"
);
}

// After visibility implementation, we keep all messages plus summary
// Original messages become user_visible only, summary becomes agent_visible only
assert!(result.messages.len() > messages.len());
}

#[tokio::test]
async fn test_auto_compact_respects_config() {
let mock_provider = Arc::new(MockProvider {
model_config: ModelConfig::new("test-model")
.unwrap()
.with_context_limit(Some(30_000)), // Smaller context limit to make threshold easier to hit
});

let agent = Agent::new();
let _ = agent.update_provider(mock_provider).await;

// Create enough messages to trigger compaction with low threshold
let mut messages = Vec::new();
// With 30k context limit, after overhead we have ~27k usable tokens
// 10% of 27k = 2.7k tokens, so we need messages that exceed that
for i in 0..200 {
messages.push(create_test_message(&format!(
"Message {} with enough content to ensure we exceed 10% of the context limit. \
Adding more content to increase token count substantially. This message contains \
multiple sentences to increase the token count. We need to ensure that our total \
token usage exceeds 10% of the available context limit after accounting for \
system prompt and tools overhead.",
i
)));
}
// Note: summarization_usage might be None if messages were fixed/removed before compaction

// Set config value
let config = Config::global();
config
.set_param("GOOSE_AUTO_COMPACT_THRESHOLD", serde_json::Value::from(0.1))
.unwrap();

// Should use config value when no override provided
let result = check_and_compact_messages(&agent, &messages, None, None)
.await
.unwrap();

// Debug info if not compacted
if !result.compacted {
eprintln!("Test failed - compaction not triggered");
}

// With such a low threshold (10%), it should compact
assert!(result.compacted);

// Clean up config
config
.set_param("GOOSE_AUTO_COMPACT_THRESHOLD", serde_json::Value::from(0.3))
.unwrap();
// Verify that we have messages after compaction
assert!(!result.messages.is_empty());
}

#[tokio::test]
Expand Down Expand Up @@ -664,14 +586,59 @@ mod tests {

// Should have triggered compaction
assert!(result.compacted);
assert!(result.summarization_usage.is_some());
// Note: summarization_usage might be None if messages were fixed/removed before compaction

// Verify the compacted messages are returned
assert!(!result.messages.is_empty());
}

#[tokio::test]
async fn test_auto_compact_removes_pending_tool_request() {
use mcp_core::tool::ToolCall;

let mock_provider = Arc::new(MockProvider {
model_config: ModelConfig::new("test-model")
.unwrap()
.with_context_limit(10_000.into()),
});

let agent = Agent::new();
let _ = agent.update_provider(mock_provider).await;

// Create messages including an assistant message with a tool request
let mut messages = vec![
create_test_message("First message"),
create_test_message("Second message"),
];

// Add an assistant message with a tool request
let tool_call = ToolCall::new("test_tool", serde_json::json!({}));

let assistant_msg = Message::assistant()
.with_tool_request("test_tool_id".to_string(), Ok(tool_call))
.with_text("I'll help you with that");
messages.push(assistant_msg);

// Create session metadata with high token count to trigger compaction
let mut session_metadata = crate::session::storage::SessionMetadata::default();
session_metadata.total_tokens = Some(9000); // High enough to trigger compaction

// Perform compaction
let result = perform_compaction(&agent, &messages).await.unwrap();

// After visibility implementation, we keep all messages plus summary
// Original messages become user_visible only, summary becomes agent_visible only
assert!(result.messages.len() > messages.len());
// The compaction should have removed the last assistant message with tool request
assert!(result.compacted);

// Check that the last assistant message with tool request is not in the compacted messages
let has_tool_request = result
.messages
.messages()
.iter()
.any(|m| matches!(m.role, rmcp::model::Role::Assistant) && m.is_tool_call());
assert!(
!has_tool_request,
"Compacted messages should not contain assistant message with tool request"
);
}

#[tokio::test]
Expand Down
Loading
Loading