diff --git a/lib/crates/arc-agent/src/compaction.rs b/lib/crates/arc-agent/src/compaction.rs index 7c59493da..ed7170e12 100644 --- a/lib/crates/arc-agent/src/compaction.rs +++ b/lib/crates/arc-agent/src/compaction.rs @@ -80,8 +80,9 @@ pub async fn compact_context( }; let summarization_prompt = format!( - "You are summarizing a coding assistant conversation to provide continuity. A new context \ -window will continue this work with only your summary and the most recent messages.\n\n\ + "You are creating a handoff document for a different coding assistant that will take over \ +this task. That assistant will only see your summary and the most recent messages — nothing else \ +from the conversation so far.\n\n\ Write a summary using EXACTLY these sections:\n\n\ ## Goal\nWhat the user asked for and any constraints or preferences stated.\n\n\ ## Progress\nWhat was accomplished, with file paths and key decisions.\n\n\ @@ -89,8 +90,9 @@ Write a summary using EXACTLY these sections:\n\n\ ## Failed Approaches\nWhat was tried and didn't work, and why.\n\n\ ## Open Issues\nBugs, edge cases, or TODOs that remain.\n\n\ ## Next Steps\nWhat should happen next to make progress.\n\n\ -Be specific — include file paths, function names, and error messages. Omit pleasantries \ -and conversational filler.{file_ops_section}" +Be thorough and specific — the assistant taking over has no prior context. Include file paths, \ +function names, error messages, and exact values. Omit pleasantries and conversational filler.\ +{file_ops_section}" ); let summary_request = Request { @@ -124,7 +126,10 @@ and conversational filler.{file_ops_section}" summary_len = summary_text.len(), "Compaction summary generated" ); - let summary_content = format!("[Context Summary]\n{summary_text}"); + let summary_content = format!( + "A different assistant began this task and produced the following summary. \ +Build on their progress — do not repeat completed steps.\n\n{summary_text}" + ); let summary_token_estimate = summary_content.len() / 4; history.compact(preserve_count, summary_content); diff --git a/lib/crates/arc-agent/src/history.rs b/lib/crates/arc-agent/src/history.rs index 693a15687..d42575d4d 100644 --- a/lib/crates/arc-agent/src/history.rs +++ b/lib/crates/arc-agent/src/history.rs @@ -21,11 +21,13 @@ impl History { return; } let preserved = self.turns.split_off(self.turns.len() - preserve_count); + let extracted_user_messages = extract_recent_user_messages(&self.turns, 20_000); self.turns.clear(); self.turns.push(Turn::System { content: summary, timestamp: std::time::SystemTime::now(), }); + self.turns.extend(extracted_user_messages); self.turns.extend(preserved); self.strip_opaque_provider_items(); } @@ -99,6 +101,29 @@ impl History { } } +/// Walk discarded turns in reverse, collecting `Turn::User` variants up to +/// a token budget (estimated at ~4 chars per token). Returns them in +/// chronological order so they can be inserted between the summary and the +/// preserved tail. +fn extract_recent_user_messages(discarded: &[Turn], token_budget: usize) -> Vec { + let char_budget = token_budget * 4; + let mut total_chars = 0; + let mut collected: Vec = Vec::new(); + + for turn in discarded.iter().rev() { + if let Turn::User { content, .. } = turn { + if total_chars + content.len() > char_budget { + break; + } + total_chars += content.len(); + collected.push(turn.clone()); + } + } + + collected.reverse(); + collected +} + #[cfg(test)] mod tests { use super::*; @@ -115,7 +140,8 @@ mod tests { }); } history.compact(4, "Summary of old conversation".into()); - assert_eq!(history.turns().len(), 5); // 1 summary + 4 preserved + // 1 summary + 4 extracted user messages + 4 preserved = 9 + assert_eq!(history.turns().len(), 9); } #[test] @@ -142,11 +168,16 @@ mod tests { } history.compact(4, "Summary".into()); let turns = history.turns(); - // Last 4 turns should be msg 4..7 - assert!(matches!(&turns[1], Turn::User { content, .. } if content == "msg 4")); - assert!(matches!(&turns[2], Turn::User { content, .. } if content == "msg 5")); - assert!(matches!(&turns[3], Turn::User { content, .. } if content == "msg 6")); - assert!(matches!(&turns[4], Turn::User { content, .. } if content == "msg 7")); + // Layout: summary, extracted user msgs (0..3), preserved (4..7) + assert!(matches!(&turns[0], Turn::System { .. })); + assert!(matches!(&turns[1], Turn::User { content, .. } if content == "msg 0")); + assert!(matches!(&turns[2], Turn::User { content, .. } if content == "msg 1")); + assert!(matches!(&turns[3], Turn::User { content, .. } if content == "msg 2")); + assert!(matches!(&turns[4], Turn::User { content, .. } if content == "msg 3")); + assert!(matches!(&turns[5], Turn::User { content, .. } if content == "msg 4")); + assert!(matches!(&turns[6], Turn::User { content, .. } if content == "msg 5")); + assert!(matches!(&turns[7], Turn::User { content, .. } if content == "msg 6")); + assert!(matches!(&turns[8], Turn::User { content, .. } if content == "msg 7")); } #[test] @@ -434,7 +465,8 @@ mod tests { history.compact(2, "Summary".into()); - let assistant_turn = &history.turns()[2]; + // Layout: summary, extracted User("old msg"), preserved User("recent msg"), preserved Assistant + let assistant_turn = &history.turns()[3]; if let Turn::Assistant { provider_parts, tool_calls, @@ -480,7 +512,8 @@ mod tests { history.compact(2, "Summary".into()); - let assistant_turn = &history.turns()[2]; + // Layout: summary, extracted User("old msg"), preserved User("recent msg"), preserved Assistant + let assistant_turn = &history.turns()[3]; if let Turn::Assistant { provider_parts, .. } = assistant_turn { assert_eq!( provider_parts.len(), @@ -526,4 +559,79 @@ mod tests { } } } + + #[test] + fn extract_recent_user_messages_collects_in_chronological_order() { + let turns = vec![ + Turn::User { + content: "first".into(), + timestamp: SystemTime::now(), + }, + Turn::Assistant { + content: "reply".into(), + tool_calls: vec![], + provider_parts: vec![], + usage: Usage::default(), + response_id: "r1".into(), + timestamp: SystemTime::now(), + }, + Turn::User { + content: "second".into(), + timestamp: SystemTime::now(), + }, + ]; + let extracted = extract_recent_user_messages(&turns, 20_000); + assert_eq!(extracted.len(), 2); + assert!(matches!(&extracted[0], Turn::User { content, .. } if content == "first")); + assert!(matches!(&extracted[1], Turn::User { content, .. } if content == "second")); + } + + #[test] + fn extract_recent_user_messages_respects_token_budget() { + let turns = vec![ + Turn::User { + content: "a".repeat(100), + timestamp: SystemTime::now(), + }, + Turn::User { + content: "b".repeat(100), + timestamp: SystemTime::now(), + }, + ]; + // Budget of 30 tokens = 120 chars; second message (100 chars) fits, first would exceed + let extracted = extract_recent_user_messages(&turns, 30); + assert_eq!(extracted.len(), 1); + assert!(matches!(&extracted[0], Turn::User { content, .. } if content.starts_with('b'))); + } + + #[test] + fn compact_extracts_only_user_turns_from_discarded() { + let mut history = History::default(); + history.push(Turn::User { + content: "user msg".into(), + timestamp: SystemTime::now(), + }); + history.push(Turn::Assistant { + content: "assistant msg".into(), + tool_calls: vec![], + provider_parts: vec![], + usage: Usage::default(), + response_id: "r1".into(), + timestamp: SystemTime::now(), + }); + history.push(Turn::User { + content: "preserved".into(), + timestamp: SystemTime::now(), + }); + + history.compact(1, "Summary".into()); + + // Layout: summary, extracted User("user msg"), preserved User("preserved") + assert_eq!(history.turns().len(), 3); + assert!(matches!(&history.turns()[0], Turn::System { .. })); + assert!(matches!(&history.turns()[1], Turn::User { content, .. } if content == "user msg")); + assert!( + matches!(&history.turns()[2], Turn::User { content, .. } if content == "preserved") + ); + } } diff --git a/lib/crates/arc-agent/src/session.rs b/lib/crates/arc-agent/src/session.rs index c4d1de3f5..55922357b 100644 --- a/lib/crates/arc-agent/src/session.rs +++ b/lib/crates/arc-agent/src/session.rs @@ -564,6 +564,40 @@ impl Session { return Err(self.aborted_error()); } + // Pre-turn compaction check: compact before building the request so the + // LLM call uses a trimmed context when we're already over threshold. + let over_threshold = crate::compaction::check_context_usage( + &self.system_prompt, + &self.history, + self.provider_profile.as_ref(), + self.config.compaction_threshold_percent, + &self.event_emitter, + &self.id, + ); + if over_threshold && self.config.enable_context_compaction { + if let Err(e) = crate::compaction::compact_context( + &mut self.history, + &self.llm_client, + self.provider_profile.as_ref(), + &self.system_prompt, + &self.file_tracker, + self.config.compaction_preserve_turns, + &self.event_emitter, + &self.id, + ) + .await + { + self.event_emitter.emit( + self.id.clone(), + AgentEvent::Error { + error: AgentError::InvalidState(format!( + "Context compaction failed: {e}" + )), + }, + ); + } + } + // Build request let request = self.build_request(); @@ -1887,7 +1921,7 @@ mod tests { // History should have been compacted: summary turn + preserved turns let turns = session.history().turns(); assert!( - turns.iter().any(|t| matches!(t, Turn::System { content, .. } if content.contains("[Context Summary]"))), + turns.iter().any(|t| matches!(t, Turn::System { content, .. } if content.contains("A different assistant began this task"))), "Should contain a summary system turn" ); }