Compare commits

..

2 Commits

11 changed files with 372 additions and 199 deletions

View File

@ -853,121 +853,6 @@ impl AgentLoop {
&self.tools &self.tools
} }
/// Sanitize message history by removing assistant messages with tool_calls
/// that don't have corresponding tool result messages, at ANY position in
/// the history (not just trailing).
///
/// Incomplete sequences can appear in the middle of history when:
/// 1. The process was interrupted mid-execution (before commit cb58d9f),
/// then a new user message was appended, burying the orphan.
/// 2. History compaction preserves orphaned tool_calls from a pre-fix era
/// or from a race condition between persistence and snapshot.
///
/// Sending such incomplete sequences to the API causes errors like
/// "insufficient tool messages following tool_calls message".
///
/// Returns the number of messages removed.
fn sanitize_incomplete_tool_call_sequences(messages: &mut Vec<ChatMessage>) -> usize {
use std::collections::HashSet;
let mut removed = 0;
// Phase 1: Single reverse pass to find ALL assistant messages with
// incomplete tool_calls, regardless of position.
//
// Scanning right-to-left means we encounter tool results before their
// parent assistants, so we naturally know which tool_call_ids have
// corresponding results.
let mut resolved_ids: HashSet<String> = HashSet::new();
let mut with_parent: HashSet<String> = HashSet::new();
let mut remove_indices: Vec<usize> = Vec::new();
// Phase 1: Reverse pass — collect tool result IDs first, then
// validate each assistant's tool_calls against already-seen results.
//
// Because we scan right-to-left, any tool result we've already seen
// appears AFTER the current message in forward order. This correctly
// identifies which assistant tool_calls have corresponding results.
for i in (0..messages.len()).rev() {
let msg = &messages[i];
if msg.role == "tool" {
if let Some(ref tc_id) = msg.tool_call_id {
resolved_ids.insert(tc_id.clone());
}
}
if msg.role == "assistant"
&& msg.tool_calls.as_ref().map_or(false, |calls| !calls.is_empty())
{
let tool_calls = msg.tool_calls.as_ref().unwrap();
let all_have_results = tool_calls
.iter()
.all(|tc| resolved_ids.contains(&tc.id));
if all_have_results {
for tc in tool_calls.iter() {
with_parent.insert(tc.id.clone());
}
} else {
let missing_count = tool_calls
.iter()
.filter(|tc| !resolved_ids.contains(&tc.id))
.count();
tracing::warn!(
tool_call_count = tool_calls.len(),
missing_tool_results = missing_count,
message_id = %msg.id,
message_index = i,
"Removing assistant message with incomplete tool call sequence — \
tool results were never persisted (likely due to process interruption \
or history compaction preserving an orphan)"
);
remove_indices.push(i);
}
}
}
// Remove in descending index order to avoid shifting
for &idx in &remove_indices {
messages.remove(idx);
removed += 1;
}
// Phase 2: Forward pass to remove ALL orphaned tool messages (not just
// trailing ones). A tool message is orphaned if its tool_call_id has no
// matching parent assistant remaining in the history.
if !with_parent.is_empty() || !resolved_ids.is_empty() {
let mut i = 0;
while i < messages.len() {
let msg = &messages[i];
if msg.role == "tool" {
let is_orphaned = match &msg.tool_call_id {
Some(tc_id) => !with_parent.contains(tc_id),
None => true,
};
if is_orphaned {
tracing::warn!(
tool_call_id = ?msg.tool_call_id,
message_id = %msg.id,
message_index = i,
"Removing orphaned tool result message — its parent assistant \
tool_calls message was removed or never persisted"
);
messages.remove(i);
removed += 1;
continue;
}
}
i += 1;
}
}
removed
}
/// Process a message using the provided conversation history. /// Process a message using the provided conversation history.
/// History management is handled externally by SessionManager. /// History management is handled externally by SessionManager.
/// ///
@ -993,7 +878,7 @@ impl AgentLoop {
// Sanitize: remove any trailing incomplete tool call sequences // Sanitize: remove any trailing incomplete tool call sequences
// that may have been persisted before a process interruption. // that may have been persisted before a process interruption.
Self::sanitize_incomplete_tool_call_sequences(&mut messages); crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
// Track tool calls for loop detection // Track tool calls for loop detection
let mut loop_detector = LoopDetector::new(LoopDetectorConfig::default()); let mut loop_detector = LoopDetector::new(LoopDetectorConfig::default());
@ -1563,8 +1448,8 @@ mod tests {
context_window_tokens: None, context_window_tokens: None,
model_extra: std::collections::HashMap::new(), model_extra: std::collections::HashMap::new(),
max_tool_iterations: 1, max_tool_iterations: 1,
tool_result_max_chars: 20_000, tool_result_max_chars: 100_000,
context_tool_result_trim_chars: 20_000, context_tool_result_trim_chars: 100_000,
max_images_in_context: 1, max_images_in_context: 1,
max_image_age_rounds: 10, max_image_age_rounds: 10,
} }
@ -1669,9 +1554,9 @@ mod tests {
#[test] #[test]
fn test_truncate_tool_result_handles_utf8_char_boundaries() { fn test_truncate_tool_result_handles_utf8_char_boundaries() {
let input = "".repeat(20_500); let input = "".repeat(100_500);
let output = truncate_tool_result(&input, 20_000); let output = truncate_tool_result(&input, 100_000);
assert!(output.contains("Output truncated")); assert!(output.contains("Output truncated"));
assert!(output.is_char_boundary(output.len())); assert!(output.is_char_boundary(output.len()));
@ -1936,7 +1821,7 @@ mod tests {
// Tool result for call_1 is MISSING — incomplete sequence // Tool result for call_1 is MISSING — incomplete sequence
]; ];
let removed = AgentLoop::sanitize_incomplete_tool_call_sequences(&mut messages); let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
assert_eq!(removed, 1); assert_eq!(removed, 1);
assert_eq!(messages.len(), 1); assert_eq!(messages.len(), 1);
assert_eq!(messages[0].role, "user"); assert_eq!(messages[0].role, "user");
@ -1957,7 +1842,7 @@ mod tests {
ChatMessage::tool("call_1", "calculator", "2"), ChatMessage::tool("call_1", "calculator", "2"),
]; ];
let removed = AgentLoop::sanitize_incomplete_tool_call_sequences(&mut messages); let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
assert_eq!(removed, 0); assert_eq!(removed, 0);
assert_eq!(messages.len(), 3); assert_eq!(messages.len(), 3);
} }
@ -1987,7 +1872,7 @@ mod tests {
// Also missing tool result for call_2 // Also missing tool result for call_2
]; ];
let removed = AgentLoop::sanitize_incomplete_tool_call_sequences(&mut messages); let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
// Should remove both trailing assistant messages with incomplete tool calls // Should remove both trailing assistant messages with incomplete tool calls
assert_eq!(removed, 2); assert_eq!(removed, 2);
assert_eq!(messages.len(), 2); assert_eq!(messages.len(), 2);
@ -2021,7 +1906,7 @@ mod tests {
// Missing tool result for call_2 // Missing tool result for call_2
]; ];
let removed_count = AgentLoop::sanitize_incomplete_tool_call_sequences(&mut messages); let removed_count = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
// Phase 1 removes the assistant message (call_2 has no result). // Phase 1 removes the assistant message (call_2 has no result).
// Phase 2 removes the orphaned tool result for call_1 (its parent // Phase 2 removes the orphaned tool result for call_1 (its parent
// assistant was removed). // assistant was removed).
@ -2038,7 +1923,7 @@ mod tests {
ChatMessage::user("how are you"), ChatMessage::user("how are you"),
]; ];
let removed = AgentLoop::sanitize_incomplete_tool_call_sequences(&mut messages); let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
assert_eq!(removed, 0); assert_eq!(removed, 0);
assert_eq!(messages.len(), 3); assert_eq!(messages.len(), 3);
} }
@ -2046,7 +1931,7 @@ mod tests {
#[test] #[test]
fn test_sanitize_handles_empty_messages() { fn test_sanitize_handles_empty_messages() {
let mut messages: Vec<ChatMessage> = vec![]; let mut messages: Vec<ChatMessage> = vec![];
let removed = AgentLoop::sanitize_incomplete_tool_call_sequences(&mut messages); let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
assert_eq!(removed, 0); assert_eq!(removed, 0);
} }
@ -2058,7 +1943,7 @@ mod tests {
ChatMessage::tool("call_1", "calculator", "2"), ChatMessage::tool("call_1", "calculator", "2"),
]; ];
let removed = AgentLoop::sanitize_incomplete_tool_call_sequences(&mut messages); let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
assert_eq!(removed, 1); assert_eq!(removed, 1);
assert!(messages.is_empty()); assert!(messages.is_empty());
} }
@ -2086,7 +1971,7 @@ mod tests {
ChatMessage::tool("call_2", "read", "contents of README"), ChatMessage::tool("call_2", "read", "contents of README"),
]; ];
let removed = AgentLoop::sanitize_incomplete_tool_call_sequences(&mut messages); let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
assert_eq!(removed, 0); assert_eq!(removed, 0);
assert_eq!(messages.len(), 4); assert_eq!(messages.len(), 4);
} }
@ -2119,7 +2004,7 @@ mod tests {
// Missing tool result for call_2 — only THIS sequence should be trimmed // Missing tool result for call_2 — only THIS sequence should be trimmed
]; ];
let removed = AgentLoop::sanitize_incomplete_tool_call_sequences(&mut messages); let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
assert_eq!(removed, 1); assert_eq!(removed, 1);
// First complete sequence preserved (5 messages), user message for second // First complete sequence preserved (5 messages), user message for second
// question preserved // question preserved
@ -2157,7 +2042,7 @@ mod tests {
ChatMessage::tool("call_2", "read", "file contents"), ChatMessage::tool("call_2", "read", "file contents"),
]; ];
let removed = AgentLoop::sanitize_incomplete_tool_call_sequences(&mut messages); let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
// call_1 assistant removed (1), rest preserved (4) // call_1 assistant removed (1), rest preserved (4)
assert_eq!(removed, 1); assert_eq!(removed, 1);
assert_eq!(messages.len(), 4); assert_eq!(messages.len(), 4);
@ -2197,7 +2082,7 @@ mod tests {
ChatMessage::user("third"), ChatMessage::user("third"),
]; ];
let removed = AgentLoop::sanitize_incomplete_tool_call_sequences(&mut messages); let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
assert_eq!(removed, 2); assert_eq!(removed, 2);
assert_eq!(messages.len(), 3); assert_eq!(messages.len(), 3);
assert_eq!(messages[0].content, "first"); assert_eq!(messages[0].content, "first");
@ -2242,7 +2127,7 @@ mod tests {
ChatMessage::tool("call_valid", "good_tool", "valid result"), ChatMessage::tool("call_valid", "good_tool", "valid result"),
]; ];
let removed = AgentLoop::sanitize_incomplete_tool_call_sequences(&mut messages); let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
// call_has_result/call_no_result assistant (1) + its orphaned tool result (1) = 2 removed // call_has_result/call_no_result assistant (1) + its orphaned tool result (1) = 2 removed
assert_eq!(removed, 2); assert_eq!(removed, 2);
assert_eq!(messages.len(), 4); assert_eq!(messages.len(), 4);
@ -2304,7 +2189,7 @@ mod tests {
ChatMessage::assistant("task 3 is done"), ChatMessage::assistant("task 3 is done"),
]; ];
let removed = AgentLoop::sanitize_incomplete_tool_call_sequences(&mut messages); let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
// Removed: assistant with t2_call_1/t2_call_2 (1 message) // Removed: assistant with t2_call_1/t2_call_2 (1 message)
assert_eq!(removed, 1); assert_eq!(removed, 1);
// Original 10 messages - 1 = 9 // Original 10 messages - 1 = 9

