Skip to content

Commit d802af0

Browse files
committed
fix(openai): tier parallel tool results independently
1 parent c3b88b3 commit d802af0

3 files changed

Lines changed: 105 additions & 29 deletions

File tree

‎src/services/api/compressToolHistory.ts‎

Lines changed: 33 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -246,18 +246,19 @@ function indexToolUses(messages: AnyMessage[]): Map<string, ToolUseBlock> {
246246
return map
247247
}
248248

249-
function indexToolResultMessages(messages: AnyMessage[]): number[] {
250-
const indices: number[] = []
249+
function indexToolResults(
250+
messages: AnyMessage[],
251+
): Array<{ messageIndex: number; blockIndex: number }> {
252+
const indices: Array<{ messageIndex: number; blockIndex: number }> = []
251253
for (let i = 0; i < messages.length; i++) {
252254
const inner = getInner(messages[i])
253255
const role = inner.role ?? messages[i].role
254256
const content = inner.content
255-
if (
256-
role === 'user' &&
257-
Array.isArray(content) &&
258-
content.some((b: { type?: string }) => b?.type === 'tool_result')
259-
) {
260-
indices.push(i)
257+
if (role !== 'user' || !Array.isArray(content)) continue
258+
for (let blockIndex = 0; blockIndex < content.length; blockIndex++) {
259+
if ((content[blockIndex] as { type?: string })?.type === 'tool_result') {
260+
indices.push({ messageIndex: i, blockIndex })
261+
}
261262
}
262263
}
263264
return indices
@@ -313,36 +314,45 @@ export function compressToolHistory<T extends AnyMessage>(
313314
options.effectiveContextWindowSize ?? getEffectiveContextWindowSize(model),
314315
)
315316

316-
const toolResultIndices = indexToolResultMessages(messages)
317-
const total = toolResultIndices.length
317+
const toolResults = indexToolResults(messages)
318+
const total = toolResults.length
318319
// If every tool-result fits in the recent tier, no boundary crosses; return
319320
// the same reference for the same copy-elision reason.
320321
if (total <= tiers.recent) return messages
321322

322-
// O(1) lookup: messageIndex → tool-result position (0 = oldest). Replaces
323-
// the naive Array.indexOf(i) that was O(n²) across the .map below.
324-
const positionByIndex = new Map<number, number>()
325-
for (let pos = 0; pos < toolResultIndices.length; pos++) {
326-
positionByIndex.set(toolResultIndices[pos], pos)
323+
// O(1) lookup within each carrier message: blockIndex → tool-result position
324+
// (0 = oldest). Parallel results share a message but receive distinct tiers.
325+
const positionsByMessage = new Map<number, Map<number, number>>()
326+
for (let pos = 0; pos < toolResults.length; pos++) {
327+
const { messageIndex, blockIndex } = toolResults[pos]
328+
let positions = positionsByMessage.get(messageIndex)
329+
if (!positions) {
330+
positions = new Map<number, number>()
331+
positionsByMessage.set(messageIndex, positions)
332+
}
333+
positions.set(blockIndex, pos)
327334
}
328335

329336
const toolUsesById = indexToolUses(messages)
330337

331338
return messages.map((msg, i) => {
332-
const pos = positionByIndex.get(i)
333-
if (pos === undefined) return msg
334-
335-
const fromEnd = total - 1 - pos
336-
if (fromEnd < tiers.recent) return msg
339+
const positions = positionsByMessage.get(i)
340+
if (!positions) return msg
341+
const firstPos = positions.values().next().value
342+
if (firstPos === undefined || total - 1 - firstPos < tiers.recent) return msg
337343

338-
const inMidWindow = fromEnd < tiers.recent + tiers.mid
339344
const content = getInner(msg).content as unknown[]
340-
const newContent = content.map(block => {
345+
const newContent = content.map((block, blockIndex) => {
346+
const pos = positions.get(blockIndex)
347+
if (pos === undefined) return block
348+
const fromEnd = total - 1 - pos
349+
if (fromEnd < tiers.recent) return block
350+
341351
const b = block as { type?: string }
342352
if (b?.type !== 'tool_result') return block
343353
const tr = block as ToolResultBlock
344354
if (!shouldCompressBlock(tr, toolUsesById)) return block
345-
return inMidWindow
355+
return fromEnd < tiers.recent + tiers.mid
346356
? truncateBlock(tr, MID_MAX_CHARS)
347357
: buildStub(tr, toolUsesById)
348358
})

‎src/services/api/openaiShim.compression.test.ts‎

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,37 @@ function buildLongConversation(numExchanges: number, resultLength = 5_000) {
114114
return out
115115
}
116116

117+
function buildParallelToolConversation(
118+
numBatches: number,
119+
batchSize: number,
120+
resultLength = 5_000,
121+
) {
122+
const out: Array<{ role: string; content: unknown }> = [
123+
{ role: 'user', content: 'start the parallel work' },
124+
]
125+
for (let batch = 0; batch < numBatches; batch++) {
126+
const firstId = batch * batchSize
127+
out.push({
128+
role: 'assistant',
129+
content: Array.from({ length: batchSize }, (_, offset) => ({
130+
type: 'tool_use',
131+
id: `toolu_${firstId + offset}`,
132+
name: 'Read',
133+
input: { file_path: `/path/to/file${firstId + offset}.ts` },
134+
})),
135+
})
136+
out.push({
137+
role: 'user',
138+
content: Array.from({ length: batchSize }, (_, offset) => ({
139+
type: 'tool_result',
140+
tool_use_id: `toolu_${firstId + offset}`,
141+
content: bigText(resultLength),
142+
})),
143+
})
144+
}
145+
return out
146+
}
147+
117148
function makeFakeResponse(): Response {
118149
return new Response(
119150
JSON.stringify({
@@ -537,12 +568,12 @@ test('Responses compression preserves structured history and materially reduces
537568
expect(compressedByCallId.get('toolu_0')).toBe(
538569
'[Old tool result content cleared]',
539570
)
540-
for (let i = 1; i <= 14; i++) {
571+
for (let i = 1; i <= 15; i++) {
541572
expect(compressedByCallId.get(`toolu_${i}`)).toMatch(
542573
/^\[Read args=.*chars omitted\]$/,
543574
)
544575
}
545-
for (let i = 15; i <= 24; i++) {
576+
for (let i = 16; i <= 24; i++) {
546577
expect(compressedByCallId.get(`toolu_${i}`)).toContain('[…truncated')
547578
}
548579
expect(compressedByCallId.get('toolu_16_extra')).toContain('[…truncated')
@@ -571,6 +602,38 @@ test('Responses compression preserves structured history and materially reduces
571602
expect(compressedSize).toBeLessThan(uncompressedSize * 0.5)
572603
})
573604

605+
test('Responses compression assigns tiers per parallel tool result', async () => {
606+
mockState.enabled = true
607+
mockState.effectiveWindow = 100_000
608+
const messages = buildParallelToolConversation(5, 6)
609+
610+
const body = await captureResponsesRequestBody(messages, MID_TIER_MODEL)
611+
const functionCalls = getResponsesFunctionCalls(body)
612+
const functionOutputs = getResponsesFunctionOutputs(body)
613+
const outputs = new Map(
614+
functionOutputs.map(item => [item.call_id, item.output ?? '']),
615+
)
616+
617+
expect(functionCalls).toHaveLength(30)
618+
expect(functionOutputs).toHaveLength(30)
619+
expect(functionCalls.map(item => item.call_id)).toEqual(
620+
functionOutputs.map(item => item.call_id),
621+
)
622+
for (let i = 0; i < 15; i++) {
623+
expect(outputs.get(`toolu_${i}`)).toMatch(
624+
/^\[Read args=.*5000 chars omitted\]$/,
625+
)
626+
}
627+
for (let i = 15; i < 25; i++) {
628+
expect(outputs.get(`toolu_${i}`)).toContain(
629+
'[…truncated 3000 chars from tool history]',
630+
)
631+
}
632+
for (let i = 25; i < 30; i++) {
633+
expect(outputs.get(`toolu_${i}`)).toHaveLength(5_000)
634+
}
635+
})
636+
574637
test('Responses compression preserves structured parts while compressing old and mid text', async () => {
575638
mockState.enabled = true
576639
mockState.effectiveWindow = 100_000
@@ -767,7 +830,7 @@ test('non-chat transports do not invoke the Chat message converter', () => {
767830
expect(selectMessages('chat_completions', () => chatMessages)).toBe(chatMessages)
768831
})
769832

770-
test('Anthropic and Gemini transports do not invoke tool history compression', () => {
833+
test('only OpenAI-compatible transports invoke tool history compression', () => {
771834
const selectMessages = __test.getCompressedMessagesForTransport
772835
const rawMessages = [{ role: 'user', content: 'hello' }]
773836
const compressedMessages = [{ role: 'user', content: 'compressed' }]
@@ -779,6 +842,7 @@ test('Anthropic and Gemini transports do not invoke tool history compression', (
779842

780843
expect(selectMessages('anthropic_messages', rawMessages, compress)).toBe(rawMessages)
781844
expect(selectMessages('gemini', rawMessages, compress)).toBe(rawMessages)
845+
expect(selectMessages('future_transport', rawMessages, compress)).toBe(rawMessages)
782846
expect(compressionCalls).toBe(0)
783847

784848
expect(selectMessages('responses', rawMessages, compress)).toBe(compressedMessages)

‎src/services/api/openaiShim.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1501,9 +1501,11 @@ function getCompressedMessagesForTransport<T>(
15011501
rawMessages: T,
15021502
compress: () => T,
15031503
): T {
1504-
return transport === 'anthropic_messages' || transport === 'gemini'
1505-
? rawMessages
1506-
: compress()
1504+
return transport === 'chat_completions' ||
1505+
transport === 'responses' ||
1506+
transport === 'responses_compat'
1507+
? compress()
1508+
: rawMessages
15071509
}
15081510

15091511
/**

0 commit comments

Comments
 (0)