| use forge_domain::{ |
| Compact, CompactionStrategy, Context, ContextMessage, ContextSummary, Environment, |
| MessageEntry, Transformer, |
| }; |
| use tracing::info; |
|
|
| use crate::TemplateEngine; |
| use crate::transformers::SummaryTransformer; |
|
|
| |
| pub struct Compactor { |
| compact: Compact, |
| environment: Environment, |
| } |
|
|
| impl Compactor { |
| pub fn new(compact: Compact, environment: Environment) -> Self { |
| Self { compact, environment } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| fn transform(&self, context_summary: ContextSummary) -> ContextSummary { |
| SummaryTransformer::new(&self.environment.cwd).transform(context_summary) |
| } |
| } |
|
|
| impl Compactor { |
| |
| pub fn compact(&self, context: Context, max: bool) -> anyhow::Result<Context> { |
| let eviction = CompactionStrategy::evict(self.compact.eviction_window); |
| let retention = CompactionStrategy::retain(self.compact.retention_window); |
|
|
| let strategy = if max { |
| |
| retention |
| } else { |
| eviction.min(retention) |
| }; |
|
|
| match strategy.eviction_range(&context) { |
| Some(sequence) => self.compress_single_sequence(context, sequence), |
| None => Ok(context), |
| } |
| } |
|
|
| |
| fn compress_single_sequence( |
| &self, |
| mut context: Context, |
| sequence: (usize, usize), |
| ) -> anyhow::Result<Context> { |
| let (start, end) = sequence; |
|
|
| |
| |
| let compaction_sequence = context |
| .messages |
| .get(start..=end) |
| .map(|slice| { |
| slice |
| .iter() |
| .filter(|msg| !msg.is_droppable()) |
| .cloned() |
| .collect::<Vec<_>>() |
| }) |
| .unwrap_or_else(|| { |
| tracing::error!( |
| "Compaction range [{}..={}] out of bounds for {} messages", |
| start, |
| end, |
| context.messages.len() |
| ); |
| Vec::new() |
| }); |
|
|
| |
| let sequence_context = Context::default().messages(compaction_sequence.clone()); |
|
|
| |
| let context_summary = ContextSummary::from(&sequence_context); |
|
|
| |
| let context_summary = self.transform(context_summary); |
|
|
| info!( |
| sequence_start = sequence.0, |
| sequence_end = sequence.1, |
| sequence_length = compaction_sequence.len(), |
| "Created context compaction summary" |
| ); |
|
|
| let summary = TemplateEngine::default().render( |
| "forge-partial-summary-frame.md", |
| &serde_json::json!({"messages": context_summary.messages}), |
| )?; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| let reasoning_details = compaction_sequence |
| .iter() |
| .rev() |
| .find_map(|msg| match &**msg { |
| ContextMessage::Text(text) => text |
| .reasoning_details |
| .as_ref() |
| .filter(|rd| !rd.is_empty()) |
| .cloned(), |
| _ => None, |
| }); |
|
|
| |
| |
| let compacted_usage = context.messages.get(start..=end).and_then(|slice| { |
| slice |
| .iter() |
| .filter_map(|entry| entry.usage.as_ref()) |
| .cloned() |
| .reduce(|a, b| a.accumulate(&b)) |
| }); |
|
|
| |
| let mut summary_entry = MessageEntry::from(ContextMessage::user(summary, None)); |
| summary_entry.usage = compacted_usage; |
| context |
| .messages |
| .splice(start..=end, std::iter::once(summary_entry)); |
|
|
| |
| context.messages.retain(|msg| !msg.is_droppable()); |
|
|
| |
| if let Some(reasoning) = reasoning_details |
| && let Some(ContextMessage::Text(msg)) = context |
| .messages |
| .iter_mut() |
| .find(|msg| msg.has_role(forge_domain::Role::Assistant)) |
| .map(|msg| &mut **msg) |
| && msg |
| .reasoning_details |
| .as_ref() |
| .is_none_or(|rd| rd.is_empty()) |
| { |
| msg.reasoning_details = Some(reasoning); |
| } |
|
|
| Ok(context) |
| } |
| } |
|
|
| #[cfg(test)] |
| mod tests { |
| use std::path::PathBuf; |
|
|
| use forge_domain::MessageEntry; |
| use pretty_assertions::assert_eq; |
|
|
| use super::*; |
|
|
| fn test_environment() -> Environment { |
| use fake::{Fake, Faker}; |
| let env: Environment = Faker.fake(); |
| env.cwd(std::path::PathBuf::from("/test/working/dir")) |
| } |
|
|
| #[test] |
| fn test_compress_single_sequence_preserves_only_last_reasoning() { |
| use forge_domain::ReasoningFull; |
|
|
| let environment = test_environment(); |
| let compactor = Compactor::new(Compact::new(), environment); |
|
|
| let first_reasoning = vec![ReasoningFull { |
| text: Some("First thought".to_string()), |
| signature: Some("sig1".to_string()), |
| ..Default::default() |
| }]; |
|
|
| let last_reasoning = vec![ReasoningFull { |
| text: Some("Last thought".to_string()), |
| signature: Some("sig2".to_string()), |
| ..Default::default() |
| }]; |
|
|
| let context = Context::default() |
| .add_message(ContextMessage::user("M1", None)) |
| .add_message(ContextMessage::assistant( |
| "R1", |
| None, |
| Some(first_reasoning.clone()), |
| None, |
| )) |
| .add_message(ContextMessage::user("M2", None)) |
| .add_message(ContextMessage::assistant( |
| "R2", |
| None, |
| Some(last_reasoning.clone()), |
| None, |
| )) |
| .add_message(ContextMessage::user("M3", None)) |
| .add_message(ContextMessage::assistant("R3", None, None, None)); |
|
|
| let actual = compactor.compress_single_sequence(context, (0, 3)).unwrap(); |
|
|
| |
| let assistant_msg = actual |
| .messages |
| .iter() |
| .find(|msg| msg.has_role(forge_domain::Role::Assistant)) |
| .expect("Should have an assistant message"); |
|
|
| if let ContextMessage::Text(text_msg) = &**assistant_msg { |
| assert_eq!( |
| text_msg.reasoning_details.as_ref(), |
| Some(&last_reasoning), |
| "Should preserve only the last reasoning, not the first" |
| ); |
| } else { |
| panic!("Expected TextMessage"); |
| } |
| } |
|
|
| #[test] |
| fn test_compress_single_sequence_no_reasoning_accumulation() { |
| use forge_domain::ReasoningFull; |
|
|
| let environment = test_environment(); |
| let compactor = Compactor::new(Compact::new(), environment); |
|
|
| let reasoning = vec![ReasoningFull { |
| text: Some("Original thought".to_string()), |
| signature: Some("sig1".to_string()), |
| ..Default::default() |
| }]; |
|
|
| |
| let context = Context::default() |
| .add_message(ContextMessage::user("M1", None)) |
| .add_message(ContextMessage::assistant( |
| "R1", |
| None, |
| Some(reasoning.clone()), |
| None, |
| )) |
| .add_message(ContextMessage::user("M2", None)) |
| .add_message(ContextMessage::assistant("R2", None, None, None)); |
|
|
| let context = compactor.compress_single_sequence(context, (0, 1)).unwrap(); |
|
|
| |
| let first_assistant = context |
| .messages |
| .iter() |
| .find(|msg| msg.has_role(forge_domain::Role::Assistant)) |
| .unwrap(); |
|
|
| if let ContextMessage::Text(text_msg) = &**first_assistant { |
| assert_eq!(text_msg.reasoning_details.as_ref().unwrap().len(), 1); |
| } |
|
|
| |
| let context = context |
| .add_message(ContextMessage::user("M3", None)) |
| .add_message(ContextMessage::assistant("R3", None, None, None)); |
|
|
| let context = compactor.compress_single_sequence(context, (0, 2)).unwrap(); |
|
|
| |
| let first_assistant = context |
| .messages |
| .iter() |
| .find(|msg| msg.has_role(forge_domain::Role::Assistant)) |
| .unwrap(); |
|
|
| if let ContextMessage::Text(text_msg) = &**first_assistant { |
| assert_eq!( |
| text_msg.reasoning_details.as_ref().unwrap().len(), |
| 1, |
| "Reasoning should not accumulate across compactions" |
| ); |
| } |
| } |
|
|
| #[test] |
| fn test_compress_single_sequence_filters_empty_reasoning() { |
| use forge_domain::ReasoningFull; |
|
|
| let environment = test_environment(); |
| let compactor = Compactor::new(Compact::new(), environment); |
|
|
| let non_empty_reasoning = vec![ReasoningFull { |
| text: Some("Valid thought".to_string()), |
| signature: Some("sig1".to_string()), |
| ..Default::default() |
| }]; |
|
|
| |
| let context = Context::default() |
| .add_message(ContextMessage::user("M1", None)) |
| .add_message(ContextMessage::assistant( |
| "R1", |
| None, |
| Some(non_empty_reasoning.clone()), |
| None, |
| )) |
| .add_message(ContextMessage::user("M2", None)) |
| .add_message(ContextMessage::assistant("R2", None, Some(vec![]), None)) |
| .add_message(ContextMessage::user("M3", None)) |
| .add_message(ContextMessage::assistant("R3", None, None, None)); |
|
|
| let actual = compactor.compress_single_sequence(context, (0, 3)).unwrap(); |
|
|
| |
| |
| let assistant_msg = actual |
| .messages |
| .iter() |
| .find(|msg| msg.has_role(forge_domain::Role::Assistant)) |
| .expect("Should have an assistant message"); |
|
|
| if let ContextMessage::Text(text_msg) = &**assistant_msg { |
| assert_eq!( |
| text_msg.reasoning_details.as_ref(), |
| Some(&non_empty_reasoning), |
| "Should skip most recent empty reasoning and preserve earlier non-empty" |
| ); |
| } else { |
| panic!("Expected TextMessage"); |
| } |
| } |
|
|
| fn render_template(data: &serde_json::Value) -> String { |
| TemplateEngine::default() |
| .render("forge-partial-summary-frame.md", data) |
| .unwrap() |
| } |
|
|
| #[test] |
| fn test_template_engine_renders_summary_frame() { |
| use forge_domain::{ContextSummary, Role, SummaryBlock, SummaryMessage, SummaryToolCall}; |
|
|
| |
| let messages = vec![ |
| SummaryBlock::new( |
| Role::User, |
| vec![SummaryMessage::content("Please read the config file")], |
| ), |
| SummaryBlock::new( |
| Role::Assistant, |
| vec![ |
| SummaryToolCall::read("config.toml") |
| .id("call_1") |
| .is_success(false) |
| .into(), |
| ], |
| ), |
| SummaryBlock::new( |
| Role::User, |
| vec![SummaryMessage::content("Now update the version number")], |
| ), |
| SummaryBlock::new( |
| Role::Assistant, |
| vec![SummaryToolCall::update("Cargo.toml").id("call_2").into()], |
| ), |
| SummaryBlock::new( |
| Role::User, |
| vec![SummaryMessage::content("Search for TODO comments")], |
| ), |
| SummaryBlock::new( |
| Role::Assistant, |
| vec![ |
| SummaryToolCall::search("TODO") |
| .id("call_3") |
| .is_success(false) |
| .into(), |
| ], |
| ), |
| SummaryBlock::new( |
| Role::Assistant, |
| vec![ |
| SummaryToolCall::codebase_search(vec![forge_domain::SearchQuery::new( |
| "authentication logic", |
| "Find authentication implementation", |
| )]) |
| .id("call_4") |
| .is_success(false) |
| .into(), |
| ], |
| ), |
| SummaryBlock::new( |
| Role::Assistant, |
| vec![ |
| SummaryToolCall::shell("cargo test") |
| .id("call_5") |
| .is_success(false) |
| .into(), |
| ], |
| ), |
| SummaryBlock::new( |
| Role::User, |
| vec![SummaryMessage::content("Great! Everything looks good.")], |
| ), |
| ]; |
|
|
| let context_summary = ContextSummary { messages }; |
| let data = serde_json::json!({"messages": context_summary.messages}); |
|
|
| let actual = render_template(&data); |
|
|
| insta::assert_snapshot!(actual); |
| } |
|
|
| #[test] |
| fn test_template_engine_renders_todo_write() { |
| use forge_domain::{ |
| ContextSummary, Role, SummaryBlock, SummaryMessage, SummaryTool, SummaryToolCall, Todo, |
| TodoChange, TodoChangeKind, TodoStatus, |
| }; |
|
|
| |
| let changes = vec![ |
| TodoChange { |
| todo: Todo::new("Implement user authentication") |
| .id("1") |
| .status(TodoStatus::Completed), |
| kind: TodoChangeKind::Updated, |
| }, |
| TodoChange { |
| todo: Todo::new("Add database migrations") |
| .id("2") |
| .status(TodoStatus::InProgress), |
| kind: TodoChangeKind::Added, |
| }, |
| TodoChange { |
| todo: Todo::new("Write documentation") |
| .id("3") |
| .status(TodoStatus::Pending), |
| kind: TodoChangeKind::Removed, |
| }, |
| ]; |
|
|
| let messages = vec![ |
| SummaryBlock::new( |
| Role::User, |
| vec![SummaryMessage::content("Create a task plan")], |
| ), |
| SummaryBlock::new( |
| Role::Assistant, |
| vec![ |
| SummaryToolCall { |
| id: Some(forge_domain::ToolCallId::new("call_1")), |
| tool: SummaryTool::TodoWrite { changes }, |
| is_success: true, |
| } |
| .into(), |
| ], |
| ), |
| ]; |
|
|
| let context_summary = ContextSummary { messages }; |
| let data = serde_json::json!({"messages": context_summary.messages}); |
|
|
| let actual = render_template(&data); |
|
|
| insta::assert_snapshot!(actual); |
| } |
|
|
| #[tokio::test] |
| async fn test_render_summary_frame_snapshot() { |
| |
| let fixture_json = forge_test_kit::fixture!("/src/fixtures/conversation.json").await; |
|
|
| let conversation: forge_domain::Conversation = |
| serde_json::from_str(&fixture_json).expect("Failed to parse conversation fixture"); |
|
|
| |
| let context = conversation |
| .context |
| .expect("Conversation should have context"); |
|
|
| |
| let environment = test_environment().cwd(PathBuf::from( |
| "/Users/tushar/Documents/Projects/code-forge-workspace/code-forge", |
| )); |
| let compactor = Compactor::new(Compact::new(), environment); |
|
|
| |
| let context_summary = ContextSummary::from(&context); |
|
|
| |
| let context_summary = compactor.transform(context_summary); |
|
|
| let data = serde_json::json!({"messages": context_summary.messages}); |
|
|
| let summary = render_template(&data); |
|
|
| insta::assert_snapshot!(summary); |
|
|
| |
| let compacted_context = compactor.compact(context, true).unwrap(); |
|
|
| insta::assert_yaml_snapshot!(compacted_context); |
| } |
|
|
| #[test] |
| fn test_compaction_removes_droppable_messages() { |
| use forge_domain::{ContextMessage, Role, TextMessage}; |
|
|
| let environment = test_environment(); |
| let compactor = Compactor::new(Compact::new(), environment); |
|
|
| |
| let context = Context::default() |
| .add_message(ContextMessage::user("User message 1", None)) |
| .add_message(ContextMessage::assistant( |
| "Assistant response 1", |
| None, |
| None, |
| None, |
| )) |
| .add_message(ContextMessage::Text( |
| TextMessage::new(Role::User, "Attachment content").droppable(true), |
| )) |
| .add_message(ContextMessage::user("User message 2", None)) |
| .add_message(ContextMessage::assistant( |
| "Assistant response 2", |
| None, |
| None, |
| None, |
| )); |
|
|
| let actual = compactor.compress_single_sequence(context, (0, 1)).unwrap(); |
|
|
| |
| |
| assert_eq!(actual.messages.len(), 3); |
|
|
| |
| for msg in &actual.messages { |
| if let ContextMessage::Text(text_msg) = &**msg { |
| assert!(!text_msg.droppable, "Droppable messages should be removed"); |
| } |
| } |
| } |
|
|
| #[test] |
| fn test_compaction_preserves_usage_information() { |
| use forge_domain::{TokenCount, Usage}; |
|
|
| let environment = test_environment(); |
| let compactor = Compactor::new(Compact::new(), environment); |
|
|
| |
| let inside_usage = Usage { |
| total_tokens: TokenCount::Actual(20000), |
| prompt_tokens: TokenCount::Actual(18000), |
| completion_tokens: TokenCount::Actual(2000), |
| cached_tokens: TokenCount::Actual(0), |
| cost: Some(0.5), |
| }; |
|
|
| |
| let inside_usage2 = Usage { |
| total_tokens: TokenCount::Actual(30000), |
| prompt_tokens: TokenCount::Actual(27000), |
| completion_tokens: TokenCount::Actual(3000), |
| cached_tokens: TokenCount::Actual(0), |
| cost: Some(1.0), |
| }; |
|
|
| |
| let outside_usage = Usage { |
| total_tokens: TokenCount::Actual(50000), |
| prompt_tokens: TokenCount::Actual(45000), |
| completion_tokens: TokenCount::Actual(5000), |
| cached_tokens: TokenCount::Actual(0), |
| cost: Some(1.5), |
| }; |
|
|
| let mut entry1 = |
| MessageEntry::from(ContextMessage::assistant("Response 1", None, None, None)); |
| entry1.usage = Some(inside_usage); |
|
|
| let mut entry3 = |
| MessageEntry::from(ContextMessage::assistant("Response 2", None, None, None)); |
| entry3.usage = Some(inside_usage2); |
|
|
| let mut entry5 = |
| MessageEntry::from(ContextMessage::assistant("Response 3", None, None, None)); |
| entry5.usage = Some(outside_usage); |
|
|
| let context = Context::default() |
| .add_entry(ContextMessage::user("Message 1", None)) |
| .add_entry(entry1) |
| .add_entry(ContextMessage::user("Message 2", None)) |
| .add_entry(entry3) |
| .add_entry(ContextMessage::user("Message 3", None)) |
| .add_entry(entry5); |
|
|
| |
| let compacted = compactor.compress_single_sequence(context, (0, 3)).unwrap(); |
|
|
| |
| assert_eq!( |
| compacted.messages.len(), |
| 3, |
| "Expected 3 messages after compaction: summary + 2 remaining messages" |
| ); |
|
|
| |
| |
| let expected_compacted_usage = Usage { |
| total_tokens: TokenCount::Actual(50000), |
| prompt_tokens: TokenCount::Actual(45000), |
| completion_tokens: TokenCount::Actual(5000), |
| cached_tokens: TokenCount::Actual(0), |
| cost: Some(1.5), |
| }; |
|
|
| assert_eq!( |
| compacted.messages[0].usage, |
| Some(expected_compacted_usage), |
| "Summary message should carry accumulated usage from compacted messages" |
| ); |
|
|
| |
| |
| let expected_total_usage = Usage { |
| total_tokens: TokenCount::Actual(100000), |
| prompt_tokens: TokenCount::Actual(90000), |
| completion_tokens: TokenCount::Actual(10000), |
| cached_tokens: TokenCount::Actual(0), |
| cost: Some(3.0), |
| }; |
|
|
| assert_eq!( |
| compacted.accumulate_usage(), |
| Some(expected_total_usage), |
| "accumulate_usage() must include usage from both compacted and surviving messages" |
| ); |
| } |
|
|
| |
| |
| |
| |
| fn ctx(pattern: &str) -> Context { |
| forge_domain::MessagePattern::new(pattern).build() |
| } |
|
|
| #[test] |
| fn test_should_compact_no_thresholds_set() { |
| let fixture = Compact::new().model("test-model"); |
| let context = ctx("ua"); |
| let actual = fixture.should_compact(&context, 1000); |
| assert_eq!(actual, false); |
| } |
|
|
| #[test] |
| fn test_should_compact_token_threshold_triggers() { |
| let fixture = Compact::new() |
| .model("test-model") |
| .token_threshold(100_usize); |
| let context = ctx("u"); |
| let actual = fixture.should_compact(&context, 150); |
| assert_eq!(actual, true); |
| } |
|
|
| #[test] |
| fn test_should_compact_turn_threshold_triggers() { |
| let fixture = Compact::new().model("test-model").turn_threshold(1_usize); |
| let context = ctx("uau"); |
| let actual = fixture.should_compact(&context, 50); |
| assert_eq!(actual, true); |
| } |
|
|
| #[test] |
| fn test_should_compact_message_threshold_triggers() { |
| let fixture = Compact::new() |
| .model("test-model") |
| .message_threshold(2_usize); |
| let context = ctx("uau"); |
| let actual = fixture.should_compact(&context, 50); |
| assert_eq!(actual, true); |
| } |
|
|
| #[test] |
| fn test_should_compact_multiple_thresholds_any_triggers() { |
| let fixture = Compact::new() |
| .model("test-model") |
| .token_threshold(200_usize) |
| .turn_threshold(5_usize) |
| .message_threshold(10_usize); |
| let context = ctx("ua"); |
| let actual = fixture.should_compact(&context, 250); |
| assert_eq!(actual, true); |
| } |
|
|
| #[test] |
| fn test_should_compact_multiple_thresholds_none_trigger() { |
| let fixture = Compact::new() |
| .model("test-model") |
| .token_threshold(200_usize) |
| .turn_threshold(5_usize) |
| .message_threshold(10_usize); |
| let context = ctx("ua"); |
| let actual = fixture.should_compact(&context, 100); |
| assert_eq!(actual, false); |
| } |
|
|
| #[test] |
| fn test_should_compact_empty_context() { |
| let fixture = Compact::new() |
| .model("test-model") |
| .message_threshold(1_usize); |
| let context = ctx(""); |
| let actual = fixture.should_compact(&context, 0); |
| assert_eq!(actual, false); |
| } |
|
|
| #[test] |
| fn test_should_compact_last_user_message_integration() { |
| let fixture = Compact::new().model("test-model").on_turn_end(true); |
| let context = ctx("au"); |
| let actual = fixture.should_compact(&context, 10); |
| assert_eq!(actual, true); |
| } |
|
|
| #[test] |
| fn test_should_compact_last_user_message_integration_disabled() { |
| let fixture = Compact::new().model("test-model").on_turn_end(false); |
| let context = ctx("au"); |
| let actual = fixture.should_compact(&context, 10); |
| assert_eq!(actual, false); |
| } |
|
|
| #[test] |
| fn test_should_compact_multiple_conditions_with_last_user_message() { |
| let fixture = Compact::new() |
| .model("test-model") |
| .token_threshold(200_usize) |
| .on_turn_end(true); |
| let context = ctx("au"); |
| let actual = fixture.should_compact(&context, 50); |
| assert_eq!(actual, true); |
| } |
|
|
| #[test] |
| fn test_compact_model_none_falls_back_to_agent_model() { |
| let compact = Compact::new() |
| .token_threshold(1000_usize) |
| .turn_threshold(5_usize); |
| assert_eq!(compact.model, None); |
| assert_eq!(compact.token_threshold, Some(1000_usize)); |
| assert_eq!(compact.turn_threshold, Some(5_usize)); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| #[test] |
| fn test_safe_threshold_triggers_earlier_than_unsafe_threshold() { |
| use forge_domain::{ContextMessage, ToolCallId, ToolName, ToolResult}; |
|
|
| |
| let unsafe_compact = Compact::new() |
| .token_threshold(100_000_usize) |
| .max_tokens(2000_usize); |
|
|
| let safe_compact = Compact::new() |
| .token_threshold(89_600_usize) |
| .max_tokens(2000_usize); |
|
|
| let _environment = test_environment(); |
|
|
| |
| let mut unsafe_context = create_large_context(80_000); |
| let mut safe_context = create_large_context(80_000); |
|
|
| |
| for turn in 1..=2 { |
| |
| let user_msg = |
| ContextMessage::user(format!("Turn {}: Please analyze this file", turn), None); |
| let assistant_msg = ContextMessage::assistant( |
| format!("I'll analyze for turn {}", turn), |
| None, |
| None, |
| None, |
| ); |
|
|
| unsafe_context = unsafe_context.add_message(user_msg.clone()); |
| safe_context = safe_context.add_message(user_msg); |
|
|
| unsafe_context = unsafe_context.add_message(assistant_msg.clone()); |
| safe_context = safe_context.add_message(assistant_msg); |
|
|
| |
| for file_read in 1..=3 { |
| let tool_result = ToolResult::new(ToolName::new("read")) |
| .call_id(ToolCallId::new(format!("call_{}_{}", turn, file_read))) |
| .success(create_large_content(5000)); |
|
|
| unsafe_context = unsafe_context.add_tool_results(vec![tool_result.clone()]); |
| safe_context = safe_context.add_tool_results(vec![tool_result]); |
| } |
|
|
| let unsafe_token_count = unsafe_context.token_count_approx(); |
| let safe_token_count = safe_context.token_count_approx(); |
|
|
| let _unsafe_should_compact = |
| unsafe_compact.should_compact(&unsafe_context, unsafe_token_count); |
| let _safe_should_compact = safe_compact.should_compact(&safe_context, safe_token_count); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| let safe_token_count_turn1 = 95_000; |
| let safe_should_compact_turn1 = |
| safe_compact.should_compact(&safe_context, safe_token_count_turn1); |
|
|
| |
| |
| assert!( |
| safe_should_compact_turn1 || safe_token_count_turn1 < 89_600, |
| "Safe threshold (89.6K) should trigger compaction at ~95K tokens to provide safety margin" |
| ); |
|
|
| |
| |
| let final_unsafe = unsafe_context.token_count_approx(); |
| let final_safe = safe_context.token_count_approx(); |
|
|
| |
| |
| assert_eq!( |
| final_unsafe, final_safe, |
| "Both contexts should have same token count" |
| ); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| } |
|
|
| |
| fn create_large_context(token_count: usize) -> Context { |
| use forge_domain::ContextMessage; |
|
|
| |
| let char_count = token_count * 4; |
| let content = "x".repeat(char_count); |
|
|
| |
| let messages_needed = 10; |
| let content_per_message = content.len() / messages_needed; |
|
|
| let mut context = Context::default(); |
| for i in 0..messages_needed { |
| let start = i * content_per_message; |
| let end = ((i + 1) * content_per_message).min(content.len()); |
| let msg_content = &content[start..end]; |
|
|
| if i % 2 == 0 { |
| context = context.add_message(ContextMessage::user(msg_content, None)); |
| } else { |
| context = |
| context.add_message(ContextMessage::assistant(msg_content, None, None, None)); |
| } |
| } |
|
|
| context |
| } |
|
|
| |
| fn create_large_content(token_count: usize) -> String { |
| |
| "x".repeat(token_count * 4) |
| } |
| } |
|
|