View File

@ -1,5 +1,5 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::{HashMap, HashSet};
use crate::domain::messages::ToolCall; use crate::domain::messages::ToolCall;
@ -237,6 +237,123 @@ impl ChatMessage {
} }
} }
// ============================================================================
// Message sanitization
// ============================================================================
/// Sanitize message history by removing assistant messages with `tool_calls`
/// that don't have corresponding tool result messages, at ANY position in
/// the history (not just trailing).
///
/// Incomplete sequences can appear in the middle of history when:
/// 1. The process was interrupted mid-execution, then a new user message
/// was appended, burying the orphan.
/// 2. History compaction preserves orphaned `tool_calls` from a pre-fix era
/// or from a race condition between persistence and snapshot.
///
/// Sending such incomplete sequences to the API causes errors like
/// "insufficient tool messages following tool_calls message".
///
/// Returns the number of messages removed.
pub(crate) fn sanitize_incomplete_tool_call_sequences(messages: &mut Vec<ChatMessage>) -> usize {
let mut removed = 0;
// Phase 1: Single reverse pass to find ALL assistant messages with
// incomplete tool_calls, regardless of position.
//
// Scanning right-to-left means we encounter tool results before their
// parent assistants, so we naturally know which tool_call_ids have
// corresponding results.
let mut resolved_ids: HashSet<String> = HashSet::new();
let mut with_parent: HashSet<String> = HashSet::new();
let mut remove_indices: Vec<usize> = Vec::new();
// Reverse pass — collect tool result IDs first, then validate each
// assistant's tool_calls against already-seen results.
//
// Because we scan right-to-left, any tool result we've already seen
// appears AFTER the current message in forward order. This correctly
// identifies which assistant tool_calls have corresponding results.
for i in (0..messages.len()).rev() {
let msg = &messages[i];
if msg.role == "tool" {
if let Some(ref tc_id) = msg.tool_call_id {
resolved_ids.insert(tc_id.clone());
}
}
if msg.role == "assistant"
&& msg.tool_calls.as_ref().map_or(false, |calls| !calls.is_empty())
{
let tool_calls = msg.tool_calls.as_ref().unwrap();
let all_have_results = tool_calls
.iter()
.all(|tc| resolved_ids.contains(&tc.id));
if all_have_results {
for tc in tool_calls.iter() {
with_parent.insert(tc.id.clone());
}
} else {
let missing_count = tool_calls
.iter()
.filter(|tc| !resolved_ids.contains(&tc.id))
.count();
tracing::warn!(
tool_call_count = tool_calls.len(),
missing_tool_results = missing_count,
message_id = %msg.id,
message_index = i,
"Removing assistant message with incomplete tool call sequence — \
tool results were never persisted (likely due to process interruption \
or history compaction preserving an orphan)"
);
remove_indices.push(i);
}
}
}
// Remove in descending index order to avoid shifting
for &idx in &remove_indices {
messages.remove(idx);
removed += 1;
}
// Phase 2: Forward pass to remove ALL orphaned tool messages (not just
// trailing ones). A tool message is orphaned if its tool_call_id has no
// matching parent assistant remaining in the history.
if !with_parent.is_empty() || !resolved_ids.is_empty() {
let mut i = 0;
while i < messages.len() {
let msg = &messages[i];
if msg.role == "tool" {
let is_orphaned = match &msg.tool_call_id {
Some(tc_id) => !with_parent.contains(tc_id),
None => true,
};
if is_orphaned {
tracing::warn!(
tool_call_id = ?msg.tool_call_id,
message_id = %msg.id,
message_index = i,
"Removing orphaned tool result message — its parent assistant \
tool_calls message was removed or never persisted"
);
messages.remove(i);
removed += 1;
continue;
}
}
i += 1;
}
}
removed
}
// ============================================================================ // ============================================================================
// InboundMessage - Message from Channel to Bus (user input) // InboundMessage - Message from Channel to Bus (user input)
// ============================================================================ // ============================================================================

