From 696269c924dcbd596c81b8d3cf94c76a8112c958 Mon Sep 17 00:00:00 2001 From: JackChen Date: Thu, 16 Apr 2026 18:25:48 +0800 Subject: [PATCH] fix: guard against re-compression of already compressed tool result markers (#118) When minChars is set low, compressed markers could be re-compressed with incorrect char counts. Skip blocks whose content already starts with the compression prefix. --- src/agent/runner.ts | 3 ++ tests/tool-result-compression.test.ts | 42 +++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/src/agent/runner.ts b/src/agent/runner.ts index 9f910a2..1a5a594 100644 --- a/src/agent/runner.ts +++ b/src/agent/runner.ts @@ -908,6 +908,9 @@ export class AgentRunner { // Never compress error results — they carry diagnostic value. if (block.is_error) return block + // Skip already-compressed results — avoid re-compression with wrong char count. + if (block.content.startsWith('[Tool output compressed')) return block + // Skip short results — the marker itself has overhead. if (block.content.length < minChars) return block diff --git a/tests/tool-result-compression.test.ts b/tests/tool-result-compression.test.ts index 085da56..0dd37fa 100644 --- a/tests/tool-result-compression.test.ts +++ b/tests/tool-result-compression.test.ts @@ -415,6 +415,48 @@ describe('AgentRunner compressToolResults', () => { expect(allToolResults[2]).toBe(longOutput) }) + it('does not re-compress already compressed markers with low minChars', async () => { + const calls: LLMMessage[][] = [] + const longOutput = 'x'.repeat(600) + const responses = [ + toolUseResponse('echo', { message: 't1' }), + toolUseResponse('echo', { message: 't2' }), + toolUseResponse('echo', { message: 't3' }), + textResponse('done'), + ] + let idx = 0 + const adapter: LLMAdapter = { + name: 'mock', + async chat(messages) { + calls.push(messages.map(m => ({ role: m.role, content: [...m.content] }))) + return responses[idx++]! + }, + async *stream() { /* unused */ }, + } + const { registry, executor } = buildRegistryAndExecutor(longOutput) + const runner = new AgentRunner(adapter, registry, executor, { + model: 'mock-model', + allowedTools: ['echo'], + maxTurns: 6, + compressToolResults: { minChars: 10 }, // very low threshold + }) + + await runner.run([{ role: 'user', content: [{ type: 'text', text: 'start' }] }]) + + // Turn 4: turn 1 was compressed in turn 3. With minChars=10 the marker + // itself (55 chars) exceeds the threshold. Without the guard it would be + // re-compressed with a wrong char count (55 instead of 600). + const turn4Messages = calls[3]! + const allToolResults = extractToolResultContents(turn4Messages) + + // Turn 1 result: should still show original 600 chars, not re-compressed + expect(allToolResults[0]).toContain('600 chars') + // Turn 2 result: compressed for the first time this turn + expect(allToolResults[1]).toContain('600 chars') + // Turn 3 result: most recent, preserved in full + expect(allToolResults[2]).toBe(longOutput) + }) + it('works together with contextStrategy', async () => { const calls: LLMMessage[][] = [] const longOutput = 'x'.repeat(600)