Skip to content

Commit a84a510

Browse files
authored
Merge pull request #49 from efecnc/feat/compaction-ground-truth-tokens
fix(agent): use ground-truth prompt_tokens in the compaction trigger
2 parents 645a626 + 4fedab2 commit a84a510

1 file changed

Lines changed: 45 additions & 1 deletion

File tree

src/agent/mod.rs

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,14 @@ fn estimate_context_tokens(context: &[crate::utils::ChatMessage]) -> usize {
151151
context.iter().map(estimate_message_tokens).sum()
152152
}
153153

154+
/// Best available context-size estimate for the compaction trigger: the larger of the bytes/4
155+
/// heuristic and the last LLM call's exact `usage.prompt_tokens`. The heuristic under-counts the
156+
/// code/JSON/non-English payloads this agent produces, while `prompt_tokens` is the provider's
157+
/// ground-truth input count — so the max guards against silently overflowing the context window.
158+
fn effective_context_tokens(estimate: usize, last_prompt_tokens: Option<u32>) -> usize {
159+
estimate.max(last_prompt_tokens.unwrap_or(0) as usize)
160+
}
161+
154162
/// Trim context from the front (oldest messages) to stay within a token budget.
155163
/// Preserves the system message at index 0 and never splits tool_call/tool pairs.
156164
/// A marker message is inserted only when messages were actually removed.
@@ -2382,6 +2390,11 @@ impl AgentLogic {
23822390
let mut consecutive_doom_detections: usize = 0;
23832391
// Set when the doom loop persists past the nudge budget; branches the terminal message.
23842392
let mut doom_loop_stuck = false;
2393+
// Ground-truth input size from the most recent LLM call's `usage.prompt_tokens` (exact,
2394+
// server-counted). The bytes/4 heuristic under-counts code/JSON/non-English — exactly what
2395+
// this agent generates — so the compaction trigger uses `max(estimate, last_prompt_tokens)`
2396+
// to avoid silently overflowing the context window. Updated after each provider response.
2397+
let mut last_prompt_tokens: Option<u32> = None;
23852398

23862399
while iterations < max_iterations {
23872400
if cancel_token.is_cancelled() {
@@ -2682,6 +2695,12 @@ impl AgentLogic {
26822695
)
26832696
.with_chat_id(&inbound.chat_id),
26842697
));
2698+
// The context just shrank, so the pre-compaction `prompt_tokens` is
2699+
// now stale. Clear it; otherwise, if the retried call returns no
2700+
// usage stats (mock/local provider, transient gap), the end-of-turn
2701+
// check would read the old huge value via `effective_context_tokens`
2702+
// and immediately fire a redundant compaction right after this one.
2703+
last_prompt_tokens = None;
26852704
// Next iteration refetches context (now smaller due
26862705
// to AddSummary + UpdateThreadMetadata) and re-runs
26872706
// the chat call. No iteration counter refund — the
@@ -2793,6 +2812,10 @@ impl AgentLogic {
27932812

27942813
// Log USAGE telemetry
27952814
if let Some(usage) = &response.usage {
2815+
// Remember the exact server-counted input size for the compaction trigger.
2816+
if usage.prompt_tokens > 0 {
2817+
last_prompt_tokens = Some(usage.prompt_tokens);
2818+
}
27962819
let usage_evt = TelemetryEvent::AgentUsage {
27972820
chat_id: inbound.chat_id.clone(),
27982821
model: "llm_provider".to_string(),
@@ -3167,7 +3190,15 @@ impl AgentLogic {
31673190
// Auto-compaction check
31683191
let current_context = mem.get_context_since_reflection().await?;
31693192
let user_turns = current_context.iter().filter(|m| m.role == "user").count();
3170-
let approx_tokens: usize = estimate_context_tokens(&current_context);
3193+
// Prefer the ground truth: `last_prompt_tokens` is the exact input size the provider
3194+
// counted for the most recent request, which (at this end-of-turn point) covers
3195+
// nearly the entire current context — only the just-produced final assistant message
3196+
// is newer. Taking the max with the bytes/4 estimate corrects the heuristic's
3197+
// under-count on code/JSON-heavy contexts so a real overflow triggers compaction.
3198+
let approx_tokens: usize = effective_context_tokens(
3199+
estimate_context_tokens(&current_context),
3200+
last_prompt_tokens,
3201+
);
31713202

31723203
// PR-3: pull the model's context window from the provider; if known,
31733204
// tighten the absolute token threshold to whichever is smaller of:
@@ -4249,6 +4280,19 @@ mod tests {
42494280
);
42504281
}
42514282

4283+
#[test]
4284+
fn effective_context_tokens_prefers_ground_truth() {
4285+
// No usage yet -> fall back to the estimate.
4286+
assert_eq!(super::effective_context_tokens(1000, None), 1000);
4287+
// Provider's exact count exceeds the bytes/4 under-estimate -> use the ground truth so a
4288+
// real overflow still triggers compaction.
4289+
assert_eq!(super::effective_context_tokens(1000, Some(9000)), 9000);
4290+
// Estimate larger (e.g. messages added since the last call) -> keep the estimate.
4291+
assert_eq!(super::effective_context_tokens(9000, Some(1000)), 9000);
4292+
// A zero ground-truth never lowers the estimate.
4293+
assert_eq!(super::effective_context_tokens(1000, Some(0)), 1000);
4294+
}
4295+
42524296
#[tokio::test]
42534297
async fn run_reasoning_loop_persists_terminal_message_on_cancel() {
42544298
let (result, context) =

0 commit comments

Comments
 (0)