View File

@ -555,7 +555,7 @@ impl InitWizard {
provider: selected_provider.clone(), provider: selected_provider.clone(),
model: selected_model.clone(), model: selected_model.clone(),
max_tool_iterations: 100, max_tool_iterations: 100,
tool_result_max_chars: 20000, tool_result_max_chars: 100_000,
context_tool_result_trim_chars: 2000, context_tool_result_trim_chars: 2000,
}; };

View File

@ -438,7 +438,7 @@ fn default_max_tool_iterations() -> usize {
} }
fn default_tool_result_max_chars() -> usize { fn default_tool_result_max_chars() -> usize {
20_000 100_000
} }
fn default_context_tool_result_trim_chars() -> usize { fn default_context_tool_result_trim_chars() -> usize {
@ -1077,7 +1077,7 @@ mod tests {
assert_eq!(provider_config.temperature, Some(0.0)); assert_eq!(provider_config.temperature, Some(0.0));
assert_eq!(provider_config.max_tokens, None); assert_eq!(provider_config.max_tokens, None);
assert_eq!(provider_config.llm_timeout_secs, 120); assert_eq!(provider_config.llm_timeout_secs, 120);
assert_eq!(provider_config.tool_result_max_chars, 20_000); assert_eq!(provider_config.tool_result_max_chars, 100_000);
assert_eq!(provider_config.context_tool_result_trim_chars, 2_000); assert_eq!(provider_config.context_tool_result_trim_chars, 2_000);
assert_eq!(provider_config.context_summary_char_budget(), 32_000); assert_eq!(provider_config.context_summary_char_budget(), 32_000);
} }
@ -1322,7 +1322,7 @@ mod tests {
let config = Config::load(file.path().to_str().unwrap()).unwrap(); let config = Config::load(file.path().to_str().unwrap()).unwrap();
assert_eq!(config.agents["default"].max_tool_iterations, 100); assert_eq!(config.agents["default"].max_tool_iterations, 100);
assert_eq!(config.agents["default"].tool_result_max_chars, 20_000); assert_eq!(config.agents["default"].tool_result_max_chars, 100_000);
assert_eq!( assert_eq!(
config.agents["default"].context_tool_result_trim_chars, config.agents["default"].context_tool_result_trim_chars,
2_000 2_000

View File

@ -121,7 +121,7 @@ mod tests {
context_window_tokens: None, context_window_tokens: None,
model_extra: HashMap::new(), model_extra: HashMap::new(),
max_tool_iterations: 1, max_tool_iterations: 1,
tool_result_max_chars: 20_000, tool_result_max_chars: 100_000,
context_tool_result_trim_chars: 20_000, context_tool_result_trim_chars: 20_000,
max_images_in_context: 1, max_images_in_context: 1,
max_image_age_rounds: 10, max_image_age_rounds: 10,

View File

@ -65,7 +65,7 @@ mod tests {
context_window_tokens: None, context_window_tokens: None,
model_extra: HashMap::new(), model_extra: HashMap::new(),
max_tool_iterations: 1, max_tool_iterations: 1,
tool_result_max_chars: 20_000, tool_result_max_chars: 100_000,
context_tool_result_trim_chars: 20_000, context_tool_result_trim_chars: 20_000,
max_images_in_context: 1, max_images_in_context: 1,
max_image_age_rounds: 10, max_image_age_rounds: 10,

View File

@ -742,8 +742,8 @@ mod tests {
context_window_tokens: None, context_window_tokens: None,
model_extra: HashMap::new(), model_extra: HashMap::new(),
max_tool_iterations: 1, max_tool_iterations: 1,
tool_result_max_chars: 20_000, tool_result_max_chars: 100_000,
context_tool_result_trim_chars: 20_000, context_tool_result_trim_chars: 100_000,
max_images_in_context: 1, max_images_in_context: 1,
max_image_age_rounds: 10, max_image_age_rounds: 10,
} }
@ -1004,8 +1004,8 @@ mod tests {
max_tool_iterations: 1, max_tool_iterations: 1,
llm_timeout_secs: 30, llm_timeout_secs: 30,
memory_maintenance_timeout_secs: 600, memory_maintenance_timeout_secs: 600,
tool_result_max_chars: 20_000, tool_result_max_chars: 100_000,
context_tool_result_trim_chars: 20_000, context_tool_result_trim_chars: 100_000,
max_images_in_context: 1, max_images_in_context: 1,
max_image_age_rounds: 10, max_image_age_rounds: 10,
}; };
@ -1052,8 +1052,8 @@ mod tests {
max_tool_iterations: 1, max_tool_iterations: 1,
llm_timeout_secs: 30, llm_timeout_secs: 30,
memory_maintenance_timeout_secs: 600, memory_maintenance_timeout_secs: 600,
tool_result_max_chars: 20_000, tool_result_max_chars: 100_000,
context_tool_result_trim_chars: 20_000, context_tool_result_trim_chars: 100_000,
max_images_in_context: 1, max_images_in_context: 1,
max_image_age_rounds: 10, max_image_age_rounds: 10,
}; };
@ -1130,8 +1130,8 @@ mod tests {
max_tool_iterations: 1, max_tool_iterations: 1,
llm_timeout_secs: 30, llm_timeout_secs: 30,
memory_maintenance_timeout_secs: 600, memory_maintenance_timeout_secs: 600,
tool_result_max_chars: 20_000, tool_result_max_chars: 100_000,
context_tool_result_trim_chars: 20_000, context_tool_result_trim_chars: 100_000,
max_images_in_context: 1, max_images_in_context: 1,
max_image_age_rounds: 10, max_image_age_rounds: 10,
}; };
@ -1218,8 +1218,8 @@ mod tests {
max_tool_iterations: 1, max_tool_iterations: 1,
llm_timeout_secs: 30, llm_timeout_secs: 30,
memory_maintenance_timeout_secs: 600, memory_maintenance_timeout_secs: 600,
tool_result_max_chars: 20_000, tool_result_max_chars: 100_000,
context_tool_result_trim_chars: 20_000, context_tool_result_trim_chars: 100_000,
max_images_in_context: 1, max_images_in_context: 1,
max_image_age_rounds: 10, max_image_age_rounds: 10,
}; };
@ -1308,8 +1308,8 @@ mod tests {
max_tool_iterations: 1, max_tool_iterations: 1,
llm_timeout_secs: 1, llm_timeout_secs: 1,
memory_maintenance_timeout_secs: 600, memory_maintenance_timeout_secs: 600,
tool_result_max_chars: 20_000, tool_result_max_chars: 100_000,
context_tool_result_trim_chars: 20_000, context_tool_result_trim_chars: 100_000,
max_images_in_context: 1, max_images_in_context: 1,
max_image_age_rounds: 10, max_image_age_rounds: 10,
}; };
@ -1397,8 +1397,8 @@ mod tests {
max_tool_iterations: 1, max_tool_iterations: 1,
llm_timeout_secs: 30, llm_timeout_secs: 30,
memory_maintenance_timeout_secs: 600, memory_maintenance_timeout_secs: 600,
tool_result_max_chars: 20_000, tool_result_max_chars: 100_000,
context_tool_result_trim_chars: 20_000, context_tool_result_trim_chars: 100_000,
max_images_in_context: 1, max_images_in_context: 1,
max_image_age_rounds: 10, max_image_age_rounds: 10,
}; };
@ -1468,8 +1468,8 @@ mod tests {
max_tool_iterations: 1, max_tool_iterations: 1,
llm_timeout_secs: 30, llm_timeout_secs: 30,
memory_maintenance_timeout_secs: 600, memory_maintenance_timeout_secs: 600,
tool_result_max_chars: 20_000, tool_result_max_chars: 100_000,
context_tool_result_trim_chars: 20_000, context_tool_result_trim_chars: 100_000,
max_images_in_context: 1, max_images_in_context: 1,
max_image_age_rounds: 10, max_image_age_rounds: 10,
}; };
@ -1548,8 +1548,8 @@ mod tests {
max_tool_iterations: 1, max_tool_iterations: 1,
llm_timeout_secs: 30, llm_timeout_secs: 30,
memory_maintenance_timeout_secs: 600, memory_maintenance_timeout_secs: 600,
tool_result_max_chars: 20_000, tool_result_max_chars: 100_000,
context_tool_result_trim_chars: 20_000, context_tool_result_trim_chars: 100_000,
max_images_in_context: 1, max_images_in_context: 1,
max_image_age_rounds: 10, max_image_age_rounds: 10,
}; };
@ -1615,8 +1615,8 @@ mod tests {
max_tool_iterations: 1, max_tool_iterations: 1,
llm_timeout_secs: 1, llm_timeout_secs: 1,
memory_maintenance_timeout_secs: 600, memory_maintenance_timeout_secs: 600,
tool_result_max_chars: 20_000, tool_result_max_chars: 100_000,
context_tool_result_trim_chars: 20_000, context_tool_result_trim_chars: 100_000,
max_images_in_context: 1, max_images_in_context: 1,
max_image_age_rounds: 10, max_image_age_rounds: 10,
}; };

