From 60408e98e5003f7e9e0ff2d89893e7cf8204683b Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:27:23 -0800 Subject: [PATCH 1/2] fix(lcm): count replay metadata in token budgets --- crates/tracedecay-lcm/src/compression.rs | 7 +- .../tracedecay-lcm/src/compression_policy.rs | 2 +- crates/tracedecay-lcm/src/lib.rs | 74 ++++++++++++++++- .../tracedecay-lcm/src/replay_transactions.rs | 15 +--- .../lcm_compression/tool_transactions.rs | 83 ++++++++++++++++++- 5 files changed, 164 insertions(+), 17 deletions(-) diff --git a/crates/tracedecay-lcm/src/compression.rs b/crates/tracedecay-lcm/src/compression.rs index 41d77e8fda..36e618c839 100644 --- a/crates/tracedecay-lcm/src/compression.rs +++ b/crates/tracedecay-lcm/src/compression.rs @@ -1425,7 +1425,7 @@ fn assemble_replay_messages( Some(cap) => { let used = anchors .iter() - .map(|message| crate::lcm_budget_tokens(&message.content)) + .map(|message| replay_transactions::replay_message_tokens(message)) .sum::(); let (selected_raws, tail_tokens) = select_budget_tail(raws, used, cap); let mut summary_budget = (cap - used - tail_tokens).max(0); @@ -1714,7 +1714,10 @@ fn context_recovery_hint(summary_nodes: &[LcmSummaryNode]) -> Option { } fn replay_token_estimate(messages: &[Value]) -> i64 { - messages.iter().map(crate::lcm_message_budget_tokens).sum() + messages + .iter() + .map(crate::lcm_replay_message_budget_tokens) + .sum() } fn replay_exceeds_budget(replay_token_estimate: i64, max_assembly_tokens: Option) -> bool { diff --git a/crates/tracedecay-lcm/src/compression_policy.rs b/crates/tracedecay-lcm/src/compression_policy.rs index de320bb9d4..57e4464708 100644 --- a/crates/tracedecay-lcm/src/compression_policy.rs +++ b/crates/tracedecay-lcm/src/compression_policy.rs @@ -103,7 +103,7 @@ pub fn overflow_recovery_assembly_cap(input: OverflowRecoveryCapInput<'_>) -> Op let message_tokens = input .messages .iter() - .map(crate::lcm_message_budget_tokens) + .map(crate::lcm_replay_message_budget_tokens) .sum::(); let overhead_tokens = (current_tokens - message_tokens).max(0); Some((assembly_cap - overhead_tokens).max(1)) diff --git a/crates/tracedecay-lcm/src/lib.rs b/crates/tracedecay-lcm/src/lib.rs index f61a0f6663..40041bc34e 100644 --- a/crates/tracedecay-lcm/src/lib.rs +++ b/crates/tracedecay-lcm/src/lib.rs @@ -142,6 +142,32 @@ pub(crate) fn lcm_message_budget_tokens(message: &Value) -> i64 { } } +/// Tokens replayed by a message, including tool calls and other fields +/// outside `content`. Replay selection and reporting must use the same +/// tokenizer for this envelope as for visible text. Role framing belongs to +/// the host's prompt overhead; store and summary IDs are local provenance. +pub(crate) fn lcm_replay_message_budget_tokens(message: &Value) -> i64 { + let content_tokens = lcm_message_budget_tokens(message); + let Some(object) = message.as_object() else { + return content_tokens; + }; + let envelope = object + .iter() + .filter(|(key, _)| { + !matches!( + key.as_str(), + "content" | "role" | "store_id" | "lcm_summary_node_id" + ) + }) + .map(|(key, value)| (key.clone(), value.clone())) + .collect::>(); + if envelope.is_empty() { + content_tokens + } else { + content_tokens.saturating_add(lcm_budget_tokens(&Value::Object(envelope).to_string())) + } +} + enum VisibleContent<'a> { Empty, Text(&'a str), @@ -278,7 +304,10 @@ impl GitScopeFilter { #[cfg(test)] mod budget_tests { - use super::{lcm_budget_tokens, lcm_message_budget_tokens, lcm_message_visible_text}; + use super::{ + lcm_budget_tokens, lcm_message_budget_tokens, lcm_message_visible_text, + lcm_replay_message_budget_tokens, + }; use serde_json::json; /// The borrowed counter must agree with counting the materialized visible @@ -343,6 +372,49 @@ mod budget_tests { ); } + #[test] + fn replay_budget_ignores_local_provenance_and_role_framing() { + let message = json!({ + "role": "system", + "content": "compact summary", + "store_id": 123, + "lcm_summary_node_id": "summary-local-id", + }); + assert_eq!( + lcm_replay_message_budget_tokens(&message), + lcm_budget_tokens("compact summary") + ); + } + + #[test] + fn overflow_recovery_does_not_charge_tool_arguments_as_fixed_overhead() { + use crate::compression_policy::{OverflowRecoveryCapInput, overflow_recovery_assembly_cap}; + + let message = json!({ + "role": "assistant", + "content": "", + "tool_calls": [{ + "id": "call-a", + "type": "function", + "function": { + "name": "execute", + "arguments": json!({"source": "f(x:u32)->u32{x+1};".repeat(32)}).to_string(), + }, + }], + }); + let message_tokens = lcm_replay_message_budget_tokens(&message); + let prompt_overhead = 128; + assert_eq!( + overflow_recovery_assembly_cap(OverflowRecoveryCapInput { + current_tokens: Some(message_tokens + prompt_overhead), + max_assembly_tokens: Some(message_tokens + prompt_overhead), + messages: &[message], + }), + Some(message_tokens), + "the replay budget must reserve fixed prompt overhead exactly once" + ); + } + /// The cap is inclusive: a chunk of messages totalling exactly the cap /// fits, one token under it does not. #[test] diff --git a/crates/tracedecay-lcm/src/replay_transactions.rs b/crates/tracedecay-lcm/src/replay_transactions.rs index ba4cd4cbf3..487f704686 100644 --- a/crates/tracedecay-lcm/src/replay_transactions.rs +++ b/crates/tracedecay-lcm/src/replay_transactions.rs @@ -153,18 +153,11 @@ fn active_replay_value(message: &LcmRawMessage) -> Option { active_replay_message_from_metadata(message) } -fn replay_message_tokens(message: &LcmRawMessage) -> i64 { - let mut tokens = crate::lcm_budget_tokens(&message.content); - if let Some(mut replay) = active_replay_value(message) { - if let Some(object) = replay.as_object_mut() { - object.remove("content"); - object.remove("role"); - object.remove("store_id"); - } - let serialized = replay.to_string(); - tokens += serialized.len().div_ceil(4) as i64; +pub(crate) fn replay_message_tokens(message: &LcmRawMessage) -> i64 { + match active_replay_value(message) { + Some(replay) => crate::lcm_replay_message_budget_tokens(&replay), + None => crate::lcm_budget_tokens(&message.content), } - tokens } #[tracing::instrument(name = "sessions.lcm.normalize_pairs", level = "trace", skip_all)] diff --git a/crates/tracedecay/tests/session_suite/lcm_compression/tool_transactions.rs b/crates/tracedecay/tests/session_suite/lcm_compression/tool_transactions.rs index 4d7781399a..173b3dda75 100644 --- a/crates/tracedecay/tests/session_suite/lcm_compression/tool_transactions.rs +++ b/crates/tracedecay/tests/session_suite/lcm_compression/tool_transactions.rs @@ -22,7 +22,12 @@ async fn fresh_tail_boundary_keeps_multi_tool_transaction_atomic_and_shrinking() Some(400), ); request.messages = with_authoritative_timestamps(messages); - request.current_tokens = Some(107); + let original_tokens = request + .messages + .iter() + .map(|message| budget_tokens(&message.to_string())) + .sum::(); + request.current_tokens = Some(original_tokens); request.overrides.threshold_tokens = Some(80); request.overrides.fresh_tail_count = Some(3); @@ -47,7 +52,81 @@ async fn fresh_tail_boundary_keeps_multi_tool_transaction_atomic_and_shrinking() .collect::>(), vec!["call-a", "call-b"] ); - assert!(response.replay_token_estimate < 107); + assert!(response.replay_token_estimate < original_tokens); +} + +#[tokio::test] +async fn tool_arguments_use_token_budget_when_selecting_atomic_replay() { + let tmp = TempDir::new().unwrap(); + let db = open_lcm_db(&tmp).await; + insert_session(&db, "cursor", "session-tool-token-budget").await; + + let arguments = json!({"source": "f(x:u32)->u32{x+1};".repeat(256)}).to_string(); + let cap = budget_tokens(&arguments) / 2; + let mut messages = active_multi_tool_transaction(); + messages[0]["tool_calls"][0]["function"]["arguments"] = json!(arguments); + let mut request = limited_compress_request( + "cursor", + "session-tool-token-budget", + provided_summary("unused"), + None, + None, + Some(cap), + ); + request.messages = with_authoritative_timestamps(messages); + request.current_tokens = None; + request.overrides.fresh_tail_count = Some(3); + + let response = db.lcm_compress(request).await.unwrap(); + + assert_eq!(response.reason, "no_backlog_to_compress"); + assert_eq!(response.summary_nodes_created, 0); + assert!( + response.replay_messages.is_empty(), + "tool arguments exceed the cap, so the whole transaction must be evicted" + ); + assert!(!response.replay_over_budget); +} + +#[tokio::test] +async fn overflow_recovery_reports_oversized_tool_arguments() { + let tmp = TempDir::new().unwrap(); + let db = open_lcm_db(&tmp).await; + insert_session(&db, "cursor", "session-tool-overflow").await; + + let arguments = json!({"source": "f(x:u32)->u32{x+1};".repeat(256)}).to_string(); + let argument_tokens = budget_tokens(&arguments); + let mut messages = active_multi_tool_transaction(); + messages[0]["tool_calls"][0]["function"]["arguments"] = json!(arguments); + ingest_active_messages(&db, "cursor", "session-tool-overflow", messages).await; + + // Recover from the canonical raw history, with no backlog to summarize. + // The newest tool transaction must remain intact even when it cannot fit. + let mut request = limited_compress_request( + "cursor", + "session-tool-overflow", + provided_summary("unused"), + None, + None, + Some(64), + ); + request.current_tokens = Some(argument_tokens); + request.overrides.fresh_tail_count = Some(3); + + let response = db.lcm_compress(request).await.unwrap(); + + assert_eq!(response.status, "best_effort"); + assert_eq!(response.reason, "irreducible_overflow_no_backlog"); + assert_eq!(response.summary_nodes_created, 0); + assert!(response.replay_over_budget); + assert!(response.replay_token_estimate >= argument_tokens); + assert_eq!(response.replay_messages.len(), 3); + assert_eq!( + response.replay_messages[0]["tool_calls"][0]["function"]["arguments"], + arguments + ); + assert_eq!(response.replay_messages[1]["tool_call_id"], "call-a"); + assert_eq!(response.replay_messages[2]["tool_call_id"], "call-b"); } #[tokio::test] From f385a059e3d4a09b70334d339c1bc491fbdeb9c1 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 2 Nov 2026 23:06:30 -0800 Subject: [PATCH 2/2] fix(lcm): avoid duplicating pinned replay objectives --- crates/tracedecay-lcm/src/compression.rs | 7 +-- .../session_suite/lcm_compression/overflow.rs | 45 +++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/crates/tracedecay-lcm/src/compression.rs b/crates/tracedecay-lcm/src/compression.rs index 36e618c839..c2bd4ea6c6 100644 --- a/crates/tracedecay-lcm/src/compression.rs +++ b/crates/tracedecay-lcm/src/compression.rs @@ -1420,7 +1420,7 @@ fn assemble_replay_messages( .flat_map(|unit| unit.messages) .collect(), summaries.iter().collect::>(), - latest_user_context_anchor(anchor_source, raws), + latest_user_context_anchor(anchor_source, anchors, raws), ), Some(cap) => { let used = anchors @@ -1430,7 +1430,7 @@ fn assemble_replay_messages( let (selected_raws, tail_tokens) = select_budget_tail(raws, used, cap); let mut summary_budget = (cap - used - tail_tokens).max(0); let preserved_objective_anchor = - latest_user_context_anchor(anchor_source, &selected_raws).and_then( + latest_user_context_anchor(anchor_source, anchors, &selected_raws).and_then( |(store_id, part, already_preserved)| { if already_preserved { return Some((store_id, part, already_preserved)); @@ -1548,11 +1548,12 @@ fn is_budget_droppable_tail_message(message: &LcmRawMessage) -> bool { fn latest_user_context_anchor( raws: &[LcmRawMessage], + anchors: &[&LcmRawMessage], selected_tail: &[&LcmRawMessage], ) -> Option<(i64, String, bool)> { for message in raws.iter().rev() { if let Some(preserved) = preserved_objective_context_content(&message.content) { - if selected_tail.iter().any(|selected| { + if selected_tail.iter().chain(anchors).any(|selected| { preserved_objective_context_content(&selected.content) == Some(preserved) }) { return None; diff --git a/crates/tracedecay/tests/session_suite/lcm_compression/overflow.rs b/crates/tracedecay/tests/session_suite/lcm_compression/overflow.rs index 116d762ce8..1eb7abab3f 100644 --- a/crates/tracedecay/tests/session_suite/lcm_compression/overflow.rs +++ b/crates/tracedecay/tests/session_suite/lcm_compression/overflow.rs @@ -633,6 +633,51 @@ async fn overflow_recovery_keeps_preserved_objective_scaffold_when_evicting_tail })); } +#[tokio::test] +async fn pinned_objective_is_not_reinjected_outside_the_replay_budget() { + let tmp = TempDir::new().unwrap(); + let db = open_lcm_db(&tmp).await; + let objective = + "[Current user objective preserved from compacted history]\nShip OAuth login now"; + let messages = [ + ("system", "policy anchor"), + ("system", objective), + ("assistant", "still working"), + ("assistant", "recent update"), + ]; + insert_raw_messages_with_roles(&db, "cursor", "session-pinned-objective", &messages).await; + let cap = messages + .iter() + .map(|(_, content)| budget_tokens(content)) + .sum::(); + let mut request = limited_compress_request( + "cursor", + "session-pinned-objective", + provided_summary("unused"), + None, + None, + Some(cap), + ); + request.current_tokens = None; + request.overrides.fresh_tail_count = Some(2); + + let response = db.lcm_compress(request).await.unwrap(); + + assert_eq!(response.summary_nodes_created, 0); + assert_eq!( + response + .replay_messages + .iter() + .filter(|message| message["content"] == objective) + .count(), + 1, + "the objective is already present among the pinned anchors" + ); + assert_eq!(response.replay_messages.len(), messages.len()); + assert_eq!(response.replay_token_estimate, cap); + assert!(!response.replay_over_budget); +} + // Mirrors hermes-lcm `_assemble_overflow_recovery_context`: with no backlog // to compact, forced overflow evicts droppable assistant/tool tail turns that // do not fit under the cap while keeping anchors and budgetable user intent.