Supersede old read outputs
This commit is contained in:
+1
-1
@@ -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
@@ -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![
|
||||
|
||||
Reference in New Issue
Block a user