View File

@ -729,29 +729,45 @@ impl SessionStore {
let delta_messages = let delta_messages =
load_messages_between(&tx, session_id, snapshot_end_seq, current_max_seq)?; load_messages_between(&tx, session_id, snapshot_end_seq, current_max_seq)?;
let mut next_seq = current_max_seq + 1;
let now = current_timestamp(); let now = current_timestamp();
// Collect all new messages first, then sanitize incomplete tool call
// sequences before writing to DB. This prevents orphaned tool_calls
// (without corresponding tool results) from being persisted permanently
// when compaction preserves an incomplete sequence from the snapshot or
// captures a partial sequence from delta messages.
let mut new_messages: Vec<ChatMessage> = Vec::new();
for message in preserved_system_messages {
new_messages.push(clone_message_for_compaction(message, message.timestamp));
}
new_messages.push(clone_message_for_compaction(summary_message, now));
for message in preserved_messages.iter().chain(delta_messages.iter()) {
new_messages.push(clone_message_for_compaction(message, message.timestamp));
}
let removed =
crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut new_messages);
if removed > 0 {
tracing::warn!(
removed_count = removed,
session_id = %session_id,
"Compaction removed incomplete tool call sequences from new history"
);
}
// Write sanitized messages to DB
let mut next_seq = current_max_seq + 1;
let mut inserted_count = 0_i64; let mut inserted_count = 0_i64;
let mut active_user_turn_count = 0_i64; let mut active_user_turn_count = 0_i64;
for message in preserved_system_messages { for message in &new_messages {
let copied = clone_message_for_compaction(message, message.timestamp); if message.role == "user" {
insert_message_with_seq(&tx, session_id, next_seq, &copied)?;
next_seq += 1;
inserted_count += 1;
}
let summary_copy = clone_message_for_compaction(summary_message, now);
insert_message_with_seq(&tx, session_id, next_seq, &summary_copy)?;
next_seq += 1;
inserted_count += 1;
for message in preserved_messages.iter().chain(delta_messages.iter()) {
let copied = clone_message_for_compaction(message, message.timestamp);
if copied.role == "user" {
active_user_turn_count += 1; active_user_turn_count += 1;
} }
insert_message_with_seq(&tx, session_id, next_seq, &copied)?; insert_message_with_seq(&tx, session_id, next_seq, message)?;
next_seq += 1; next_seq += 1;
inserted_count += 1; inserted_count += 1;
} }

