feat: align output limits with terminal integration spec

- Update preview sizes: 2KB/4KB/8KB → 5KB/10KB/20KB (default 10KB)
- Update read_command_output default limit: 32KB → 40KB
- Match spec's MODEL_TRUNCATION_BYTES (10KB) for preview
- Match spec's DEFAULT_MAX_OUTPUT_TOKENS (10000 tokens × 4 bytes = 40KB) for retrieval
- Update all related tests and documentation
This commit is contained in:
Hannes Rudolph 2026-01-27 17:28:00 -07:00
parent 5711f1f32b
commit 70787f7a4a
5 changed files with 69 additions and 68 deletions

View file

@ -29,9 +29,9 @@ export const DEFAULT_WRITE_DELAY_MS = 1000
* the LLM decides to retrieve more via `read_command_output`. Larger previews
* mean more immediate context but consume more of the context window.
*
* - `small`: 2KB preview - Best for long-running commands with verbose output
* - `medium`: 4KB preview - Balanced default for most use cases
* - `large`: 8KB preview - Best when commands produce critical info early
* - `small`: 5KB preview - Best for long-running commands with verbose output
* - `medium`: 10KB preview - Balanced default for most use cases
* - `large`: 20KB preview - Best when commands produce critical info early
*
* @see OutputInterceptor - Uses this setting to determine when to spill to disk
* @see PersistedCommandOutput - Contains the resulting preview and artifact reference
@ -46,14 +46,14 @@ export type TerminalOutputPreviewSize = "small" | "medium" | "large"
* to disk and made available via the `read_command_output` tool.
*/
export const TERMINAL_PREVIEW_BYTES: Record<TerminalOutputPreviewSize, number> = {
small: 2048, // 2KB
medium: 4096, // 4KB
large: 8192, // 8KB
small: 5 * 1024, // 5KB
medium: 10 * 1024, // 10KB
large: 20 * 1024, // 20KB
}
/**
* Default terminal output preview size.
* The "medium" (4KB) setting provides a good balance between immediate
* The "medium" (10KB) setting provides a good balance between immediate
* visibility and context window conservation for most use cases.
*/
export const DEFAULT_TERMINAL_OUTPUT_PREVIEW_SIZE: TerminalOutputPreviewSize = "medium"

View file

