Skip to content

Commit 003b3ca

Browse files
authored
perf(agents): sanitize compaction messages once for token estimation (#92026)
splitMessagesByTokenShare wrapped each message in a 1-element array and double-cloned it per message. Sanitize the full array once and pass a precomputed per-message token count array; totals unchanged, allocations reduced.
1 parent 91cc69d commit 003b3ca

1 file changed

Lines changed: 43 additions & 9 deletions

File tree

src/agents/compaction-planning.ts

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -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. */
5872
export 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

Comments
 (0)