@@ -54,6 +54,20 @@ export function estimateMessagesTokens(messages: AgentMessage[]): number {
5454 return safe . reduce ( ( sum , message ) => sum + estimateTokens ( message ) , 0 ) ;
5555}
5656
57+ /**
58+ * Per-original-message token estimates, aligned 1:1 to the input array. Sanitizes
59+ * the full array once instead of wrapping and re-cloning each message in its own
60+ * 1-element array. Runtime-context entries are not model-visible, so they estimate
61+ * to 0 here just as sanitizeCompactionMessages([msg]) would drop them.
62+ */
63+ function estimatePerMessageTokens ( messages : AgentMessage [ ] ) : number [ ] {
64+ // SECURITY: toolResult.details must never enter LLM-facing compaction; strip once for the whole array.
65+ const detailStripped = stripToolResultDetails ( messages ) ;
66+ // stripRuntimeContextCustomMessages filters by reference, so kept entries keep their identity.
67+ const modelVisible = new Set ( stripRuntimeContextCustomMessages ( detailStripped ) ) ;
68+ return detailStripped . map ( ( message ) => ( modelVisible . has ( message ) ? estimateTokens ( message ) : 0 ) ) ;
69+ }
70+
5771/** Removes runtime-only context and tool-result details before token estimates or summaries. */
5872export function sanitizeCompactionMessages ( messages : AgentMessage [ ] ) : AgentMessage [ ] {
5973 return stripToolResultDetails ( stripRuntimeContextCustomMessages ( messages ) ) ;
@@ -85,14 +99,20 @@ export function splitMessagesByTokenShare(
8599 return [ messages ] ;
86100 }
87101
88- const totalTokens = estimateMessagesTokens ( messages ) ;
102+ // Sanitize the full array once and reuse per-message token counts; avoids the
103+ // per-message [msg] wrap-and-clone that previously ran on every iteration.
104+ const perMessageTokens = estimatePerMessageTokens ( messages ) ;
105+ const totalTokens = perMessageTokens . reduce ( ( sum , tokens ) => sum + tokens , 0 ) ;
89106 const targetTokens = totalTokens / normalizedParts ;
90107 const chunks : AgentMessage [ ] [ ] = [ ] ;
91108 let current : AgentMessage [ ] = [ ] ;
92109 let currentTokens = 0 ;
93110
94111 let pendingToolCallIds = new Set < string > ( ) ;
95112 let pendingChunkStartIndex : number | null = null ;
113+ // Token count for each message currently buffered in `current`, kept in lockstep so a
114+ // boundary split can re-sum without re-estimating.
115+ let currentTokenCounts : number [ ] = [ ] ;
96116
97117 const splitCurrentAtPendingBoundary = ( ) : boolean => {
98118 if (
@@ -105,13 +125,15 @@ export function splitMessagesByTokenShare(
105125 // Keep an assistant tool_use and its following tool_result responses in the same chunk.
106126 chunks . push ( current . slice ( 0 , pendingChunkStartIndex ) ) ;
107127 current = current . slice ( pendingChunkStartIndex ) ;
108- currentTokens = current . reduce ( ( sum , msg ) => sum + estimateCompactionMessageTokens ( msg ) , 0 ) ;
128+ currentTokenCounts = currentTokenCounts . slice ( pendingChunkStartIndex ) ;
129+ currentTokens = currentTokenCounts . reduce ( ( sum , tokens ) => sum + tokens , 0 ) ;
109130 pendingChunkStartIndex = 0 ;
110131 return true ;
111132 } ;
112133
113- for ( const message of messages ) {
114- const messageTokens = estimateCompactionMessageTokens ( message ) ;
134+ for ( let index = 0 ; index < messages . length ; index += 1 ) {
135+ const message = messages [ index ] ;
136+ const messageTokens = perMessageTokens [ index ] ;
115137
116138 if (
117139 pendingToolCallIds . size === 0 &&
@@ -121,11 +143,13 @@ export function splitMessagesByTokenShare(
121143 ) {
122144 chunks . push ( current ) ;
123145 current = [ ] ;
146+ currentTokenCounts = [ ] ;
124147 currentTokens = 0 ;
125148 pendingChunkStartIndex = null ;
126149 }
127150
128151 current . push ( message ) ;
152+ currentTokenCounts . push ( messageTokens ) ;
129153 currentTokens += messageTokens ;
130154
131155 if ( message . role === "assistant" ) {
@@ -183,12 +207,16 @@ export function chunkMessagesByMaxTokens(
183207 // (chars/4 heuristic misses multi-byte chars, special tokens, code tokens, etc.)
184208 const effectiveMax = Math . max ( 1 , Math . floor ( maxTokens / SAFETY_MARGIN ) ) ;
185209
210+ // Sanitize the full array once and reuse per-message token counts; avoids the
211+ // per-message [msg] wrap-and-clone that previously ran on every iteration.
212+ const perMessageTokens = estimatePerMessageTokens ( messages ) ;
186213 const chunks : AgentMessage [ ] [ ] = [ ] ;
187214 let currentChunk : AgentMessage [ ] = [ ] ;
188215 let currentTokens = 0 ;
189216
190- for ( const message of messages ) {
191- const messageTokens = estimateCompactionMessageTokens ( message ) ;
217+ for ( let index = 0 ; index < messages . length ; index += 1 ) {
218+ const message = messages [ index ] ;
219+ const messageTokens = perMessageTokens [ index ] ;
192220 if ( currentChunk . length > 0 && currentTokens + messageTokens > effectiveMax ) {
193221 chunks . push ( currentChunk ) ;
194222 currentChunk = [ ] ;
@@ -265,10 +293,16 @@ export function buildOversizedFallbackPlan(params: {
265293 const smallMessages : AgentMessage [ ] = [ ] ;
266294 const oversizedNotes : string [ ] = [ ] ;
267295
268- for ( const msg of params . messages ) {
269- if ( isOversizedForSummary ( msg , params . contextWindow ) ) {
296+ // Sanitize the full array once and reuse per-message token counts; avoids the
297+ // per-message [msg] wrap-and-clone (twice per oversized message) of the prior loop.
298+ const perMessageTokens = estimatePerMessageTokens ( params . messages ) ;
299+ const oversizedThreshold = params . contextWindow * 0.5 ;
300+
301+ for ( let index = 0 ; index < params . messages . length ; index += 1 ) {
302+ const msg = params . messages [ index ] ;
303+ const tokens = perMessageTokens [ index ] ;
304+ if ( tokens * SAFETY_MARGIN > oversizedThreshold ) {
270305 const role = ( msg as { role ?: string } ) . role ?? "message" ;
271- const tokens = estimateCompactionMessageTokens ( msg ) ;
272306 oversizedNotes . push (
273307 `[Large ${ role } (~${ Math . round ( tokens / 1000 ) } K tokens) omitted from summary]` ,
274308 ) ;
0 commit comments