@@ -3,12 +3,19 @@ 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 { compactionThresholdFor , contextTokensFromUsage } from "../provider/context-window.js" ;
9+ import { COMPACTOR_KEEP_RECENT_TURNS , compactorNoOpFloor } from "../session/compactor.js" ;
10+ import { createContextEstimate , estimateOverheadTokens } from "./context-estimate.js" ;
911
1012const COMPACTOR_NAME = "pruning-compactor" ;
11- const MIN_TURNS_TO_COMPACT = 6 ;
13+ // The exact turn count `createPruningCompactor` (session/compactor.ts) is
14+ // guaranteed to no-op on. Derived from the same keepRecentTurns both real
15+ // registrations (session, sub-agent) use, so this floor cannot silently
16+ // drift from what the compactor will actually do — arming at or below it
17+ // would spend a reactor cycle that shrinks nothing.
18+ const MIN_TURNS_TO_COMPACT = compactorNoOpFloor ( COMPACTOR_KEEP_RECENT_TURNS ) ;
1219const MAX_OVERFLOW_RECOVERIES = 2 ;
1320
1421// A compact action runs in its own reactor cycle, after which the reactor
@@ -20,51 +27,82 @@ const MAX_OVERFLOW_RECOVERIES = 2;
2027// would be worse than growing the context.
2128export type CompactionGovernor = ReturnType < typeof createCompactionGovernor > ;
2229
23- export function createCompactionGovernor ( requestContinuation ?: ( ) => void ) {
30+ export function createCompactionGovernor (
31+ requestContinuation ?: ( ) => void ,
32+ systemPrompt = "" ,
33+ toolDefinitions : readonly ToolDefinition [ ] = [ ] ,
34+ ) {
2435 let pending = false ;
2536 let idlePending = false ;
2637 let postCompactInfer = false ;
2738 let overflowRecoveries = 0 ;
39+ // Set whenever the arming decision fell back to the local estimate because
40+ // the provider omitted usage or reported zero, so callers rendering a meter
41+ // can flag the number as approximate instead of implying provider-grade
42+ // precision.
43+ let usingEstimate = false ;
44+ // Model of the last inference.done turn, kept for live re-checks between
45+ // inference cycles (see interceptActions) where the event carries no model.
46+ let lastModel : string | undefined ;
47+ let turnCount = 0 ;
2848
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 ( ) ;
49+ // Running local estimate of the turns we send, plus the fixed system-prompt
50+ // and tool-schema overhead every request carries. Providers that omit usage
51+ // or report zero leave the proactive path blind; the estimate fills that
52+ // gap. When the provider reports real usage we prefer it so a coarse local
53+ // count cannot thrash against a trustworthy signal.
54+ const estimate = createContextEstimate ( estimateOverheadTokens ( systemPrompt , toolDefinitions ) ) ;
3455
3556 // Re-sync after turn appends, tool results, and compaction rewrites. Callers
3657 // pass the full turn list so the estimate stays accurate without incremental
3758 // add/subtract bookkeeping.
3859 function syncFromTurns ( turns : readonly ConversationTurn [ ] ) : number {
60+ turnCount = turns . length ;
3961 return estimate . syncFromTurns ( turns ) ;
4062 }
4163
64+ function isOverThreshold ( contextTokens : number ) : boolean {
65+ return contextTokens > compactionThresholdFor ( lastModel ) && turnCount > MIN_TURNS_TO_COMPACT ;
66+ }
67+
4268 function noteInferenceDone (
4369 event : Extract < ReactorInboundEvent , { type : "inference.done" } > ,
4470 turns : readonly ConversationTurn [ ] ,
4571 ) : void {
4672 overflowRecoveries = 0 ;
4773 if ( requestContinuation === undefined ) return ;
4874 syncFromTurns ( turns ) ;
49- const reportedTokens = event . usage ?. input ?? 0 ;
50- const contextTokens = reportedTokens > 0 ? reportedTokens : estimate . tokens ;
51- // Assign, don't OR: an under-threshold follow-up must disarm a sticky pending
52- // left from an earlier over-threshold turn (e.g. after the provider reports
53- // real usage that lands below the threshold).
54- pending =
55- contextTokens > compactionThresholdFor ( event . source ?. model ) &&
56- turns . length > MIN_TURNS_TO_COMPACT ;
75+ lastModel = event . source ?. model ;
76+ const reportedTokens = contextTokensFromUsage ( event . usage ) ;
77+ usingEstimate = reportedTokens <= 0 ;
78+ const contextTokens = usingEstimate ? estimate . tokens : reportedTokens ;
79+ // Assign, don't OR: an under-threshold follow-up must disarm a sticky
80+ // pending left from an earlier over-threshold turn (e.g. after the
81+ // provider reports real usage that lands below the threshold).
82+ pending = isOverThreshold ( contextTokens ) ;
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 result produced by that turn's own tool batch. When the
91+ // provider is reporting real usage, that snapshot is authoritative and
92+ // `pending` alone is trusted (there is no fresher provider number to check
93+ // against until the next inference.done). But when usage was omitted or
94+ // zero, `pending` was itself derived from the local estimate — in that case
95+ // a large tool result can push the estimate over threshold before the next
96+ // inference.done ever runs, so this re-derives the same arming rule against
97+ // the live estimate (already re-synced this cycle by the director) instead
98+ // of trusting a `pending` that can be stale by exactly one tool batch.
6299 function interceptActions (
63100 event : ReactorInboundEvent ,
64101 actions : ReactorAction [ ] ,
65102 capabilities : ReactorCapabilities ,
66103 ) : ReactorAction [ ] | null {
67- if ( ! pending || event . type !== "tool.done" ) return null ;
104+ if ( event . type !== "tool.done" ) return null ;
105+ if ( ! pending && ! ( usingEstimate && isOverThreshold ( estimate . tokens ) ) ) return null ;
68106 if ( ! actions . some ( ( a ) => a . type === "infer" ) ) return null ;
69107 pending = false ;
70108 postCompactInfer = true ;
@@ -140,6 +178,12 @@ export function createCompactionGovernor(requestContinuation?: () => void) {
140178 get estimatedTokens ( ) : number {
141179 return estimate . tokens ;
142180 } ,
181+ // True once the provider has omitted or zeroed usage on the current
182+ // turn, so a status-bar meter reading this can mark itself approximate
183+ // rather than silently understating a real number.
184+ get usingEstimate ( ) : boolean {
185+ return usingEstimate ;
186+ } ,
143187 syncFromTurns,
144188 noteInferenceDone,
145189 noteIdleTurn,
0 commit comments