Files
cursor-byok/server/src/run/compaction.rs
T
leokun 76417e005b feat: add usage snapshot event and enhance compaction logic
- Introduced `UsageSnapshot` event to track token usage during conversation runs.
- Updated `RunEngine` to emit usage snapshots, providing better visibility into token consumption.
- Refactored compaction logic to utilize a new `compaction_estimate` function for improved token budget management.
- Added tests to validate timeout constants for blob synchronization and ensure correct behavior of usage tracking during compaction.
2026-09-01 20:04:35 +08:00

268 lines
8.7 KiB
Rust

//! Decides when to compact provider-visible context and builds a stable fallback summary.
use std::collections::HashSet;
use crate::{
model::{
estimate_context_tokens, estimate_projected_messages_tokens, CanonicalMessage, PreparedRun,
ProjectedMessage,
},
store::ContextUsageAnchor,
};
const FALLBACK_CHARS: usize = 12_000;
pub(super) const RESERVE_TOKENS: u64 = 10_000;
pub(super) const OUTPUT_TOKENS: u64 = 4_096;
pub(super) const INSTRUCTIONS: &str = "Summarize the conversation for the next model turn. Preserve goals, constraints, decisions, files, commands, errors, results, and unfinished work. Do not call tools. Return only the concise durable summary.";
pub(super) fn input_budget(prepared: &PreparedRun) -> Option<u64> {
prepared
.model
.context_window_tokens
.map(|window| window.saturating_sub(RESERVE_TOKENS))
}
pub(super) fn estimated_tokens(
prepared: &PreparedRun,
projected_messages: &[ProjectedMessage],
anchor: Option<ContextUsageAnchor>,
) -> u64 {
anchor
.filter(|anchor| anchor.message_count <= projected_messages.len())
.map(|anchor| {
anchor
.context_input_tokens
.saturating_add(estimate_projected_messages_tokens(
&projected_messages[anchor.message_count..],
))
})
.unwrap_or_else(|| estimate_context_tokens(&prepared.prompt, projected_messages))
}
pub(super) fn compaction_estimate(
prepared: &PreparedRun,
projected_messages: &[ProjectedMessage],
anchor: Option<ContextUsageAnchor>,
) -> Option<u64> {
let budget = input_budget(prepared)?;
let estimated = estimated_tokens(prepared, projected_messages, anchor);
(estimated > budget).then_some(estimated)
}
#[cfg(test)]
pub(super) fn should_compact(
prepared: &PreparedRun,
projected_messages: &[ProjectedMessage],
anchor: Option<ContextUsageAnchor>,
) -> bool {
compaction_estimate(prepared, projected_messages, anchor).is_some()
}
pub(super) fn validate_compacted(
prepared: &PreparedRun,
projected_messages: &[ProjectedMessage],
) -> std::result::Result<u64, String> {
let estimated = estimate_context_tokens(&prepared.prompt, projected_messages);
let Some(budget) = input_budget(prepared) else {
return Ok(estimated);
};
if estimated <= budget {
return Ok(estimated);
}
Err(format!(
"context overflow after compaction: estimated input {estimated} tokens exceeds budget {budget} tokens"
))
}
pub(super) fn partition(
messages: &[CanonicalMessage],
current_ids: &HashSet<&str>,
) -> (Vec<CanonicalMessage>, Option<CanonicalMessage>) {
let latest_request_context = messages
.iter()
.rposition(|message| message.message_id.starts_with("request-context:"));
let compactable = messages
.iter()
.enumerate()
.filter(|(index, message)| {
Some(*index) != latest_request_context
&& !current_ids.contains(message.message_id.as_str())
})
.map(|(_, message)| message.clone())
.collect();
let retained = latest_request_context
.and_then(|index| messages.get(index))
.filter(|message| !current_ids.contains(message.message_id.as_str()))
.cloned();
(compactable, retained)
}
pub(super) fn fallback_summary(messages: &[CanonicalMessage]) -> String {
let serialized = serde_json::to_string(messages).unwrap_or_default();
let start = serialized
.char_indices()
.rev()
.nth(FALLBACK_CHARS.saturating_sub(1))
.map_or(0, |(index, _)| index);
format!(
"Durable recent conversation state:\n{}",
&serialized[start..]
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{
project_messages, CheckpointId, ConversationId, ModelSpec, Origin, PromptSpec, Role,
RunAction, RunId, RunKind,
};
fn prepared(context_window_tokens: u64) -> PreparedRun {
let mut model = ModelSpec::new("model");
model.context_window_tokens = Some(context_window_tokens);
PreparedRun {
run_id: RunId::new("run"),
cursor_request_id: None,
conversation_id: ConversationId::new("conversation"),
kind: RunKind::Root,
model,
prompt: PromptSpec {
instructions: String::new(),
tools: Vec::new(),
},
initial_messages: Vec::new(),
action: RunAction::Start,
base_checkpoint_id: CheckpointId(1),
}
}
#[test]
fn automatic_compaction_uses_fixed_reserve_for_every_action() {
let messages = vec![CanonicalMessage::text(
"user",
Role::User,
Origin::Runtime,
"x".repeat(40_000),
)];
let projected = project_messages(&messages).unwrap();
let estimated = estimate_context_tokens(&prepared(1).prompt, &projected);
let mut prepared = prepared(estimated + RESERVE_TOKENS);
assert!(!should_compact(&prepared, &projected, None));
prepared.model.context_window_tokens = Some(estimated + RESERVE_TOKENS - 1);
assert!(should_compact(&prepared, &projected, None));
prepared.action = RunAction::Resume {
pending_tool_round: None,
};
assert!(should_compact(&prepared, &projected, None));
}
#[test]
fn provider_usage_anchor_only_estimates_messages_added_after_last_request() {
let messages = vec![
CanonicalMessage::text("old", Role::User, Origin::Runtime, "x".repeat(400_000)),
CanonicalMessage::text("new", Role::User, Origin::Runtime, "short follow-up"),
];
let projected = project_messages(&messages).unwrap();
let anchor = ContextUsageAnchor {
context_input_tokens: 103_904,
message_count: 1,
};
let expected = 103_904 + estimate_projected_messages_tokens(&projected[1..]);
assert_eq!(
estimated_tokens(&prepared(200_000), &projected, Some(anchor)),
expected
);
assert!(!should_compact(
&prepared(200_000),
&projected,
Some(anchor)
));
}
#[test]
fn provider_usage_anchor_triggers_after_new_messages_cross_budget() {
let messages = vec![
CanonicalMessage::text("old", Role::User, Origin::Runtime, "old"),
CanonicalMessage::text("new", Role::User, Origin::Runtime, "x".repeat(80_000)),
];
let projected = project_messages(&messages).unwrap();
assert!(should_compact(
&prepared(200_000),
&projected,
Some(ContextUsageAnchor {
context_input_tokens: 180_000,
message_count: 1,
})
));
}
#[test]
fn missing_anchor_uses_full_fallback() {
let messages = vec![CanonicalMessage::text(
"user",
Role::User,
Origin::Runtime,
"x".repeat(40_000),
)];
let projected = project_messages(&messages).unwrap();
let prepared = prepared(200_000);
assert_eq!(
estimated_tokens(&prepared, &projected, None),
estimate_context_tokens(&prepared.prompt, &projected)
);
}
#[test]
fn invalid_anchor_message_count_uses_full_fallback() {
let messages = vec![CanonicalMessage::text(
"user",
Role::User,
Origin::Runtime,
"x".repeat(40_000),
)];
let projected = project_messages(&messages).unwrap();
let expected = estimate_context_tokens(&prepared(200_000).prompt, &projected);
assert_eq!(
estimated_tokens(
&prepared(200_000),
&projected,
Some(ContextUsageAnchor {
context_input_tokens: 1,
message_count: 2,
})
),
expected
);
}
#[test]
fn compacted_history_is_validated_against_the_same_budget() {
let messages = vec![CanonicalMessage::text(
"user",
Role::User,
Origin::Runtime,
"x".repeat(40_000),
)];
let projected = project_messages(&messages).unwrap();
let estimated = estimate_context_tokens(&prepared(1).prompt, &projected);
assert_eq!(
validate_compacted(&prepared(estimated + RESERVE_TOKENS), &projected),
Ok(estimated)
);
assert!(
validate_compacted(&prepared(estimated + RESERVE_TOKENS - 1), &projected)
.unwrap_err()
.contains("context overflow after compaction")
);
}
}