From ffeaa11961e277e4327c0aec8c876910443a7eca Mon Sep 17 00:00:00 2001 From: Dmatut7 <2966283641@qq.com> Date: Sun, 14 Jun 2026 00:48:54 +0800 Subject: [PATCH] fix(agent-core): cap background-task output buffer by bytes, not code units MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The in-memory output ring buffer is bounded by `MAX_OUTPUT_BYTES` (1 MiB) and `entry.outputSizeBytes` is tracked with `Buffer.byteLength`, but the eviction loop measured chunks with `string.length` (UTF-16 code units). For multibyte output (CJK, emoji) the resident buffer could therefore grow well past the byte cap — e.g. ~3 MiB of 3-byte characters before any eviction — defeating the memory bound the constant promises. Measure eviction in UTF-8 bytes so the cap holds regardless of encoding. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../agent-core/src/agent/background/index.ts | 9 ++-- .../test/agent/background/output-cap.test.ts | 54 +++++++++++++++++++ 2 files changed, 60 insertions(+), 3 deletions(-) create mode 100644 packages/agent-core/test/agent/background/output-cap.test.ts diff --git a/packages/agent-core/src/agent/background/index.ts b/packages/agent-core/src/agent/background/index.ts index 5c9963c57..12521e3ac 100644 --- a/packages/agent-core/src/agent/background/index.ts +++ b/packages/agent-core/src/agent/background/index.ts @@ -532,12 +532,15 @@ export class BackgroundManager { private appendOutput(entry: ManagedTask, chunk: string): void { entry.outputSizeBytes += Buffer.byteLength(chunk, 'utf-8'); entry.outputChunks.push(chunk); - // Enforce output cap: drop oldest chunks when over budget. - let total = entry.outputChunks.reduce((s, c) => s + c.length, 0); + // Enforce output cap: drop oldest chunks when over budget. Measure in + // bytes (matching MAX_OUTPUT_BYTES and entry.outputSizeBytes) — using + // string length would let multibyte output grow the ring buffer well past + // the byte cap. + let total = entry.outputChunks.reduce((s, c) => s + Buffer.byteLength(c, 'utf-8'), 0); while (total > MAX_OUTPUT_BYTES && entry.outputChunks.length > 1) { const removed = entry.outputChunks.shift(); if (removed === undefined) break; - total -= removed.length; + total -= Buffer.byteLength(removed, 'utf-8'); } const persistence = this.persistence; diff --git a/packages/agent-core/test/agent/background/output-cap.test.ts b/packages/agent-core/test/agent/background/output-cap.test.ts new file mode 100644 index 000000000..50a6ab58d --- /dev/null +++ b/packages/agent-core/test/agent/background/output-cap.test.ts @@ -0,0 +1,54 @@ +/** + * BackgroundManager in-memory output ring-buffer byte cap. + * + * The cap (`MAX_OUTPUT_BYTES`) is a byte budget, so eviction must be measured + * in UTF-8 bytes — not UTF-16 code units, which would let multibyte output + * grow the resident buffer well past the cap. + */ + +import { describe, expect, it } from 'vitest'; + +import { BackgroundManager } from '../../../src/agent/background'; +import type { + BackgroundTask, + BackgroundTaskInfo, + BackgroundTaskInfoBase, + BackgroundTaskSink, +} from '../../../src/agent/background/task'; +import { createBackgroundManager, waitForTerminal } from './helpers'; + +const MAX_OUTPUT_BYTES = 1024 * 1024; // mirror of the cap in src/agent/background/index.ts + +class ChunkEmittingTask implements BackgroundTask { + readonly idPrefix = 'agent'; + readonly kind = 'agent' as const; + + constructor( + readonly description: string, + private readonly chunks: readonly string[], + ) {} + + async start(sink: BackgroundTaskSink): Promise { + for (const chunk of this.chunks) sink.appendOutput(chunk); + await sink.settle({ status: 'completed' }); + } + + toInfo(base: BackgroundTaskInfoBase): BackgroundTaskInfo { + return { ...base, kind: 'agent' }; + } +} + +describe('BackgroundManager output ring-buffer byte cap', () => { + it('evicts oldest chunks by UTF-8 byte size for multibyte output', async () => { + const manager: BackgroundManager = createBackgroundManager().manager; // in-memory, no persistence + // Three 600 KB (UTF-8) chunks of a 3-byte character: 1.8 MB of bytes but + // only 600 K UTF-16 code units. The old char-based total never crossed the + // 1,048,576 budget, so nothing was evicted and the whole 1.8 MB stayed. + const chunk = '世'.repeat(200_000); + const taskId = manager.registerTask(new ChunkEmittingTask('emit', [chunk, chunk, chunk])); + await waitForTerminal(manager, taskId); + + const output = await manager.readOutput(taskId); + expect(Buffer.byteLength(output, 'utf-8')).toBeLessThanOrEqual(MAX_OUTPUT_BYTES); + }); +});