mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-10-05 20:44:07 +08:00
- 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.
268 lines
8.7 KiB
Rust
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")
|
|
);
|
|
}
|
|
}
|