diff --git a/crates/code_assistant/resources/compaction_prompt.md b/crates/code_assistant/resources/compaction_prompt.md
new file mode 100644
index 00000000..f2213526
--- /dev/null
+++ b/crates/code_assistant/resources/compaction_prompt.md
@@ -0,0 +1,8 @@
+
+The conversation history is nearing the model's context window limit. Provide a thorough summary that allows resuming the task without the earlier messages. Include:
+- The current objectives or tasks.
+- Key actions taken so far and their outcomes.
+- Important files, commands, or decisions that matter for continuing.
+- Outstanding questions or follow-up work that still needs attention.
+Respond with plain text only.
+
diff --git a/crates/code_assistant/src/acp/agent.rs b/crates/code_assistant/src/acp/agent.rs
index 5dcf40e1..72daa242 100644
--- a/crates/code_assistant/src/acp/agent.rs
+++ b/crates/code_assistant/src/acp/agent.rs
@@ -1,4 +1,6 @@
use agent_client_protocol as acp;
+#[allow(unused_imports)]
+use anyhow::Context;
use anyhow::Result;
use serde_json::{json, Map as JsonMap, Value as JsonValue};
use std::collections::{HashMap, HashSet};
@@ -321,14 +323,12 @@ impl acp::Agent for ACPAgentImpl {
let session_id = {
let mut manager = session_manager.lock().await;
+ let session_model_config = SessionModelConfig::new(model_name.clone());
manager
.create_session_with_config(
None,
Some(session_config),
- Some(SessionModelConfig {
- model_name: model_name.clone(),
- record_path: None,
- }),
+ Some(session_model_config),
)
.map_err(|e| {
tracing::error!("Failed to create session: {}", e);
@@ -344,13 +344,11 @@ impl acp::Agent for ACPAgentImpl {
{
if model_info.selection_changed {
let mut manager = session_manager.lock().await;
- if let Err(err) = manager.set_session_model_config(
- &session_id,
- Some(SessionModelConfig {
- model_name: model_info.selected_model_name.clone(),
- record_path: None,
- }),
- ) {
+ let fallback_model_config =
+ SessionModelConfig::new(model_info.selected_model_name.clone());
+ if let Err(err) =
+ manager.set_session_model_config(&session_id, Some(fallback_model_config))
+ {
tracing::error!(
error = ?err,
"ACP: Failed to persist fallback model selection for session {}",
@@ -446,16 +444,12 @@ impl acp::Agent for ACPAgentImpl {
.map(|config| config.model_name.as_str()),
) {
if model_info.selection_changed {
- let record_path = stored_model_config
- .as_ref()
- .and_then(|config| config.record_path.clone());
let mut manager = session_manager.lock().await;
+ let fallback_model_config =
+ SessionModelConfig::new(model_info.selected_model_name.clone());
if let Err(err) = manager.set_session_model_config(
&arguments.session_id.0,
- Some(SessionModelConfig {
- model_name: model_info.selected_model_name.clone(),
- record_path,
- }),
+ Some(fallback_model_config),
) {
tracing::error!(
error = ?err,
@@ -541,10 +535,7 @@ impl acp::Agent for ACPAgentImpl {
let session_model_config = match config_result {
Ok(Some(config)) => config,
- Ok(None) => SessionModelConfig {
- model_name: model_name.clone(),
- record_path: None,
- },
+ Ok(None) => SessionModelConfig::new(model_name.clone()),
Err(e) => {
let error_msg = format!(
"Failed to load session model configuration for session {}: {e}",
@@ -565,6 +556,7 @@ impl acp::Agent for ACPAgentImpl {
&model_name_for_prompt,
playback_path,
fast_playback,
+ None,
)
.await
{
@@ -813,27 +805,20 @@ impl acp::Agent for ACPAgentImpl {
manager.get_session_model_config(&session_id.0)
};
- let record_path = match existing_config {
- Ok(Some(config)) => config.record_path,
- Ok(None) => None,
- Err(err) => {
- tracing::error!(
- error = ?err,
- "ACP: Failed to read existing session model configuration"
- );
- return Err(acp::Error::internal_error());
- }
- };
+ if let Err(err) = existing_config {
+ tracing::error!(
+ error = ?err,
+ "ACP: Failed to read existing session model configuration"
+ );
+ return Err(acp::Error::internal_error());
+ }
{
let mut manager = session_manager.lock().await;
- if let Err(err) = manager.set_session_model_config(
- &session_id.0,
- Some(SessionModelConfig {
- model_name: display_name.clone(),
- record_path,
- }),
- ) {
+ let new_model_config = SessionModelConfig::new(display_name.clone());
+ if let Err(err) =
+ manager.set_session_model_config(&session_id.0, Some(new_model_config))
+ {
tracing::error!(
error = ?err,
"ACP: Failed to persist session model selection"
diff --git a/crates/code_assistant/src/acp/types.rs b/crates/code_assistant/src/acp/types.rs
index a736fb6b..bb9b25e2 100644
--- a/crates/code_assistant/src/acp/types.rs
+++ b/crates/code_assistant/src/acp/types.rs
@@ -15,6 +15,13 @@ pub fn fragment_to_content_block(fragment: &DisplayFragment) -> acp::ContentBloc
text: text.clone(),
meta: None,
}),
+ DisplayFragment::CompactionDivider { summary } => {
+ acp::ContentBlock::Text(acp::TextContent {
+ annotations: None,
+ text: format!("Conversation compacted:\n{summary}"),
+ meta: None,
+ })
+ }
DisplayFragment::Image { media_type, data } => {
acp::ContentBlock::Image(acp::ImageContent {
annotations: None,
diff --git a/crates/code_assistant/src/acp/ui.rs b/crates/code_assistant/src/acp/ui.rs
index ba3cb61f..202acbaa 100644
--- a/crates/code_assistant/src/acp/ui.rs
+++ b/crates/code_assistant/src/acp/ui.rs
@@ -622,6 +622,7 @@ impl UserInterface for ACPUserUI {
// Events that don't translate to ACP
UiEvent::UpdateMemory { .. }
| UiEvent::SetMessages { .. }
+ | UiEvent::DisplayCompactionSummary { .. }
| UiEvent::StreamingStarted(_)
| UiEvent::StreamingStopped { .. }
| UiEvent::RefreshChatList
@@ -653,6 +654,10 @@ impl UserInterface for ACPUserUI {
let content = fragment_to_content_block(fragment);
self.queue_session_update(acp::SessionUpdate::AgentMessageChunk { content });
}
+ DisplayFragment::CompactionDivider { .. } => {
+ let content = fragment_to_content_block(fragment);
+ self.queue_session_update(acp::SessionUpdate::AgentMessageChunk { content });
+ }
DisplayFragment::ThinkingText(_) => {
let content = fragment_to_content_block(fragment);
self.queue_session_update(acp::SessionUpdate::AgentThoughtChunk { content });
diff --git a/crates/code_assistant/src/agent/persistence.rs b/crates/code_assistant/src/agent/persistence.rs
index a40c8486..1f7218d5 100644
--- a/crates/code_assistant/src/agent/persistence.rs
+++ b/crates/code_assistant/src/agent/persistence.rs
@@ -118,7 +118,7 @@ impl FileStatePersistence {
);
let json = std::fs::read_to_string(&self.state_file_path)?;
let mut session: ChatSession = serde_json::from_str(&json)?;
- session.ensure_config();
+ session.ensure_config()?;
info!(
"Loaded agent state with {} messages",
diff --git a/crates/code_assistant/src/agent/runner.rs b/crates/code_assistant/src/agent/runner.rs
index 7a518509..f2f8222b 100644
--- a/crates/code_assistant/src/agent/runner.rs
+++ b/crates/code_assistant/src/agent/runner.rs
@@ -2,11 +2,12 @@ use crate::agent::persistence::AgentStatePersistence;
use crate::agent::types::ToolExecution;
use crate::config::ProjectManager;
use crate::persistence::{ChatMetadata, SessionModelConfig};
+use crate::session::instance::SessionActivityState;
use crate::session::SessionConfig;
use crate::tools::core::{ResourcesTracker, ToolContext, ToolRegistry, ToolScope};
use crate::tools::{generate_system_message, ParserRegistry, ToolRequest};
use crate::types::*;
-use crate::ui::{UiEvent, UserInterface};
+use crate::ui::{DisplayFragment, UiEvent, UserInterface};
use crate::utils::CommandExecutor;
use anyhow::Result;
use llm::{
@@ -61,6 +62,8 @@ pub struct Agent {
model_hint: Option,
// Model configuration associated with this session
session_model_config: Option,
+ // Optional override for the model's context window (primarily used in tests)
+ context_limit_override: Option,
// Counter for generating unique request IDs
next_request_id: u64,
// Session ID for this agent instance
@@ -73,6 +76,9 @@ pub struct Agent {
pending_message_ref: Option>>>,
}
+const CONTEXT_USAGE_THRESHOLD: f32 = 0.8;
+const CONTEXT_COMPACTION_PROMPT: &str = include_str!("../../resources/compaction_prompt.md");
+
impl Agent {
/// Formats an error, particularly ToolErrors, into a user-friendly string.
fn format_error_for_user(error: &anyhow::Error) -> String {
@@ -114,6 +120,7 @@ impl Agent {
tool_executions: Vec::new(),
cached_system_prompts: HashMap::new(),
session_model_config: None,
+ context_limit_override: None,
next_request_id: 1, // Start from 1
session_id: None,
session_name: String::new(),
@@ -193,6 +200,18 @@ impl Agent {
}
}
+ async fn update_activity_state(&self, new_state: SessionActivityState) -> Result<()> {
+ if let Some(session_id) = &self.session_id {
+ self.ui
+ .send_event(UiEvent::UpdateSessionActivityState {
+ session_id: session_id.clone(),
+ activity_state: new_state,
+ })
+ .await?;
+ }
+ Ok(())
+ }
+
/// Build current session metadata
fn build_current_metadata(&self) -> Option {
// Only build metadata if we have a session ID
@@ -299,6 +318,11 @@ impl Agent {
.await?;
}
+ if self.should_trigger_compaction()? {
+ self.perform_compaction().await?;
+ continue;
+ }
+
let messages = self.render_tool_results_in_messages();
// 1. Get LLM response (without adding to history yet)
@@ -408,12 +432,10 @@ impl Agent {
}
self.session_name = session_state.name;
self.session_model_config = session_state.model_config;
- if let Some(model_hint) = self
- .session_model_config
- .as_ref()
- .map(|cfg| cfg.model_name.clone())
- {
- self.set_model_hint(Some(model_hint));
+ self.context_limit_override = None;
+ if let Some(model_config) = self.session_model_config.as_mut() {
+ let model_name = model_config.model_name.clone();
+ self.set_model_hint(Some(model_name));
}
// Restore next_request_id from session, or calculate from existing messages for backward compatibility
@@ -794,6 +816,7 @@ impl Agent {
content: MessageContent::Text(text_content.trim().to_string()),
request_id: msg.request_id,
usage: msg.usage.clone(),
+ ..Default::default()
}
}
// For non-structured content, keep as is
@@ -899,6 +922,7 @@ impl Agent {
// Log messages for debugging
/*
for (i, message) in request.messages.iter().enumerate() {
+ debug!("Message {}:", i);
debug!("Message {}:", i);
// Using the Display trait implementation for Message
let formatted_message = format!("{message}");
@@ -1012,6 +1036,194 @@ impl Agent {
Ok((response, request_id))
}
+ async fn get_non_streaming_response(
+ &mut self,
+ messages: Vec,
+ ) -> Result<(llm::LLMResponse, u64)> {
+ let request_id = self.next_request_id;
+ self.next_request_id += 1;
+
+ let messages_with_reminder = self.inject_naming_reminder_if_needed(messages);
+
+ let converted_messages = match self.tool_syntax() {
+ ToolSyntax::Native => messages_with_reminder,
+ _ => self.convert_tool_results_to_text(messages_with_reminder),
+ };
+
+ let request = LLMRequest {
+ messages: converted_messages,
+ system_prompt: self.get_system_prompt(),
+ tools: match self.tool_syntax() {
+ ToolSyntax::Native => {
+ Some(crate::tools::AnnotatedToolDefinition::to_tool_definitions(
+ ToolRegistry::global().get_tool_definitions_for_scope(self.tool_scope),
+ ))
+ }
+ ToolSyntax::Xml => None,
+ ToolSyntax::Caret => None,
+ },
+ stop_sequences: None,
+ request_id,
+ session_id: self.session_id.clone().unwrap_or_default(),
+ };
+
+ let response = self.llm_provider.send_message(request, None).await?;
+
+ debug!(
+ "Compaction response usage — Input: {}, Output: {}, Cache Read: {}",
+ response.usage.input_tokens,
+ response.usage.output_tokens,
+ response.usage.cache_read_input_tokens
+ );
+
+ Ok((response, request_id))
+ }
+
+ fn format_compaction_summary_for_prompt(summary: &str) -> String {
+ let trimmed = summary.trim();
+ if trimmed.is_empty() {
+ "Conversation summary: (empty)".to_string()
+ } else {
+ format!("Conversation summary:\n{trimmed}")
+ }
+ }
+
+ fn extract_compaction_summary_text(blocks: &[ContentBlock]) -> String {
+ let mut collected = Vec::new();
+ for block in blocks {
+ match block {
+ ContentBlock::Text { text, .. } => collected.push(text.as_str()),
+ ContentBlock::Thinking { thinking, .. } => {
+ collected.push(thinking.as_str());
+ }
+ _ => {}
+ }
+ }
+
+ let merged = collected.join("\n").trim().to_string();
+ if merged.is_empty() {
+ "No summary was generated.".to_string()
+ } else {
+ merged
+ }
+ }
+
+ fn active_messages(&self) -> &[Message] {
+ if self.message_history.is_empty() {
+ return &[];
+ }
+ let start = self
+ .message_history
+ .iter()
+ .rposition(|message| message.is_compaction_summary)
+ .unwrap_or(0);
+ &self.message_history[start..]
+ }
+
+ #[cfg(test)]
+ pub fn set_test_session_metadata(
+ &mut self,
+ session_id: String,
+ model_config: SessionModelConfig,
+ ) {
+ self.session_id = Some(session_id);
+ self.session_model_config = Some(model_config);
+ }
+
+ #[cfg(test)]
+ pub fn set_test_context_limit(&mut self, limit: u32) {
+ self.context_limit_override = Some(limit);
+ }
+
+ #[cfg(test)]
+ pub fn message_history_for_tests(&self) -> &Vec {
+ &self.message_history
+ }
+
+ fn context_usage_ratio(&mut self) -> Result