diff --git a/src/app/runtime/context/dedup.rs b/src/app/runtime/context/dedup.rs new file mode 100644 index 0000000..ca01fa2 --- /dev/null +++ b/src/app/runtime/context/dedup.rs @@ -0,0 +1,183 @@ +//! Cross-call tool-result deduplication: when a read-only tool is called +//! again with identical arguments, the earlier result is replaced with a +//! placeholder so only the latest copy occupies context. +//! +//! Flow: pair each `Role::Tool` message to its originating `ToolCall` via +//! `tool_call_id` -> key on `(function.name, sha256(canonical_json(args)))` +//! -> for read-only tools, keep only the last occurrence of each key in +//! full, placeholder the rest. +//! +//! Why: reading the same file (or re-running the same grep) twice in a +//! session otherwise keeps both full copies in context until compaction +//! eventually drops the older one wholesale, along with everything else +//! from that period. Mutating tools (`write`, `edit`, `bash`, `delete`, +//! `git_operator`, ...) are never touched, even with identical +//! arguments, because call order and repetition can be semantically +//! meaningful (e.g. retrying a flaky `bash` command until it passes). + +use std::collections::HashMap; +use sha2::Digest; +use crate::app::subagent::division::tool_scope::READ_TOOLS; +use crate::dto::chat::message::{ChatMessage, Role}; + +const DUPLICATE_PLACEHOLDER: &str = + "[duplicate result — superseded by a later identical call, see below]"; + +/// Replace superseded read-only tool results with a placeholder. +/// +/// Return: a `Vec` the same length as `messages`, and +/// `true` iff at least one entry was replaced. The caller uses the +/// `bool` to decide whether the result is worth persisting/announcing, +/// without `ChatMessage` needing to implement `PartialEq`. +pub fn collapse(messages: &[ChatMessage]) -> (Vec, bool) { + // tool_call_id -> (tool name, canonical JSON of its arguments) + let mut call_info: HashMap = HashMap::new(); + for m in messages { + if let Some(calls) = &m.tool_calls { + for call in calls { + let canonical = serde_json::to_string(&call.function.arguments).unwrap_or_default(); + call_info.insert(call.id.clone(), (call.function.name.clone(), canonical)); + } + } + } + + // For each (tool, args-hash) key among read-only tools, find the + // index of its LAST occurrence — that's the one kept in full. + let mut last_index_for_key: HashMap = HashMap::new(); + for (idx, m) in messages.iter().enumerate() { + if m.role != Role::Tool { + continue; + } + let Some(id) = &m.tool_call_id else { continue }; + let Some((name, args)) = call_info.get(id) else { continue }; + if !READ_TOOLS.contains(&name.as_str()) { + continue; + } + last_index_for_key.insert(dedup_key(name, args), idx); + } + + let mut changed = false; + let result = messages.iter().enumerate().map(|(idx, m)| { + if m.role != Role::Tool { + return m.clone(); + } + let Some(id) = &m.tool_call_id else { return m.clone() }; + let Some((name, args)) = call_info.get(id) else { return m.clone() }; + if !READ_TOOLS.contains(&name.as_str()) { + return m.clone(); + } + let key = dedup_key(name, args); + if last_index_for_key.get(&key) == Some(&idx) { + return m.clone(); + } + changed = true; + ChatMessage::tool_result(id.clone(), DUPLICATE_PLACEHOLDER.to_string()) + }).collect(); + + (result, changed) +} + +/// Build the dedup key for a tool call. +/// +/// Why hash the arguments: keeps the key a fixed, short size regardless +/// of argument payload size. `serde_json::to_string` is already +/// canonical here — this codebase doesn't enable serde_json's +/// `preserve_order` feature, so `Value::Object` is backed by a +/// `BTreeMap` and always serializes keys in sorted order. +fn dedup_key(tool_name: &str, canonical_args: &str) -> String { + let hash = hex::encode(sha2::Sha256::digest(canonical_args.as_bytes())); + format!("{tool_name}:{hash}") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dto::chat::message::ChatMessage; + use crate::dto::chat::tool::{ToolCall, ToolFunction}; + use serde_json::json; + + fn assistant_with_call(id: &str, name: &str, args: serde_json::Value) -> ChatMessage { + let mut m = ChatMessage::assistant(None); + m.tool_calls = Some(vec![ToolCall { + id: id.to_string(), + type_: "function".to_string(), + function: ToolFunction { name: name.to_string(), arguments: args }, + }]); + m + } + + #[test] + fn older_result_of_same_read_tool_and_args_is_replaced() { + let messages = vec![ + assistant_with_call("call-1", "read", json!({"path": "a.rs"})), + ChatMessage::tool_result("call-1".to_string(), "first read of a.rs".to_string()), + assistant_with_call("call-2", "read", json!({"path": "a.rs"})), + ChatMessage::tool_result("call-2".to_string(), "second read of a.rs".to_string()), + ]; + + let (result, changed) = collapse(&messages); + + assert!(changed); + assert_eq!(result[1].content.as_deref(), Some(DUPLICATE_PLACEHOLDER)); + assert_eq!(result[3].content.as_deref(), Some("second read of a.rs")); + } + + #[test] + fn different_arguments_are_not_deduplicated() { + let messages = vec![ + assistant_with_call("call-1", "read", json!({"path": "a.rs"})), + ChatMessage::tool_result("call-1".to_string(), "read of a.rs".to_string()), + assistant_with_call("call-2", "read", json!({"path": "b.rs"})), + ChatMessage::tool_result("call-2".to_string(), "read of b.rs".to_string()), + ]; + + let (result, changed) = collapse(&messages); + + assert!(!changed); + assert_eq!(result[1].content.as_deref(), Some("read of a.rs")); + assert_eq!(result[3].content.as_deref(), Some("read of b.rs")); + } + + #[test] + fn key_order_in_arguments_does_not_prevent_dedup() { + let messages = vec![ + assistant_with_call("call-1", "grep", json!({"pattern": "foo", "path": "."})), + ChatMessage::tool_result("call-1".to_string(), "first grep".to_string()), + assistant_with_call("call-2", "grep", json!({"path": ".", "pattern": "foo"})), + ChatMessage::tool_result("call-2".to_string(), "second grep".to_string()), + ]; + + let (result, changed) = collapse(&messages); + + assert!(changed); + assert_eq!(result[1].content.as_deref(), Some(DUPLICATE_PLACEHOLDER)); + } + + #[test] + fn mutating_tool_with_identical_args_is_never_deduplicated() { + let messages = vec![ + assistant_with_call("call-1", "bash", json!({"command": "cargo test"})), + ChatMessage::tool_result("call-1".to_string(), "first run: 3 failed".to_string()), + assistant_with_call("call-2", "bash", json!({"command": "cargo test"})), + ChatMessage::tool_result("call-2".to_string(), "second run: 0 failed".to_string()), + ]; + + let (result, changed) = collapse(&messages); + + assert!(!changed); + assert_eq!(result[1].content.as_deref(), Some("first run: 3 failed")); + assert_eq!(result[3].content.as_deref(), Some("second run: 0 failed")); + } + + #[test] + fn tool_result_with_no_matching_call_is_left_untouched() { + let messages = vec![ + ChatMessage::tool_result("orphan-id".to_string(), "some result".to_string()), + ]; + + let (result, changed) = collapse(&messages); + + assert!(!changed); + assert_eq!(result[0].content.as_deref(), Some("some result")); + } +} diff --git a/src/app/runtime/context/mod.rs b/src/app/runtime/context/mod.rs index 9164975..b0e5c74 100644 --- a/src/app/runtime/context/mod.rs +++ b/src/app/runtime/context/mod.rs @@ -10,5 +10,6 @@ //! auto-loop already needs per-stage control to decide when to emit //! `TurnEvent::Compacted`. +pub mod dedup; pub mod tokens; pub mod window; diff --git a/src/app/subagent/division.rs b/src/app/subagent/division.rs index 5f39672..4cb439c 100644 --- a/src/app/subagent/division.rs +++ b/src/app/subagent/division.rs @@ -18,7 +18,10 @@ pub mod tool_scope { /// Write-tier plus delete, git, and the remaining LSP actions. pub const FULL: &str = "full"; - const READ_TOOLS: &[&str] = &[ + /// The read-only tool set — reused by `context::dedup` as the + /// authoritative "safe to deduplicate" classification, so there's a + /// single list of read-only tool names in the codebase instead of two. + pub const READ_TOOLS: &[&str] = &[ "read", "grep", "glob", "search", "seqthink", "recall", "lsp_connect", "lsp_diagnostics", "lsp_hover", "lsp_definition", "lsp_references", "read_findings",