mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
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:
parent
22346f0224
commit
cf7472945d
2 changed files with 304 additions and 37 deletions
|
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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("[...")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue