Compare commits

...
2 Commits
Author SHA1 Message Date
clawdbot 3a802f7bf9 release: v0.1.9 2026-10-28 19:58:30 +00:00
Bougie 8523d59a84 fix(runtime): keep tool-use/tool-result pairs paired in Trident compaction (#3367)
Trident stage 1 dropped the assistant message that carried a file tool
use while keeping the tool result that answered it, so the compacted
session ended up with orphaned `tool_result` blocks. Providers reject
such requests with HTTP 400 ("tool_result blocks must be a response to
a preceding tool_use"), which breaks the auto-compact retry path that
runs Trident before resending a request.

Stage 1 also counted a tool result as an independent file operation,
which pushed `last_write` past the write the result belonged to and made
the newest write look obsolete.

Only tool uses count as file operations now, and a tool result is
dropped together with the tool use that produced it.
2026-10-28 19:01:54 +00:00
5 changed files with 150 additions and 25 deletions

No files matched your search

+6
View File
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.1.9] - 2026-10-29
### Fixed
- Trident compaction no longer leaves orphaned tool_result blocks after superseding a file tool use, which made providers reject auto-compact retries with HTTP 400 (#3367)
## [0.1.8] - 2026-10-23
### Fixed
+11 -11
View File
@@ -93,7 +93,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "api"
version = "0.1.8"
version = "0.1.9"
dependencies = [
"criterion",
"reqwest",
@@ -478,7 +478,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "claw-analog"
version = "0.1.8"
version = "0.1.9"
dependencies = [
"api",
"clap",
@@ -498,7 +498,7 @@ dependencies = [
[[package]]
name = "claw-rag-service"
version = "0.1.8"
version = "0.1.9"
dependencies = [
"axum 0.8.9",
"blake3",
@@ -531,7 +531,7 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "commands"
version = "0.1.8"
version = "0.1.9"
dependencies = [
"plugins",
"runtime",
@@ -540,7 +540,7 @@ dependencies = [
[[package]]
name = "compat-harness"
version = "0.1.8"
version = "0.1.9"
dependencies = [
"commands",
"runtime",
@@ -1568,7 +1568,7 @@ dependencies = [
[[package]]
name = "mock-anthropic-service"
version = "0.1.8"
version = "0.1.9"
dependencies = [
"api",
"serde_json",
@@ -1761,7 +1761,7 @@ dependencies = [
[[package]]
name = "plugins"
version = "0.1.8"
version = "0.1.9"
dependencies = [
"serde",
"serde_json",
@@ -2136,7 +2136,7 @@ dependencies = [
[[package]]
name = "runtime"
version = "0.1.8"
version = "0.1.9"
dependencies = [
"glob",
"plugins",
@@ -2261,7 +2261,7 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "rusty-claude-cli"
version = "0.1.8"
version = "0.1.9"
dependencies = [
"api",
"commands",
@@ -2622,7 +2622,7 @@ dependencies = [
[[package]]
name = "telemetry"
version = "0.1.8"
version = "0.1.9"
dependencies = [
"serde",
"serde_json",
@@ -2902,7 +2902,7 @@ dependencies = [
[[package]]
name = "tools"
version = "0.1.8"
version = "0.1.9"
dependencies = [
"api",
"aspect-core",
+1 -1
View File
@@ -3,7 +3,7 @@ members = ["crates/*"]
resolver = "2"
[workspace.package]
version = "0.1.8"
version = "0.1.9"
edition = "2021"
license = "MIT"
publish = false
+1 -1
View File
@@ -82,7 +82,7 @@ async fn send_message_posts_json_and_parses_response() {
);
assert_eq!(
request.headers.get("user-agent").map(String::as_str),
Some("claude-code/0.1.8")
Some("claude-code/0.1.9")
);
assert_eq!(
request.headers.get("anthropic-beta").map(String::as_str),
+131 -12
View File
@@ -216,6 +216,48 @@ fn stage1_supersede(messages: &[ConversationMessage]) -> (Vec<ConversationMessag
}
}
// A tool result is only meaningful together with the tool use that
// produced it. Dropping the tool use while keeping its result leaves an
// orphaned `tool_result` block, which providers reject with
// "tool_result blocks must be a response to a preceding tool_use".
let dropped_tool_use_ids: BTreeSet<&str> = obsolete_indices
.iter()
.filter_map(|index| messages.get(*index))
.flat_map(|message| message.blocks.iter())
.filter_map(|block| match block {
ContentBlock::ToolUse { id, .. } => Some(id.as_str()),
_ => None,
})
.collect();
for (index, message) in messages.iter().enumerate() {
if obsolete_indices.contains(&index) {
continue;
}
let answers_dropped_tool_use = message.blocks.iter().any(|block| {
matches!(
block,
ContentBlock::ToolResult { tool_use_id, .. }
if dropped_tool_use_ids.contains(tool_use_id.as_str())
)
});
if !answers_dropped_tool_use {
continue;
}
// Keep a message that still carries a surviving tool use: dropping it
// would orphan that tool use instead.
let keeps_surviving_tool_use = message.blocks.iter().any(|block| {
matches!(
block,
ContentBlock::ToolUse { id, .. }
if !dropped_tool_use_ids.contains(id.as_str())
)
});
if !keeps_surviving_tool_use {
obsolete_indices.insert(index);
}
}
let superseded_count = obsolete_indices.len();
let kept: Vec<ConversationMessage> = messages
.iter()
@@ -239,18 +281,11 @@ fn extract_file_operation(block: &ContentBlock) -> Option<(String, FileOp)> {
};
Some((path, op_type))
}
ContentBlock::ToolResult {
tool_name, output, ..
} => {
let path = extract_path_from_tool_output(tool_name, output)?;
let op_type = match tool_name.as_str() {
"read_file" | "Read" => FileOp::Read,
"write_file" | "Write" => FileOp::Write,
"edit_file" | "Edit" => FileOp::Edit,
_ => return None,
};
Some((path, op_type))
}
// A tool result is the answer half of a tool use, not an independent
// file operation. Counting it as one shifts `last_write` past the
// write it belongs to, which makes the newest write look obsolete
// and splits its tool-use/tool-result pair.
ContentBlock::ToolResult { .. } => None,
ContentBlock::Text { .. } => None,
ContentBlock::Thinking { .. } => None,
}
@@ -732,6 +767,90 @@ mod tests {
assert_eq!(kept.len(), messages.len());
}
#[test]
fn stage1_keeps_tool_use_and_result_paired() {
// Realistic transcript: the model reads the same file twice and then
// rewrites it. Stage 1 must drop the obsolete reads *and* their tool
// results, but it must never leave a `tool_result` behind without the
// `tool_use` that produced it: providers reject such requests with
// "tool_result blocks must be a response to a preceding tool_use".
let messages = vec![
ConversationMessage::assistant(vec![ContentBlock::ToolUse {
id: "1".to_string(),
name: "read_file".to_string(),
input: r#"{"path":"src/main.rs"}"#.to_string(),
}]),
ConversationMessage::tool_result(
"1",
"read_file",
r#"{"type":"text","file":{"filePath":"/ws/src/main.rs","content":"old"}}"#,
false,
),
ConversationMessage::assistant(vec![ContentBlock::ToolUse {
id: "2".to_string(),
name: "read_file".to_string(),
input: r#"{"path":"src/main.rs"}"#.to_string(),
}]),
ConversationMessage::tool_result(
"2",
"read_file",
r#"{"type":"text","file":{"filePath":"/ws/src/main.rs","content":"old"}}"#,
false,
),
ConversationMessage::assistant(vec![ContentBlock::ToolUse {
id: "3".to_string(),
name: "write_file".to_string(),
input: r#"{"path":"src/main.rs","content":"new"}"#.to_string(),
}]),
ConversationMessage::tool_result(
"3",
"write_file",
r#"{"type":"update","filePath":"/ws/src/main.rs","content":"new"}"#,
false,
),
];
let (kept, superseded) = stage1_supersede(&messages);
assert!(superseded > 0, "obsolete reads should be superseded");
let tool_use_ids: BTreeSet<&str> = kept
.iter()
.flat_map(|message| message.blocks.iter())
.filter_map(|block| match block {
ContentBlock::ToolUse { id, .. } => Some(id.as_str()),
_ => None,
})
.collect();
for message in &kept {
for block in &message.blocks {
if let ContentBlock::ToolResult { tool_use_id, .. } = block {
assert!(
tool_use_ids.contains(tool_use_id.as_str()),
"orphaned tool_result for tool_use {tool_use_id}"
);
}
}
}
// The most recent write survives, together with its tool result.
let has_tool_use = |id: &str| {
kept.iter().any(|message| {
message.blocks.iter().any(
|block| matches!(block, ContentBlock::ToolUse { id: tool_use_id, .. } if tool_use_id == id),
)
})
};
let has_tool_result = |id: &str| {
kept.iter().any(|message| {
message.blocks.iter().any(
|block| matches!(block, ContentBlock::ToolResult { tool_use_id, .. } if tool_use_id == id),
)
})
};
assert!(has_tool_use("3"), "the newest write must survive");
assert!(has_tool_result("3"), "its tool result must survive too");
}
#[test]
fn stage2_collapses_chatty_messages() {
let mut messages = vec![];