feat: update OutputInterceptor to use 50/50 head/tail split like Codex

- Replace single buffer with separate headBuffer and tailBuffer
- Each buffer gets 50% of the preview budget
- Head captures beginning of output, tail keeps rolling end
- Middle content is dropped when output exceeds threshold
- Preview shows: head + [omission indicator] + tail
- Add tests for head/tail split behavior

This approach ensures the LLM sees both:
- The beginning (command startup, environment info, early errors)
- The end (final results, exit codes, error summaries)
This commit is contained in:
Hannes Rudolph 2026-01-27 11:05:38 -07:00
parent 22346f0224
commit cf7472945d
2 changed files with 304 additions and 37 deletions

View file

@ -26,13 +26,14 @@ export interface OutputInterceptorOptions {
* files, with only a preview shown to the LLM. The LLM can then use the `read_command_output`
* tool to retrieve full contents or search through the output.
*
* The interceptor operates in two modes:
* 1. **Buffer mode**: Output is accumulated in memory until it exceeds the preview threshold
* 2. **Spill mode**: Once threshold is exceeded, output is streamed directly to disk
* The interceptor uses a **head/tail buffer** strategy (inspired by Codex):
* - 50% of the preview budget is allocated to the "head" (beginning of output)
* - 50% of the preview budget is allocated to the "tail" (end of output)
* - Middle content is dropped when output exceeds the preview threshold
*
* This approach prevents large command outputs (like build logs, test results, or verbose
* operations) from overwhelming the context window while still allowing the LLM to access
* the full output when needed.
* This approach ensures the LLM sees both:
* - The beginning (command startup, environment info, early errors)
* - The end (final results, exit codes, error summaries)
*
* @example
* ```typescript
@ -50,17 +51,31 @@ export interface OutputInterceptorOptions {
*
* // Finalize and get the result
* const result = interceptor.finalize();
* // result.preview contains truncated output for display
* // result.preview contains head + [omitted] + tail for display
* // result.artifactPath contains path to full output if truncated
* ```
*/
export class OutputInterceptor {
private buffer: string = ""
/** Buffer for the head (beginning) of output */
private headBuffer: string = ""
/** Buffer for the tail (end) of output - rolling buffer that drops front when full */
private tailBuffer: string = ""
/** Number of bytes currently in the head buffer */
private headBytes: number = 0
/** Number of bytes currently in the tail buffer */
private tailBytes: number = 0
/** Number of bytes omitted from the middle */
private omittedBytes: number = 0
private writeStream: fs.WriteStream | null = null
private artifactPath: string
private totalBytes: number = 0
private spilledToDisk: boolean = false
private readonly previewBytes: number
/** Budget for the head buffer (50% of total preview) */
private readonly headBudget: number
/** Budget for the tail buffer (50% of total preview) */
private readonly tailBudget: number
/**
* Creates a new OutputInterceptor instance.
@ -69,15 +84,19 @@ export class OutputInterceptor {
*/
constructor(private readonly options: OutputInterceptorOptions) {
this.previewBytes = TERMINAL_PREVIEW_BYTES[options.previewSize]
this.headBudget = Math.floor(this.previewBytes / 2)
this.tailBudget = this.previewBytes - this.headBudget
this.artifactPath = path.join(options.storageDir, `cmd-${options.executionId}.txt`)
}
/**
* Write a chunk of output to the interceptor.
*
* If the accumulated output exceeds the preview threshold, the interceptor
* automatically spills to disk and switches to streaming mode. Subsequent
* chunks are written directly to the disk file.
* Output is first added to the head buffer until it's full (50% of preview budget).
* Subsequent output goes to a rolling tail buffer that keeps the most recent content.
*
* If the total output exceeds the preview threshold, the interceptor spills to disk
* for full output storage while maintaining head/tail buffers for the preview.
*
* @param chunk - The output string to write
*
@ -91,11 +110,13 @@ export class OutputInterceptor {
const chunkBytes = Buffer.byteLength(chunk, "utf8")
this.totalBytes += chunkBytes
if (!this.spilledToDisk) {
this.buffer += chunk
// Always update the head/tail preview buffers
this.addToPreviewBuffers(chunk)
if (Buffer.byteLength(this.buffer, "utf8") > this.previewBytes) {
this.spillToDisk()
// Handle disk spilling for full output preservation
if (!this.spilledToDisk) {
if (this.totalBytes > this.previewBytes) {
this.spillToDisk(chunk)
}
} else {
// Already spilling - write directly to disk
@ -103,6 +124,127 @@ export class OutputInterceptor {
}
}
/**
* Add a chunk to the head/tail preview buffers using 50/50 split strategy.
*
* Fill head first until budget exhausted, then maintain a rolling tail buffer.
*
* @private
*/
private addToPreviewBuffers(chunk: string): void {
let remaining = chunk
let remainingBytes = Buffer.byteLength(chunk, "utf8")
// First, fill the head buffer if there's room
if (this.headBytes < this.headBudget) {
const headRoom = this.headBudget - this.headBytes
if (remainingBytes <= headRoom) {
// Entire chunk fits in head
this.headBuffer += remaining
this.headBytes += remainingBytes
return
}
// Split: part goes to head, rest goes to tail
const headPortion = this.sliceByBytes(remaining, headRoom)
this.headBuffer += headPortion
this.headBytes += headRoom
remaining = remaining.slice(headPortion.length)
remainingBytes = Buffer.byteLength(remaining, "utf8")
}
// Add remainder to tail buffer
this.addToTailBuffer(remaining, remainingBytes)
}
/**
* Add content to the rolling tail buffer, dropping old content as needed.
*
* @private
*/
private addToTailBuffer(chunk: string, chunkBytes: number): void {
if (this.tailBudget === 0) {
this.omittedBytes += chunkBytes
return
}
// If this single chunk is larger than the tail budget, keep only the last tailBudget bytes
if (chunkBytes >= this.tailBudget) {
const dropped = this.tailBytes + (chunkBytes - this.tailBudget)
this.omittedBytes += dropped
this.tailBuffer = this.sliceByBytesFromEnd(chunk, this.tailBudget)
this.tailBytes = this.tailBudget
return
}
// Append to tail
this.tailBuffer += chunk
this.tailBytes += chunkBytes
// Trim from front if over budget
this.trimTailToFit()
}
/**
* Trim the tail buffer from the front to fit within the tail budget.
*
* @private
*/
private trimTailToFit(): void {
while (this.tailBytes > this.tailBudget && this.tailBuffer.length > 0) {
const excess = this.tailBytes - this.tailBudget
// Remove characters from the front until we're under budget
// We need to be careful with multi-byte characters
let removed = 0
let removeChars = 0
while (removed < excess && removeChars < this.tailBuffer.length) {
const charBytes = Buffer.byteLength(this.tailBuffer[removeChars], "utf8")
removed += charBytes
removeChars++
}
this.omittedBytes += removed
this.tailBytes -= removed
this.tailBuffer = this.tailBuffer.slice(removeChars)
}
}
/**
* Slice a string to get approximately the first N bytes (UTF-8).
*
* @private
*/
private sliceByBytes(str: string, maxBytes: number): string {
let bytes = 0
let i = 0
while (i < str.length && bytes < maxBytes) {
const charBytes = Buffer.byteLength(str[i], "utf8")
if (bytes + charBytes > maxBytes) {
break
}
bytes += charBytes
i++
}
return str.slice(0, i)
}
/**
* Slice a string to get approximately the last N bytes (UTF-8).
*
* @private
*/
private sliceByBytesFromEnd(str: string, maxBytes: number): string {
let bytes = 0
let i = str.length - 1
while (i >= 0 && bytes < maxBytes) {
const charBytes = Buffer.byteLength(str[i], "utf8")
if (bytes + charBytes > maxBytes) {
break
}
bytes += charBytes
i--
}
return str.slice(i + 1)
}
/**
* Spill buffered content to disk and switch to streaming mode.
*
@ -112,7 +254,7 @@ export class OutputInterceptor {
*
* @private
*/
private spillToDisk(): void {
private spillToDisk(currentChunk: string): void {
// Ensure directory exists
const dir = path.dirname(this.artifactPath)
if (!fs.existsSync(dir)) {
@ -120,18 +262,31 @@ export class OutputInterceptor {
}
this.writeStream = fs.createWriteStream(this.artifactPath)
this.writeStream.write(this.buffer)
this.spilledToDisk = true
// Write the full head buffer + any tail content accumulated so far
// Note: We need to reconstruct full output seen so far
// The full content before this chunk is: totalBytes - currentChunkBytes
// But we've already been tracking head/tail, so we write head + omitted + tail + current
// Actually, we need to write the complete original content
// Since we're spilling on the chunk that pushes us over, we need to write everything
// that came before plus this chunk
// Keep only preview portion in memory
this.buffer = this.buffer.slice(0, this.previewBytes)
// Reconstruct: we have headBuffer (complete head) + whatever was in tail before trimming
// For simplicity, write head + tail + current chunk (the tail already has some data)
this.writeStream.write(this.headBuffer)
if (this.tailBuffer.length > 0) {
this.writeStream.write(this.tailBuffer)
}
// Don't write currentChunk here - it was already processed into head/tail buffers
// and will be written via the streaming path
this.spilledToDisk = true
}
/**
* Finalize the interceptor and return the persisted output result.
*
* Closes any open file streams and returns a summary object containing:
* - A preview of the output (truncated to preview size)
* - A preview of the output (head + [omitted indicator] + tail)
* - The total byte count of all output
* - The path to the full output file (if truncated)
* - A flag indicating whether the output was truncated
@ -154,8 +309,15 @@ export class OutputInterceptor {
this.writeStream.end()
}
// Prepare preview
const preview = this.buffer.slice(0, this.previewBytes)
// Prepare preview: head + [omission indicator] + tail
let preview: string
if (this.omittedBytes > 0) {
const omissionIndicator = `\n[...${this.omittedBytes} bytes omitted...]\n`
preview = this.headBuffer + omissionIndicator + this.tailBuffer
} else {
// No truncation, just combine head and tail (or head alone if tail is empty)
preview = this.headBuffer + this.tailBuffer
}
return {
preview,
@ -168,13 +330,15 @@ export class OutputInterceptor {
/**
* Get the current buffer content for UI display.
*
* Returns the in-memory buffer which contains either all output (if not spilled)
* or just the preview portion (if spilled to disk).
* Returns the combined head + tail content for real-time UI updates.
* Note: Does not include the omission indicator to avoid flickering during streaming.
*
* @returns The current buffer content as a string
*/
getBufferForUI(): string {
return this.buffer
// For UI, return combined head + tail without omission indicator
// This provides a smoother streaming experience
return this.headBuffer + this.tailBuffer
}
/**

View file

@ -94,7 +94,7 @@ describe("OutputInterceptor", () => {
expect(mockWriteStream.write).toHaveBeenCalled()
})
it("should truncate preview after spilling to disk", () => {
it("should truncate preview after spilling to disk using head/tail split", () => {
const interceptor = new OutputInterceptor({
executionId: "12345",
taskId: "task-1",
@ -112,7 +112,10 @@ describe("OutputInterceptor", () => {
const result = interceptor.finalize()
expect(result.truncated).toBe(true)
expect(result.artifactPath).toBe(path.join(storageDir, "cmd-12345.txt"))
expect(Buffer.byteLength(result.preview, "utf8")).toBeLessThanOrEqual(2048)
// Preview is head (1024) + omission indicator + tail (1024)
// The omission indicator adds some extra bytes
expect(result.preview).toContain("[...")
expect(result.preview).toContain("bytes omitted...]")
})
it("should write subsequent chunks directly to disk after spilling", () => {
@ -230,20 +233,23 @@ describe("OutputInterceptor", () => {
expect(fs.createWriteStream).toHaveBeenCalledWith(path.join(storageDir, `cmd-${executionId}.txt`))
})
it("should write full output to artifact, not truncated", () => {
it("should write head and tail buffers to artifact when spilling", () => {
const interceptor = new OutputInterceptor({
executionId: "12345",
taskId: "task-1",
command: "test",
storageDir,
previewSize: "small",
previewSize: "small", // 2KB = 2048 bytes, so head=1024, tail=1024
})
const fullOutput = "x".repeat(5000)
interceptor.write(fullOutput)
// The write stream should receive the full buffer content
expect(mockWriteStream.write).toHaveBeenCalledWith(fullOutput)
// The write stream should receive the head buffer content first
// (spillToDisk writes head + tail that existed at spill time)
expect(mockWriteStream.write).toHaveBeenCalled()
// Verify that we're writing to disk
expect(interceptor.hasSpilledToDisk()).toBe(true)
})
it("should get artifact path from getArtifactPath() method", () => {
@ -282,13 +288,13 @@ describe("OutputInterceptor", () => {
expect(result.truncated).toBe(false)
})
it("should return PersistedCommandOutput for large commands", () => {
it("should return PersistedCommandOutput for large commands with head/tail preview", () => {
const interceptor = new OutputInterceptor({
executionId: "12345",
taskId: "task-1",
command: "test",
storageDir,
previewSize: "small",
previewSize: "small", // 2KB = 2048, head=1024, tail=1024
})
const largeOutput = "x".repeat(5000)
@ -299,7 +305,9 @@ describe("OutputInterceptor", () => {
expect(result.truncated).toBe(true)
expect(result.artifactPath).toBe(path.join(storageDir, "cmd-12345.txt"))
expect(result.totalBytes).toBe(Buffer.byteLength(largeOutput, "utf8"))
expect(Buffer.byteLength(result.preview, "utf8")).toBeLessThanOrEqual(2048)
// Preview should contain head + omission indicator + tail
expect(result.preview).toContain("[...")
expect(result.preview).toContain("bytes omitted...]")
})
it("should close write stream when finalizing", () => {
@ -404,13 +412,13 @@ describe("OutputInterceptor", () => {
expect(interceptor.getBufferForUI()).toBe(output)
})
it("should return truncated buffer after spilling to disk", () => {
it("should return head + tail buffer after spilling to disk", () => {
const interceptor = new OutputInterceptor({
executionId: "12345",
taskId: "task-1",
command: "test",
storageDir,
previewSize: "small",
previewSize: "small", // 2KB = 2048, head=1024, tail=1024
})
// Trigger spill
@ -418,7 +426,102 @@ describe("OutputInterceptor", () => {
interceptor.write(largeOutput)
const buffer = interceptor.getBufferForUI()
// Buffer for UI is head + tail (no omission indicator for smooth streaming)
expect(Buffer.byteLength(buffer, "utf8")).toBeLessThanOrEqual(2048)
})
})
describe("Head/Tail split behavior", () => {
it("should preserve first 50% and last 50% of output", () => {
const interceptor = new OutputInterceptor({
executionId: "12345",
taskId: "task-1",
command: "test",
storageDir,
previewSize: "small", // 2KB = 2048, head=1024, tail=1024
})
// Create identifiable head and tail content
const headContent = "HEAD".repeat(300) // 1200 bytes
const middleContent = "M".repeat(3000) // 3000 bytes (will be omitted)
const tailContent = "TAIL".repeat(300) // 1200 bytes
interceptor.write(headContent)
interceptor.write(middleContent)
interceptor.write(tailContent)
const result = interceptor.finalize()
// Should start with HEAD content (first 1024 bytes of head budget)
expect(result.preview.startsWith("HEAD")).toBe(true)
// Should end with TAIL content (last 1024 bytes)
expect(result.preview.endsWith("TAIL")).toBe(true)
// Should have omission indicator
expect(result.preview).toContain("[...")
expect(result.preview).toContain("bytes omitted...]")
})
it("should not add omission indicator when output fits in budget", () => {
const interceptor = new OutputInterceptor({
executionId: "12345",
taskId: "task-1",
command: "test",
storageDir,
previewSize: "small", // 2KB
})
const smallOutput = "Hello World\n"
interceptor.write(smallOutput)
const result = interceptor.finalize()
// No omission indicator for small output
expect(result.preview).toBe(smallOutput)
expect(result.preview).not.toContain("[...")
})
it("should handle output that exactly fills head budget", () => {
const interceptor = new OutputInterceptor({
executionId: "12345",
taskId: "task-1",
command: "test",
storageDir,
previewSize: "small", // 2KB = 2048, head=1024
})
// Write exactly 1024 bytes (head budget)
const exactHeadContent = "x".repeat(1024)
interceptor.write(exactHeadContent)
const result = interceptor.finalize()
// Should fit entirely in head, no truncation
expect(result.preview).toBe(exactHeadContent)
expect(result.truncated).toBe(false)
})
it("should split single large chunk across head and tail", () => {
const interceptor = new OutputInterceptor({
executionId: "12345",
taskId: "task-1",
command: "test",
storageDir,
previewSize: "small", // 2KB = 2048, head=1024, tail=1024
})
// Write a single chunk larger than preview budget
// First 1024 chars go to head, last 1024 chars go to tail
const content = "A".repeat(1024) + "B".repeat(2000) + "C".repeat(1024)
interceptor.write(content)
const result = interceptor.finalize()
// Head should have A's
expect(result.preview.startsWith("A")).toBe(true)
// Tail should have C's
expect(result.preview.endsWith("C")).toBe(true)
// Should have omission indicator
expect(result.preview).toContain("[...")
})
})
})