Supersede old read outputs

This commit is contained in:
2026-06-23 20:17:06 -05:00
parent b1c23270c8
commit f35b685279
2 changed files with 546 additions and 3 deletions
+1 -1
View File
@@ -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.
+545 -2
View File
@@ -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<Mo
let mut messages = vec![ModelMessage::System { content: system }];
messages.extend(records.iter().filter_map(record_to_model_message));
messages = sanitize_tool_message_structure(messages);
supersede_old_read_outputs(&mut messages);
let budget = context_budget_tokens(config);
if estimate_messages_tokens(&messages) > budget {
@@ -288,6 +289,298 @@ fn record_to_model_message(record: &Record) -> Option<ModelMessage> {
}
}
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<bool> = 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<String, StoredToolCall> {
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<ReadOutputSection>,
}
#[derive(Debug, Clone)]
struct ReadOutputSection {
path: String,
start_line: usize,
end_line: usize,
coverage_start: usize,
coverage_end: Option<usize>,
start_byte: usize,
end_byte: usize,
}
#[derive(Debug, Clone)]
struct ReadRequestSpec {
start_line: usize,
end_line: Option<usize>,
}
fn parse_read_output_sections(
content: &str,
request_specs: &[ReadRequestSpec],
) -> Vec<ReadOutputSection> {
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<ReadRequestSpec> {
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<usize>) {
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<usize> = 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<usize> {
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![