diff --git a/ROADMAP.md b/ROADMAP.md index 6def3e4..ce1fa3b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -20,7 +20,7 @@ This release focuses on making Cass easier to interrupt, easier to audit, and sa - Preserve valid tool-call structure when compacting context; do not leave orphaned tool result messages. - Compact or summarize older tool outputs when needed instead of dropping records blindly. -- [ ] **Supersede old file-read outputs.** When the same file is read again in a session, avoid repeatedly sending stale large read outputs to the model. +- [x] **Supersede old file-read outputs.** When the same file is read again in a session, avoid repeatedly sending stale large read outputs to the model. - Keep the historical tool call visible in the conversation record. - Replace superseded model-context output with a short note indicating it was omitted because a newer read exists. - Be careful with partial reads: a later read of the same file does not always supersede a different line range. diff --git a/src/agent.rs b/src/agent.rs index 220e8dc..4b1f2a4 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -7,7 +7,7 @@ use crate::providers::types::ModelMessage; use crate::tools::{self, ToolContext, ToolRuntimeEvent}; use anyhow::Result; use serde_json::Value; -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use std::path::PathBuf; use tokio::sync::mpsc; @@ -247,6 +247,7 @@ fn build_messages(records: &[Record], system: String, config: &Config) -> Vec budget { @@ -288,6 +289,298 @@ fn record_to_model_message(record: &Record) -> Option { } } +fn supersede_old_read_outputs(messages: &mut [ModelMessage]) { + let read_calls = read_tool_calls_by_id(messages); + let mut read_outputs = Vec::new(); + for (message_idx, message) in messages.iter().enumerate() { + let ModelMessage::Tool { + tool_call_id, + name, + content, + } = message + else { + continue; + }; + if name != "read" { + continue; + } + let request_specs = read_calls + .get(tool_call_id) + .map(|call| read_request_specs(&call.arguments)) + .unwrap_or_default(); + let sections = parse_read_output_sections(content, &request_specs); + if !sections.is_empty() { + read_outputs.push(ReadOutputInfo { + message_idx, + sections, + }); + } + } + + for read_idx in 0..read_outputs.len() { + let superseded: Vec = read_outputs[read_idx] + .sections + .iter() + .map(|section| { + read_outputs[read_idx + 1..] + .iter() + .flat_map(|later| later.sections.iter()) + .any(|later| later.path == section.path && range_covers(later, section)) + }) + .collect(); + if !superseded.iter().any(|v| *v) { + continue; + } + + let message_idx = read_outputs[read_idx].message_idx; + let replacement = superseded_read_content( + tool_content(messages, message_idx), + &read_outputs[read_idx].sections, + &superseded, + ); + if let Some(ModelMessage::Tool { content, .. }) = messages.get_mut(message_idx) { + *content = replacement; + } + } +} + +fn tool_content(messages: &[ModelMessage], idx: usize) -> &str { + match &messages[idx] { + ModelMessage::Tool { content, .. } => content, + _ => "", + } +} + +fn read_tool_calls_by_id(messages: &[ModelMessage]) -> BTreeMap { + let mut calls = BTreeMap::new(); + for message in messages { + let ModelMessage::Assistant { tool_calls, .. } = message else { + continue; + }; + for call in tool_calls { + if call.name == "read" { + calls.insert(call.id.clone(), call.clone()); + } + } + } + calls +} + +#[derive(Debug, Clone)] +struct ReadOutputInfo { + message_idx: usize, + sections: Vec, +} + +#[derive(Debug, Clone)] +struct ReadOutputSection { + path: String, + start_line: usize, + end_line: usize, + coverage_start: usize, + coverage_end: Option, + start_byte: usize, + end_byte: usize, +} + +#[derive(Debug, Clone)] +struct ReadRequestSpec { + start_line: usize, + end_line: Option, +} + +fn parse_read_output_sections( + content: &str, + request_specs: &[ReadRequestSpec], +) -> Vec { + let mut headers = Vec::new(); + for (line_start, line) in content_lines_with_offsets(content) { + if let Some((path, header_start, header_end)) = parse_read_header(line) { + headers.push((line_start, path, header_start, header_end)); + } + } + + let mut sections = Vec::new(); + for (idx, (start_byte, path, header_start, header_end)) in headers.iter().enumerate() { + let end_byte = headers + .get(idx + 1) + .map(|(next_start, _, _, _)| *next_start) + .unwrap_or(content.len()); + let section_content = &content[*start_byte..end_byte]; + let (start_line, end_line) = + observed_read_range(section_content, *header_start, *header_end); + let (coverage_start, coverage_end) = read_coverage( + request_specs.get(idx), + start_line, + end_line, + *header_end, + section_content.contains("truncated by Cass"), + ); + sections.push(ReadOutputSection { + path: path.clone(), + start_line, + end_line, + coverage_start, + coverage_end, + start_byte: *start_byte, + end_byte, + }); + } + sections +} + +fn read_request_specs(arguments: &Value) -> Vec { + if let Some(files) = arguments.get("files").and_then(|files| files.as_array()) { + return files + .iter() + .map(|file| read_request_spec(file.get("lines").and_then(|lines| lines.as_str()))) + .collect(); + } + + if arguments.get("path").is_some() { + return vec![read_request_spec( + arguments.get("lines").and_then(|lines| lines.as_str()), + )]; + } + + Vec::new() +} + +fn read_request_spec(lines: Option<&str>) -> ReadRequestSpec { + let Some(lines) = lines.map(str::trim).filter(|lines| !lines.is_empty()) else { + return ReadRequestSpec { + start_line: 1, + end_line: None, + }; + }; + let Some((start, end)) = lines.split_once('-') else { + return ReadRequestSpec { + start_line: 1, + end_line: Some(0), + }; + }; + let start_line = if start.is_empty() { + 1 + } else { + start.parse().unwrap_or(1) + }; + let end_line = if end.is_empty() { + None + } else { + Some(end.parse().unwrap_or(0)) + }; + ReadRequestSpec { + start_line, + end_line, + } +} + +fn read_coverage( + request: Option<&ReadRequestSpec>, + observed_start: usize, + observed_end: usize, + header_end: usize, + truncated: bool, +) -> (usize, Option) { + let Some(request) = request.filter(|_| !truncated) else { + return (observed_start, Some(observed_end)); + }; + + let start = request.start_line.max(1); + let end = match request.end_line { + None => None, + Some(requested_end) if header_end < requested_end => None, + Some(requested_end) => Some(requested_end), + }; + (start, end) +} + +fn content_lines_with_offsets(content: &str) -> Vec<(usize, &str)> { + let mut out = Vec::new(); + let mut start = 0; + while start < content.len() { + let rest = &content[start..]; + let newline_offset = rest.find('\n'); + let end = newline_offset + .map(|offset| start + offset) + .unwrap_or(content.len()); + out.push((start, &content[start..end])); + start = match newline_offset { + Some(offset) => start + offset + 1, + None => content.len(), + }; + } + out +} + +fn parse_read_header(line: &str) -> Option<(String, usize, usize)> { + let inner = line.strip_prefix("--- ")?.strip_suffix(" ---")?; + let (path, range) = inner.rsplit_once(" lines ")?; + let (start, end) = range.split_once('-')?; + Some((path.to_string(), start.parse().ok()?, end.parse().ok()?)) +} + +fn observed_read_range( + section_content: &str, + header_start: usize, + header_end: usize, +) -> (usize, usize) { + let observed: Vec = content_lines_with_offsets(section_content) + .into_iter() + .skip(1) + .filter_map(|(_, line)| parse_numbered_read_line(line)) + .collect(); + match (observed.first(), observed.last()) { + (Some(start), Some(end)) => (*start, *end), + _ => (header_start, header_end), + } +} + +fn parse_numbered_read_line(line: &str) -> Option { + let (prefix, _) = line.split_once(" | ")?; + prefix.trim().parse().ok() +} + +fn range_covers(newer: &ReadOutputSection, older: &ReadOutputSection) -> bool { + if older.end_line < older.start_line { + return newer.coverage_start <= older.start_line; + } + if newer.coverage_start > older.start_line { + return false; + } + match newer.coverage_end { + Some(end) => end >= older.end_line, + None => true, + } +} + +fn superseded_read_content( + original: &str, + sections: &[ReadOutputSection], + superseded: &[bool], +) -> String { + let mut out = String::with_capacity(original.len().min(1024)); + let mut cursor = 0; + for (section, is_superseded) in sections.iter().zip(superseded.iter()) { + out.push_str(&original[cursor..section.start_byte]); + if *is_superseded { + out.push_str(&superseded_read_note(section)); + } else { + out.push_str(&original[section.start_byte..section.end_byte]); + } + cursor = section.end_byte; + } + out.push_str(&original[cursor..]); + out +} + +fn superseded_read_note(section: &ReadOutputSection) -> String { + format!( + "[Cass omitted this earlier read output for {} lines {}-{} from the model context because a newer read of the same range exists later in the conversation.]\n", + section.path, section.start_line, section.end_line + ) +} + fn context_budget_tokens(config: &Config) -> usize { let context_length = config .model_metadata @@ -566,7 +859,15 @@ mod tests { StoredToolCall { id: id.to_string(), name: "read".to_string(), - arguments: json!({"path":"src/main.rs"}), + arguments: json!({"files":[{"path":"src/main.rs"}]}), + } + } + + fn call_with_lines(id: &str, path: &str, lines: &str) -> StoredToolCall { + StoredToolCall { + id: id.to_string(), + name: "read".to_string(), + arguments: json!({"files":[{"path":path,"lines":lines}]}), } } @@ -667,6 +968,248 @@ mod tests { ); } + #[test] + fn repeated_read_of_same_range_supersedes_old_output() { + let records = vec![ + Record::Assistant { + content: String::new(), + reasoning: String::new(), + reasoning_field: None, + tool_calls: vec![call("call_1")], + ts: now_ts(), + }, + Record::Tool { + tool_call_id: "call_1".into(), + name: "read".into(), + ok: true, + content: "--- /tmp/example.rs lines 1-2 ---\n 1 | old one\n 2 | old two\n" + .into(), + ts: now_ts(), + }, + Record::Assistant { + content: String::new(), + reasoning: String::new(), + reasoning_field: None, + tool_calls: vec![call("call_2")], + ts: now_ts(), + }, + Record::Tool { + tool_call_id: "call_2".into(), + name: "read".into(), + ok: true, + content: "--- /tmp/example.rs lines 1-2 ---\n 1 | new one\n 2 | new two\n" + .into(), + ts: now_ts(), + }, + ]; + let messages = build_messages(&records, "system".into(), &Config::default()); + assert_valid_tool_structure(&messages); + + let first = messages + .iter() + .find(|message| matches!(message, ModelMessage::Tool { tool_call_id, .. } if tool_call_id == "call_1")) + .unwrap(); + assert!(matches!( + first, + ModelMessage::Tool { content, .. } + if content.contains("Cass omitted this earlier read output") + && !content.contains("old one") + )); + let second = messages + .iter() + .find(|message| matches!(message, ModelMessage::Tool { tool_call_id, .. } if tool_call_id == "call_2")) + .unwrap(); + assert!(matches!( + second, + ModelMessage::Tool { content, .. } if content.contains("new one") + )); + } + + #[test] + fn later_full_read_supersedes_old_output_even_when_file_shrinks() { + let records = vec![ + Record::Assistant { + content: String::new(), + reasoning: String::new(), + reasoning_field: None, + tool_calls: vec![call("call_1")], + ts: now_ts(), + }, + Record::Tool { + tool_call_id: "call_1".into(), + name: "read".into(), + ok: true, + content: "--- /tmp/example.rs lines 1-3 ---\n 1 | old one\n 2 | old two\n 3 | deleted\n".into(), + ts: now_ts(), + }, + Record::Assistant { + content: String::new(), + reasoning: String::new(), + reasoning_field: None, + tool_calls: vec![call("call_2")], + ts: now_ts(), + }, + Record::Tool { + tool_call_id: "call_2".into(), + name: "read".into(), + ok: true, + content: "--- /tmp/example.rs lines 1-2 ---\n 1 | new one\n 2 | new two\n".into(), + ts: now_ts(), + }, + ]; + let messages = build_messages(&records, "system".into(), &Config::default()); + + let first = messages + .iter() + .find(|message| matches!(message, ModelMessage::Tool { tool_call_id, .. } if tool_call_id == "call_1")) + .unwrap(); + assert!(matches!( + first, + ModelMessage::Tool { content, .. } + if content.contains("Cass omitted this earlier read output") + && !content.contains("deleted") + )); + } + + #[test] + fn later_partial_read_of_different_range_does_not_supersede_old_output() { + let records = vec![ + Record::Assistant { + content: String::new(), + reasoning: String::new(), + reasoning_field: None, + tool_calls: vec![call_with_lines("call_1", "src/main.rs", "1-2")], + ts: now_ts(), + }, + Record::Tool { + tool_call_id: "call_1".into(), + name: "read".into(), + ok: true, + content: "--- /tmp/example.rs lines 1-2 ---\n 1 | keep one\n 2 | keep two\n".into(), + ts: now_ts(), + }, + Record::Assistant { + content: String::new(), + reasoning: String::new(), + reasoning_field: None, + tool_calls: vec![call_with_lines("call_2", "src/main.rs", "10-12")], + ts: now_ts(), + }, + Record::Tool { + tool_call_id: "call_2".into(), + name: "read".into(), + ok: true, + content: "--- /tmp/example.rs lines 10-12 ---\n 10 | other\n 11 | lines\n 12 | here\n".into(), + ts: now_ts(), + }, + ]; + let messages = build_messages(&records, "system".into(), &Config::default()); + + let first = messages + .iter() + .find(|message| matches!(message, ModelMessage::Tool { tool_call_id, .. } if tool_call_id == "call_1")) + .unwrap(); + assert!(matches!( + first, + ModelMessage::Tool { content, .. } + if content.contains("keep one") + && !content.contains("Cass omitted this earlier read output") + )); + } + + #[test] + fn later_wider_read_supersedes_covered_partial_output() { + let records = vec![ + Record::Assistant { + content: String::new(), + reasoning: String::new(), + reasoning_field: None, + tool_calls: vec![call_with_lines("call_1", "src/main.rs", "10-12")], + ts: now_ts(), + }, + Record::Tool { + tool_call_id: "call_1".into(), + name: "read".into(), + ok: true, + content: "--- /tmp/example.rs lines 10-12 ---\n 10 | old\n 11 | old\n 12 | old\n".into(), + ts: now_ts(), + }, + Record::Assistant { + content: String::new(), + reasoning: String::new(), + reasoning_field: None, + tool_calls: vec![call_with_lines("call_2", "src/main.rs", "1-20")], + ts: now_ts(), + }, + Record::Tool { + tool_call_id: "call_2".into(), + name: "read".into(), + ok: true, + content: "--- /tmp/example.rs lines 1-20 ---\n 1 | new\n 10 | new\n 20 | new\n".into(), + ts: now_ts(), + }, + ]; + let messages = build_messages(&records, "system".into(), &Config::default()); + + let first = messages + .iter() + .find(|message| matches!(message, ModelMessage::Tool { tool_call_id, .. } if tool_call_id == "call_1")) + .unwrap(); + assert!(matches!( + first, + ModelMessage::Tool { content, .. } + if content.contains("Cass omitted this earlier read output") + && !content.contains("old") + )); + } + + #[test] + fn multi_file_read_only_replaces_superseded_sections() { + let records = vec![ + Record::Assistant { + content: String::new(), + reasoning: String::new(), + reasoning_field: None, + tool_calls: vec![call("call_1")], + ts: now_ts(), + }, + Record::Tool { + tool_call_id: "call_1".into(), + name: "read".into(), + ok: true, + content: "--- /tmp/a.rs lines 1-1 ---\n 1 | stale\n--- /tmp/b.rs lines 1-1 ---\n 1 | keep\n".into(), + ts: now_ts(), + }, + Record::Assistant { + content: String::new(), + reasoning: String::new(), + reasoning_field: None, + tool_calls: vec![call("call_2")], + ts: now_ts(), + }, + Record::Tool { + tool_call_id: "call_2".into(), + name: "read".into(), + ok: true, + content: "--- /tmp/a.rs lines 1-1 ---\n 1 | fresh\n".into(), + ts: now_ts(), + }, + ]; + let messages = build_messages(&records, "system".into(), &Config::default()); + + let first = messages + .iter() + .find(|message| matches!(message, ModelMessage::Tool { tool_call_id, .. } if tool_call_id == "call_1")) + .unwrap(); + assert!(matches!( + first, + ModelMessage::Tool { content, .. } + if content.contains("Cass omitted this earlier read output") + && !content.contains("stale") + && content.contains("keep") + )); + } + #[test] fn invalid_leading_tool_results_are_removed() { let messages = sanitize_tool_message_structure(vec![