View File

@ -7,7 +7,7 @@ use crate::text::take_prefix_chars;
use crate::tools::traits::{Tool, ToolResult}; use crate::tools::traits::{Tool, ToolResult};
use crate::tools::extract_u64; use crate::tools::extract_u64;
const MAX_CHARS: usize = 128_000; const MAX_CHARS: usize = 100_000;
const DEFAULT_LIMIT: usize = 2000; const DEFAULT_LIMIT: usize = 2000;
pub struct FileReadTool { pub struct FileReadTool {

View File

@ -379,11 +379,6 @@ export function MessageBubble({ message, onNavigateToSubAgent }: MessageBubblePr
</button> </button>
</div> </div>
)} )}
{!taskResult && !isTaskTool && !hasResult && (
<div className="px-3 pb-2 text-xs text-zinc-500">
...
</div>
)}
{isTaskTool && !taskResult && !message.subagentTaskId && ( {isTaskTool && !taskResult && !message.subagentTaskId && (
<div className="px-3 pb-2 text-xs text-zinc-500"> <div className="px-3 pb-2 text-xs text-zinc-500">
... ...
@ -455,9 +450,6 @@ export function MessageBubble({ message, onNavigateToSubAgent }: MessageBubblePr
</pre> </pre>
</div> </div>
)} )}
{!hasArgs && !hasResult && (
<div className="text-xs text-zinc-500">...</div>
)}
{isTaskTool && message.subagentTaskId && ( {isTaskTool && message.subagentTaskId && (
<button <button
onClick={(e) => { onClick={(e) => {

View File

@ -1,7 +1,7 @@
import { useEffect, useRef } from 'react' import { useEffect, useRef, useState, useCallback } from 'react'
import { MessageBubble } from './MessageBubble' import { MessageBubble } from './MessageBubble'
import type { ChatMessage } from '../../types/protocol' import type { ChatMessage } from '../../types/protocol'
import { Sparkles } from 'lucide-react' import { Sparkles, ArrowDown, ArrowUp } from 'lucide-react'
interface MessageListProps { interface MessageListProps {
messages: ChatMessage[] messages: ChatMessage[]
@ -11,12 +11,121 @@ interface MessageListProps {
export function MessageList({ messages, onNavigateToSubAgent }: MessageListProps) { export function MessageList({ messages, onNavigateToSubAgent }: MessageListProps) {
const bottomRef = useRef<HTMLDivElement>(null) const bottomRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const isStickyRef = useRef(true)
const prevShowBottomRef = useRef(false)
const prevShowTopRef = useRef(false)
const lastMessageCountRef = useRef(0)
const isProgrammaticScrollRef = useRef(false)
const scrollTimerRef = useRef<number>(0)
const [showScrollToBottom, setShowScrollToBottom] = useState(false)
const [showScrollToTop, setShowScrollToTop] = useState(false)
const [hasNewMessage, setHasNewMessage] = useState(false)
// ---- scroll handlers ----
const handleScroll = useCallback(() => {
const el = containerRef.current
if (!el) return
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight
const nearBottom = distanceFromBottom < 120
isStickyRef.current = nearBottom
// 编程式滚动进行中(按钮点击触发),跳过按钮状态更新,避免闪烁
if (isProgrammaticScrollRef.current) return
// 回到顶部按钮:滚过 0.8 个视口时显示
const shouldShowTop = el.scrollTop > el.clientHeight * 0.8
if (shouldShowTop !== prevShowTopRef.current) {
prevShowTopRef.current = shouldShowTop
setShowScrollToTop(shouldShowTop)
}
// 回到底部按钮:离开底部时显示
const shouldShowBottom = !nearBottom
if (shouldShowBottom !== prevShowBottomRef.current) {
prevShowBottomRef.current = shouldShowBottom
setShowScrollToBottom(shouldShowBottom)
}
// 用户手动滚回底部时清除"有新消息"标记
if (nearBottom && hasNewMessage) {
setHasNewMessage(false)
}
}, [hasNewMessage])
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => {
isStickyRef.current = true
prevShowBottomRef.current = false
setShowScrollToBottom(false)
setHasNewMessage(false)
isProgrammaticScrollRef.current = true
clearTimeout(scrollTimerRef.current)
scrollTimerRef.current = setTimeout(() => {
isProgrammaticScrollRef.current = false
}, 500)
bottomRef.current?.scrollIntoView({ behavior })
}, [])
const scrollToTop = useCallback(() => {
isProgrammaticScrollRef.current = true
clearTimeout(scrollTimerRef.current)
scrollTimerRef.current = setTimeout(() => {
isProgrammaticScrollRef.current = false
}, 500)
containerRef.current?.scrollTo({ top: 0, behavior: 'smooth' })
}, [])
// ---- auto-scroll effect ----
useEffect(() => { useEffect(() => {
if (bottomRef.current) { if (messages.length === 0) return
bottomRef.current.scrollIntoView({ behavior: 'smooth' })
const lastMessage = messages[messages.length - 1]
// 用户自己发的消息 → 始终滚到底部
if (lastMessage.role === 'user') {
scrollToBottom('instant')
return
} }
}, [messages])
// 用户在底部 → 自动跟随新消息
if (isStickyRef.current && bottomRef.current) {
bottomRef.current.scrollIntoView({ behavior: 'instant' })
}
// 用户不在底部且有新消息 → 标记未读
if (!isStickyRef.current && messages.length > lastMessageCountRef.current) {
setHasNewMessage(true)
}
lastMessageCountRef.current = messages.length
}, [messages, scrollToBottom])
// ---- ResizeObserver: 窗口大小变化时保持底部对齐 ----
useEffect(() => {
const el = containerRef.current
if (!el) return
const observer = new ResizeObserver(() => {
if (isStickyRef.current && bottomRef.current) {
bottomRef.current.scrollIntoView({ behavior: 'instant' })
}
})
observer.observe(el)
return () => observer.disconnect()
}, [])
// ---- 清理定时器 ----
useEffect(() => {
return () => clearTimeout(scrollTimerRef.current)
}, [])
// ---- empty state ----
if (messages.length === 0) { if (messages.length === 0) {
return ( return (
@ -36,15 +145,69 @@ export function MessageList({ messages, onNavigateToSubAgent }: MessageListProps
) )
} }
// ---- main render ----
return ( return (
<div <div className="relative h-full">
ref={containerRef} <div
className="h-full overflow-y-auto p-6 space-y-6" ref={containerRef}
> onScroll={handleScroll}
{messages.map((message) => ( className="h-full overflow-y-auto p-6 space-y-6 scroll-smooth"
<MessageBubble key={message.id} message={message} onNavigateToSubAgent={onNavigateToSubAgent} /> >
))} {messages.map((message) => (
<div ref={bottomRef} /> <MessageBubble key={message.id} message={message} onNavigateToSubAgent={onNavigateToSubAgent} />
))}
<div ref={bottomRef} />
</div>
{/* 浮动导航按钮组 */}
<div className="absolute right-4 bottom-4 z-10 flex flex-col items-center gap-2 pointer-events-none">
{/* 回到顶部 */}
{showScrollToTop && (
<button
onClick={scrollToTop}
className="pointer-events-auto group relative flex items-center justify-center
w-9 h-9 rounded-full
bg-[#1a1a25]/80 backdrop-blur-md
border border-white/[0.06]
text-zinc-500 hover:text-[#00f0ff]
hover:border-[#00f0ff]/30
hover:shadow-[0_0_20px_rgba(0,240,255,0.12)]
transition-all duration-300 ease-out
animate-fade-in"
aria-label="回到顶部"
>
<ArrowUp className="h-4 w-4 transition-transform duration-300 group-hover:-translate-y-0.5" />
</button>
)}
{/* 回到底部 */}
{showScrollToBottom && (
<button
onClick={() => scrollToBottom('smooth')}
className="pointer-events-auto group relative flex items-center justify-center
w-9 h-9 rounded-full
bg-[#1a1a25]/80 backdrop-blur-md
border border-white/[0.06]
text-zinc-500 hover:text-[#00f0ff]
hover:border-[#00f0ff]/30
hover:shadow-[0_0_20px_rgba(0,240,255,0.12)]
transition-all duration-300 ease-out
animate-fade-in"
aria-label="回到底部"
>
<ArrowDown className={`h-4 w-4 transition-transform duration-300 group-hover:translate-y-0.5 ${hasNewMessage ? 'animate-bounce' : ''}`} />
{/* 未读消息指示点 */}
{hasNewMessage && (
<span className="absolute -top-0.5 -right-0.5 flex h-2.5 w-2.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-[#00f0ff]/60"></span>
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-[#00f0ff]"></span>
</span>
)}
</button>
)}
</div>
</div> </div>
) )
} }