Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions packages/agent-core/src/agent/background/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
54 changes: 54 additions & 0 deletions packages/agent-core/test/agent/background/output-cap.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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);
});
});