@@ -3,9 +3,14 @@ import type {
33 ReactorAction ,
44 ReactorCapabilities ,
55 ReactorInboundEvent ,
6+ ToolDefinition ,
67} from "@intx/types/runtime" ;
7- import { compactionThresholdFor } from "../provider/context-window.js" ;
8- import { createContextEstimate } from "./context-estimate.js" ;
8+ import {
9+ compactionThresholdFor ,
10+ contextTokensFromUsage ,
11+ urgentCompactionThresholdFor ,
12+ } from "../provider/context-window.js" ;
13+ import { createContextEstimate , estimateOverheadTokens } from "./context-estimate.js" ;
914
1015const COMPACTOR_NAME = "pruning-compactor" ;
1116const MIN_TURNS_TO_COMPACT = 6 ;
@@ -20,17 +25,30 @@ const MAX_OVERFLOW_RECOVERIES = 2;
2025// would be worse than growing the context.
2126export type CompactionGovernor = ReturnType < typeof createCompactionGovernor > ;
2227
23- export function createCompactionGovernor ( requestContinuation ?: ( ) => void ) {
28+ export function createCompactionGovernor (
29+ requestContinuation ?: ( ) => void ,
30+ systemPrompt = "" ,
31+ toolDefinitions : readonly ToolDefinition [ ] = [ ] ,
32+ ) {
2433 let pending = false ;
2534 let idlePending = false ;
2635 let postCompactInfer = false ;
2736 let overflowRecoveries = 0 ;
37+ // Set whenever the arming decision fell back to the local estimate because
38+ // the provider omitted usage or reported zero, so callers rendering a meter
39+ // can flag the number as approximate instead of implying provider-grade
40+ // precision.
41+ let usingEstimate = false ;
42+ // Model of the last inference.done turn, kept for live re-checks between
43+ // inference cycles (see interceptActions) where the event carries no model.
44+ let lastModel : string | undefined ;
2845
29- // Running local estimate of the turns we send. Providers that omit usage or
30- // report zero leave the proactive path blind; the estimate fills that gap.
31- // When the provider reports real usage we prefer it so a coarse local count
32- // cannot thrash against a trustworthy signal.
33- const estimate = createContextEstimate ( ) ;
46+ // Running local estimate of the turns we send, plus the fixed system-prompt
47+ // and tool-schema overhead every request carries. Providers that omit usage
48+ // or report zero leave the proactive path blind; the estimate fills that
49+ // gap. When the provider reports real usage we prefer it so a coarse local
50+ // count cannot thrash against a trustworthy signal.
51+ const estimate = createContextEstimate ( estimateOverheadTokens ( systemPrompt , toolDefinitions ) ) ;
3452
3553 // Re-sync after turn appends, tool results, and compaction rewrites. Callers
3654 // pass the full turn list so the estimate stays accurate without incremental
@@ -46,25 +64,42 @@ export function createCompactionGovernor(requestContinuation?: () => void) {
4664 overflowRecoveries = 0 ;
4765 if ( requestContinuation === undefined ) return ;
4866 syncFromTurns ( turns ) ;
49- const reportedTokens = event . usage ?. input ?? 0 ;
50- const contextTokens = reportedTokens > 0 ? reportedTokens : estimate . tokens ;
67+ lastModel = event . source ?. model ;
68+ const reportedTokens = contextTokensFromUsage ( event . usage ) ;
69+ usingEstimate = reportedTokens <= 0 ;
70+ const contextTokens = usingEstimate ? estimate . tokens : reportedTokens ;
5171 // Assign, don't OR: an under-threshold follow-up must disarm a sticky pending
5272 // left from an earlier over-threshold turn (e.g. after the provider reports
5373 // real usage that lands below the threshold).
74+ //
75+ // The turn-count floor exists to skip compacting a history too short to
76+ // meaningfully shrink, but a single early turn (one huge file read or
77+ // tool payload) can blow past the urgent threshold well before the floor
78+ // is met — in that case waiting is worse than compacting a short history,
79+ // so the floor is bypassed.
5480 pending =
55- contextTokens > compactionThresholdFor ( event . source ?. model ) &&
56- turns . length > MIN_TURNS_TO_COMPACT ;
81+ contextTokens > compactionThresholdFor ( lastModel ) &&
82+ ( turns . length > MIN_TURNS_TO_COMPACT || contextTokens > urgentCompactionThresholdFor ( lastModel ) ) ;
5783 }
5884
5985 // Compaction waits for the natural pause between a tool batch finishing and
6086 // the follow-up infer: the infer is dropped from the action set, the compact
6187 // cycle runs, and the continuation message re-enters inference.
88+ //
89+ // `pending` reflects the snapshot as of the last inference.done, which
90+ // predates any tool results produced by that turn. A large tool result can
91+ // push the live estimate past the urgent threshold before the next
92+ // inference.done ever runs, so this also re-checks the live estimate
93+ // (already re-synced this cycle by the director before calling here)
94+ // rather than trusting a potentially stale `pending`.
6295 function interceptActions (
6396 event : ReactorInboundEvent ,
6497 actions : ReactorAction [ ] ,
6598 capabilities : ReactorCapabilities ,
6699 ) : ReactorAction [ ] | null {
67- if ( ! pending || event . type !== "tool.done" ) return null ;
100+ if ( event . type !== "tool.done" ) return null ;
101+ const urgentNow = estimate . tokens > urgentCompactionThresholdFor ( lastModel ) ;
102+ if ( ! pending && ! urgentNow ) return null ;
68103 if ( ! actions . some ( ( a ) => a . type === "infer" ) ) return null ;
69104 pending = false ;
70105 postCompactInfer = true ;
@@ -140,6 +175,12 @@ export function createCompactionGovernor(requestContinuation?: () => void) {
140175 get estimatedTokens ( ) : number {
141176 return estimate . tokens ;
142177 } ,
178+ // True once the provider has omitted or zeroed usage on the current
179+ // turn, so a status-bar meter reading this can mark itself approximate
180+ // rather than silently understating a real number.
181+ get usingEstimate ( ) : boolean {
182+ return usingEstimate ;
183+ } ,
143184 syncFromTurns,
144185 noteInferenceDone,
145186 noteIdleTurn,
0 commit comments