@ -22,13 +22,13 @@ Parameters:
- artifact_id: (required) The artifact filename from the truncated output message (e.g., "cmd-1706119234567.txt")
- search: (optional) Pattern to filter lines. Supports regex or literal strings. Case-insensitive.
- offset: (optional) Byte offset to start reading from. Default: 0. Use for pagination.
- limit: (optional) Maximum bytes to return. Default: 32KB.
- limit: (optional) Maximum bytes to return. Default: 40KB.
Example: Reading truncated command output
{ "artifact_id": "cmd-1706119234567.txt" }
Example: Reading with pagination (after first 32KB)
{ "artifact_id": "cmd-1706119234567.txt", "offset": 32768 }
Example: Reading with pagination (after first 40KB)
{ "artifact_id": "cmd-1706119234567.txt", "offset": 40960 }
Example: Searching for errors in build output
{ "artifact_id": "cmd-1706119234567.txt", "search": "error|failed|Error" }
@ -42,7 +42,7 @@ const SEARCH_DESCRIPTION = `Optional regex or literal pattern to filter lines (c
const OFFSET_DESCRIPTION = `Byte offset to start reading from (default: 0, for pagination)`
const LIMIT_DESCRIPTION = `Maximum bytes to return (default: 32KB)`
const LIMIT_DESCRIPTION = `Maximum bytes to return (default: 40KB)`
export default {
type: "function",

View file

@ -6,8 +6,8 @@ import { getTaskDirectoryPath } from "../../utils/storage"
import { BaseTool, ToolCallbacks } from "./BaseTool"
/** Default byte limit for read operations (32KB) */
const DEFAULT_LIMIT = 32 * 1024 // 32KB default limit
/** Default byte limit for read operations (40KB) */
const DEFAULT_LIMIT = 40 * 1024 // 40KB default limit
/**
* Parameters accepted by the read_command_output tool.

View file

@ -159,16 +159,16 @@ describe("ReadCommandOutputTool", () => {
})
describe("Pagination (offset/limit)", () => {
it("should use default limit of 32KB", async () => {
it("should use default limit of 40KB", async () => {
const artifactId = "cmd-1706119234567.txt"
const largeContent = "x".repeat(50 * 1024) // 50KB
const fileSize = Buffer.byteLength(largeContent, "utf8")
vi.mocked(fs.stat).mockResolvedValue({ size: fileSize } as any)
// Mock read to return only up to default limit (32KB)
// Mock read to return only up to default limit (40KB)
mockFileHandle.read.mockImplementation((buf: Buffer) => {
const defaultLimit = 32 * 1024
const defaultLimit = 40 * 1024
const bytesToRead = Math.min(buf.length, defaultLimit)
buf.write(largeContent.slice(0, bytesToRead))
return Promise.resolve({ bytesRead: bytesToRead })

View file

@ -59,7 +59,7 @@ describe("OutputInterceptor", () => {
taskId: "task-1",
command: "echo test",
storageDir,
previewSize: "small", // 2KB
previewSize: "small", // 5KB
})
const smallOutput = "Hello World\n"
@ -81,18 +81,18 @@ describe("OutputInterceptor", () => {
taskId: "task-1",
command: "echo test",
storageDir,
previewSize: "small", // 2KB = 2048 bytes
previewSize: "small", // 5KB = 5120 bytes
})
// Write enough data to exceed 2KB threshold
const chunk = "x".repeat(1024) // 1KB chunk
interceptor.write(chunk) // 1KB - should stay in memory
expect(interceptor.hasSpilledToDisk()).toBe(false)
// Write enough data to exceed 5KB threshold
const chunk = "x".repeat(2 * 1024) // 2KB chunk
interceptor.write(chunk) // 2KB - should stay in memory
expect(interceptor.hasSpilledToDisk()).toBe(false)
interceptor.write(chunk) // 3KB - should trigger spill
interceptor.write(chunk) // 4KB - should stay in memory
expect(interceptor.hasSpilledToDisk()).toBe(false)
interceptor.write(chunk) // 6KB - should trigger spill
expect(interceptor.hasSpilledToDisk()).toBe(true)
expect(fs.createWriteStream).toHaveBeenCalledWith(path.join(storageDir, "cmd-12345.txt"))
expect(mockWriteStream.write).toHaveBeenCalled()
@ -104,11 +104,11 @@ describe("OutputInterceptor", () => {
taskId: "task-1",
command: "echo test",
storageDir,
previewSize: "small", // 2KB
previewSize: "small", // 5KB
})
// Write data that exceeds threshold
const chunk = "x".repeat(3000)
const chunk = "x".repeat(6000)
interceptor.write(chunk)
expect(interceptor.hasSpilledToDisk()).toBe(true)
@ -131,8 +131,8 @@ describe("OutputInterceptor", () => {
previewSize: "small",
})
// Trigger spill
const largeChunk = "x".repeat(3000)
// Trigger spill (must exceed 5KB = 5120 bytes)
const largeChunk = "x".repeat(6000)
interceptor.write(largeChunk)
expect(interceptor.hasSpilledToDisk()).toBe(true)
@ -148,7 +148,7 @@ describe("OutputInterceptor", () => {
})
describe("Threshold settings", () => {
it("should handle small (2KB) threshold correctly", () => {
it("should handle small (5KB) threshold correctly", () => {
const interceptor = new OutputInterceptor({
executionId: "12345",
taskId: "task-1",
@ -157,16 +157,16 @@ describe("OutputInterceptor", () => {
previewSize: "small",
})
// Write exactly 2KB
interceptor.write("x".repeat(2048))
// Write exactly 5KB
interceptor.write("x".repeat(5 * 1024))
expect(interceptor.hasSpilledToDisk()).toBe(false)
// Write more to exceed 2KB
// Write more to exceed 5KB
interceptor.write("x")
expect(interceptor.hasSpilledToDisk()).toBe(true)
})
it("should handle medium (4KB) threshold correctly", () => {
it("should handle medium (10KB) threshold correctly", () => {
const interceptor = new OutputInterceptor({
executionId: "12345",
taskId: "task-1",
@ -175,16 +175,16 @@ describe("OutputInterceptor", () => {
previewSize: "medium",
})
// Write exactly 4KB
interceptor.write("x".repeat(4096))
// Write exactly 10KB
interceptor.write("x".repeat(10 * 1024))
expect(interceptor.hasSpilledToDisk()).toBe(false)
// Write more to exceed 4KB
// Write more to exceed 10KB
interceptor.write("x")
expect(interceptor.hasSpilledToDisk()).toBe(true)
})
it("should handle large (8KB) threshold correctly", () => {
it("should handle large (20KB) threshold correctly", () => {
const interceptor = new OutputInterceptor({
executionId: "12345",
taskId: "task-1",
@ -193,11 +193,11 @@ describe("OutputInterceptor", () => {
previewSize: "large",
})
// Write exactly 8KB
interceptor.write("x".repeat(8192))
// Write exactly 20KB
interceptor.write("x".repeat(20 * 1024))
expect(interceptor.hasSpilledToDisk()).toBe(false)
// Write more to exceed 8KB
// Write more to exceed 20KB
interceptor.write("x")
expect(interceptor.hasSpilledToDisk()).toBe(true)
})
@ -215,8 +215,8 @@ describe("OutputInterceptor", () => {
previewSize: "small",
})
// Trigger spill
interceptor.write("x".repeat(3000))
// Trigger spill (must exceed 5KB = 5120 bytes)
interceptor.write("x".repeat(6000))
expect(fs.mkdirSync).toHaveBeenCalledWith(storageDir, { recursive: true })
})
@ -231,8 +231,8 @@ describe("OutputInterceptor", () => {
previewSize: "small",
})
// Trigger spill
interceptor.write("x".repeat(3000))
// Trigger spill (must exceed 5KB = 5120 bytes)
interceptor.write("x".repeat(6000))
expect(fs.createWriteStream).toHaveBeenCalledWith(path.join(storageDir, `cmd-${executionId}.txt`))
})
@ -243,10 +243,10 @@ describe("OutputInterceptor", () => {
taskId: "task-1",
command: "test",
storageDir,
previewSize: "small", // 2KB = 2048 bytes, so head=1024, tail=1024
previewSize: "small", // 5KB = 5120 bytes, so head=2560, tail=2560
})
const fullOutput = "x".repeat(5000)
const fullOutput = "x".repeat(10000)
interceptor.write(fullOutput)
// The write stream should receive the head buffer content first
@ -298,10 +298,10 @@ describe("OutputInterceptor", () => {
taskId: "task-1",
command: "test",
storageDir,
previewSize: "small", // 2KB = 2048, head=1024, tail=1024
previewSize: "small", // 5KB = 5120, head=2560, tail=2560
})
const largeOutput = "x".repeat(5000)
const largeOutput = "x".repeat(10000)
interceptor.write(largeOutput)
const result = await interceptor.finalize()
@ -323,8 +323,8 @@ describe("OutputInterceptor", () => {
previewSize: "small",
})
// Trigger spill
interceptor.write("x".repeat(3000))
// Trigger spill (must exceed 5KB = 5120 bytes)
interceptor.write("x".repeat(6000))
await interceptor.finalize()
expect(mockWriteStream.end).toHaveBeenCalled()
@ -339,13 +339,14 @@ describe("OutputInterceptor", () => {
previewSize: "small",
})
const output = "x".repeat(5000)
// Must exceed 5KB = 5120 bytes to trigger truncation
const output = "x".repeat(6000)
interceptor.write(output)
const result = await interceptor.finalize()
expect(result).toHaveProperty("preview")
expect(result).toHaveProperty("totalBytes", 5000)
expect(result).toHaveProperty("totalBytes", 6000)
expect(result).toHaveProperty("artifactPath")
expect(result).toHaveProperty("truncated", true)
expect(result.artifactPath).toMatch(/cmd-12345\.txt$/)
@ -422,16 +423,16 @@ describe("OutputInterceptor", () => {
taskId: "task-1",
command: "test",
storageDir,
previewSize: "small", // 2KB = 2048, head=1024, tail=1024
previewSize: "small", // 5KB = 5120, head=2560, tail=2560
})
// Trigger spill
const largeOutput = "x".repeat(5000)
const largeOutput = "x".repeat(10000)
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)
expect(Buffer.byteLength(buffer, "utf8")).toBeLessThanOrEqual(5120)
})
})
@ -442,13 +443,13 @@ describe("OutputInterceptor", () => {
taskId: "task-1",
command: "test",
storageDir,
previewSize: "small", // 2KB = 2048, head=1024, tail=1024
previewSize: "small", // 5KB = 5120, head=2560, tail=2560
})
// 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
const headContent = "HEAD".repeat(750) // 3000 bytes
const middleContent = "M".repeat(6000) // 6000 bytes (will be omitted)
const tailContent = "TAIL".repeat(750) // 3000 bytes
interceptor.write(headContent)
interceptor.write(middleContent)
@ -456,9 +457,9 @@ describe("OutputInterceptor", () => {
const result = await interceptor.finalize()
// Should start with HEAD content (first 1024 bytes of head budget)
// Should start with HEAD content (first 2560 bytes of head budget)
expect(result.preview.startsWith("HEAD")).toBe(true)
// Should end with TAIL content (last 1024 bytes)
// Should end with TAIL content (last 2560 bytes)
expect(result.preview.endsWith("TAIL")).toBe(true)
// Should have omission indicator
expect(result.preview).toContain("[...")
@ -471,7 +472,7 @@ describe("OutputInterceptor", () => {
taskId: "task-1",
command: "test",
storageDir,
previewSize: "small", // 2KB
previewSize: "small", // 5KB
})
const smallOutput = "Hello World\n"
@ -490,11 +491,11 @@ describe("OutputInterceptor", () => {
taskId: "task-1",
command: "test",
storageDir,
previewSize: "small", // 2KB = 2048, head=1024
previewSize: "small", // 5KB = 5120, head=2560
})
// Write exactly 1024 bytes (head budget)
const exactHeadContent = "x".repeat(1024)
// Write exactly 2560 bytes (head budget)
const exactHeadContent = "x".repeat(2560)
interceptor.write(exactHeadContent)
const result = await interceptor.finalize()
@ -510,12 +511,12 @@ describe("OutputInterceptor", () => {
taskId: "task-1",
command: "test",
storageDir,
previewSize: "small", // 2KB = 2048, head=1024, tail=1024
previewSize: "small", // 5KB = 5120, head=2560, tail=2560
})
// 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)
// First 2560 chars go to head, last 2560 chars go to tail
const content = "A".repeat(2560) + "B".repeat(4000) + "C".repeat(2560)
interceptor.write(content)
const result = await interceptor.finalize()