Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions crates/tracedecay-lcm/src/compression.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1420,17 +1420,17 @@ fn assemble_replay_messages(
.flat_map(|unit| unit.messages)
.collect(),
summaries.iter().collect::<Vec<_>>(),
latest_user_context_anchor(anchor_source, raws),
latest_user_context_anchor(anchor_source, anchors, raws),
),
Some(cap) => {
let used = anchors
.iter()
.map(|message| crate::lcm_budget_tokens(&message.content))
.map(|message| replay_transactions::replay_message_tokens(message))
.sum::<i64>();
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));
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1714,7 +1715,10 @@ fn context_recovery_hint(summary_nodes: &[LcmSummaryNode]) -> Option<String> {
}

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<i64>) -> bool {
Expand Down
2 changes: 1 addition & 1 deletion crates/tracedecay-lcm/src/compression_policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<i64>();
let overhead_tokens = (current_tokens - message_tokens).max(0);
Some((assembly_cap - overhead_tokens).max(1))
Expand Down
74 changes: 73 additions & 1 deletion crates/tracedecay-lcm/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<serde_json::Map<String, Value>>();
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),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand Down
15 changes: 4 additions & 11 deletions crates/tracedecay-lcm/src/replay_transactions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,18 +153,11 @@ fn active_replay_value(message: &LcmRawMessage) -> Option<Value> {
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)]
Expand Down
45 changes: 45 additions & 0 deletions crates/tracedecay/tests/session_suite/lcm_compression/overflow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<i64>();
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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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::<i64>();
request.current_tokens = Some(original_tokens);
request.overrides.threshold_tokens = Some(80);
request.overrides.fresh_tail_count = Some(3);

Expand All @@ -47,7 +52,81 @@ async fn fresh_tail_boundary_keeps_multi_tool_transaction_atomic_and_shrinking()
.collect::<Vec<_>>(),
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]
Expand Down
Loading