Skip to content

Commit df97fff

Browse files
committed
fix: add character limit to prevent terminal output context explosion
- Enhanced truncateOutput function to accept character limits alongside line limits - Character limits take priority over line limits to prevent context window explosion - Added terminalOutputCharacterLimit setting (default: 100,000 characters) - Updated all terminal output processing to use both limits - Added comprehensive tests for character limit functionality Fixes #5775
1 parent d513b9c commit df97fff

File tree

10 files changed

+182
-12
lines changed

10 files changed

+182
-12
lines changed

packages/types/src/global-settings.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ export const globalSettingsSchema = z.object({
7575
maxReadFileLine: z.number().optional(),
7676

7777
terminalOutputLineLimit: z.number().optional(),
78+
terminalOutputCharacterLimit: z.number().optional(),
7879
terminalShellIntegrationTimeout: z.number().optional(),
7980
terminalShellIntegrationDisabled: z.boolean().optional(),
8081
terminalCommandDelay: z.number().optional(),
@@ -212,6 +213,7 @@ export const EVALS_SETTINGS: RooCodeSettings = {
212213
soundVolume: 0.5,
213214

214215
terminalOutputLineLimit: 500,
216+
terminalOutputCharacterLimit: 100000,
215217
terminalShellIntegrationTimeout: 30000,
216218
terminalCommandDelay: 0,
217219
terminalPowershellCounter: false,

src/core/environment/getEnvironmentDetails.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,11 @@ export async function getEnvironmentDetails(cline: Task, includeFileDetails: boo
2525

2626
const clineProvider = cline.providerRef.deref()
2727
const state = await clineProvider?.getState()
28-
const { terminalOutputLineLimit = 500, maxWorkspaceFiles = 200 } = state ?? {}
28+
const {
29+
terminalOutputLineLimit = 500,
30+
terminalOutputCharacterLimit = 100000,
31+
maxWorkspaceFiles = 200,
32+
} = state ?? {}
2933

3034
// It could be useful for cline to know if the user went from one or no
3135
// file to another between messages, so we always include this context.
@@ -111,7 +115,11 @@ export async function getEnvironmentDetails(cline: Task, includeFileDetails: boo
111115
let newOutput = TerminalRegistry.getUnretrievedOutput(busyTerminal.id)
112116

113117
if (newOutput) {
114-
newOutput = Terminal.compressTerminalOutput(newOutput, terminalOutputLineLimit)
118+
newOutput = Terminal.compressTerminalOutput(
119+
newOutput,
120+
terminalOutputLineLimit,
121+
terminalOutputCharacterLimit,
122+
)
115123
terminalDetails += `\n### New Output\n${newOutput}`
116124
}
117125
}
@@ -139,7 +147,11 @@ export async function getEnvironmentDetails(cline: Task, includeFileDetails: boo
139147
let output = process.getUnretrievedOutput()
140148

141149
if (output) {
142-
output = Terminal.compressTerminalOutput(output, terminalOutputLineLimit)
150+
output = Terminal.compressTerminalOutput(
151+
output,
152+
terminalOutputLineLimit,
153+
terminalOutputCharacterLimit,
154+
)
143155
terminalOutputs.push(`Command: \`${process.command}\`\n${output}`)
144156
}
145157
}

src/core/tools/executeCommandTool.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,11 @@ export async function executeCommandTool(
6363
const executionId = cline.lastMessageTs?.toString() ?? Date.now().toString()
6464
const clineProvider = await cline.providerRef.deref()
6565
const clineProviderState = await clineProvider?.getState()
66-
const { terminalOutputLineLimit = 500, terminalShellIntegrationDisabled = false } = clineProviderState ?? {}
66+
const {
67+
terminalOutputLineLimit = 500,
68+
terminalOutputCharacterLimit = 100000,
69+
terminalShellIntegrationDisabled = false,
70+
} = clineProviderState ?? {}
6771

6872
// Get command execution timeout from VSCode configuration (in seconds)
6973
const commandExecutionTimeoutSeconds = vscode.workspace
@@ -79,6 +83,7 @@ export async function executeCommandTool(
7983
customCwd,
8084
terminalShellIntegrationDisabled,
8185
terminalOutputLineLimit,
86+
terminalOutputCharacterLimit,
8287
commandExecutionTimeout,
8388
}
8489

@@ -125,6 +130,7 @@ export type ExecuteCommandOptions = {
125130
customCwd?: string
126131
terminalShellIntegrationDisabled?: boolean
127132
terminalOutputLineLimit?: number
133+
terminalOutputCharacterLimit?: number
128134
commandExecutionTimeout?: number
129135
}
130136

@@ -136,6 +142,7 @@ export async function executeCommand(
136142
customCwd,
137143
terminalShellIntegrationDisabled = false,
138144
terminalOutputLineLimit = 500,
145+
terminalOutputCharacterLimit = 100000,
139146
commandExecutionTimeout = 0,
140147
}: ExecuteCommandOptions,
141148
): Promise<[boolean, ToolResponse]> {
@@ -171,7 +178,11 @@ export async function executeCommand(
171178
const callbacks: RooTerminalCallbacks = {
172179
onLine: async (lines: string, process: RooTerminalProcess) => {
173180
accumulatedOutput += lines
174-
const compressedOutput = Terminal.compressTerminalOutput(accumulatedOutput, terminalOutputLineLimit)
181+
const compressedOutput = Terminal.compressTerminalOutput(
182+
accumulatedOutput,
183+
terminalOutputLineLimit,
184+
terminalOutputCharacterLimit,
185+
)
175186
const status: CommandExecutionStatus = { executionId, status: "output", output: compressedOutput }
176187
clineProvider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) })
177188

@@ -190,7 +201,11 @@ export async function executeCommand(
190201
} catch (_error) {}
191202
},
192203
onCompleted: (output: string | undefined) => {
193-
result = Terminal.compressTerminalOutput(output ?? "", terminalOutputLineLimit)
204+
result = Terminal.compressTerminalOutput(
205+
output ?? "",
206+
terminalOutputLineLimit,
207+
terminalOutputCharacterLimit,
208+
)
194209
cline.say("command_output", result)
195210
completed = true
196211
},

src/core/webview/ClineProvider.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1392,6 +1392,7 @@ export class ClineProvider
13921392
cachedChromeHostUrl,
13931393
writeDelayMs,
13941394
terminalOutputLineLimit,
1395+
terminalOutputCharacterLimit,
13951396
terminalShellIntegrationTimeout,
13961397
terminalShellIntegrationDisabled,
13971398
terminalCommandDelay,
@@ -1491,6 +1492,7 @@ export class ClineProvider
14911492
cachedChromeHostUrl: cachedChromeHostUrl,
14921493
writeDelayMs: writeDelayMs ?? 1000,
14931494
terminalOutputLineLimit: terminalOutputLineLimit ?? 500,
1495+
terminalOutputCharacterLimit: terminalOutputCharacterLimit ?? 100000,
14941496
terminalShellIntegrationTimeout: terminalShellIntegrationTimeout ?? Terminal.defaultShellIntegrationTimeout,
14951497
terminalShellIntegrationDisabled: terminalShellIntegrationDisabled ?? false,
14961498
terminalCommandDelay: terminalCommandDelay ?? 0,
@@ -1658,6 +1660,7 @@ export class ClineProvider
16581660
fuzzyMatchThreshold: stateValues.fuzzyMatchThreshold ?? 1.0,
16591661
writeDelayMs: stateValues.writeDelayMs ?? 1000,
16601662
terminalOutputLineLimit: stateValues.terminalOutputLineLimit ?? 500,
1663+
terminalOutputCharacterLimit: stateValues.terminalOutputCharacterLimit ?? 100000,
16611664
terminalShellIntegrationTimeout:
16621665
stateValues.terminalShellIntegrationTimeout ?? Terminal.defaultShellIntegrationTimeout,
16631666
terminalShellIntegrationDisabled: stateValues.terminalShellIntegrationDisabled ?? false,

src/core/webview/webviewMessageHandler.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1085,6 +1085,10 @@ export const webviewMessageHandler = async (
10851085
await updateGlobalState("terminalOutputLineLimit", message.value)
10861086
await provider.postStateToWebview()
10871087
break
1088+
case "terminalOutputCharacterLimit":
1089+
await updateGlobalState("terminalOutputCharacterLimit", message.value)
1090+
await provider.postStateToWebview()
1091+
break
10881092
case "terminalShellIntegrationTimeout":
10891093
await updateGlobalState("terminalShellIntegrationTimeout", message.value)
10901094
await provider.postStateToWebview()

src/integrations/misc/__tests__/extract-text.spec.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,108 @@ describe("truncateOutput", () => {
306306
const expectedLines = ["line1", "", "[...10 lines omitted...]", "", "line12", "line13", "line14", "line15"]
307307
expect(resultLines).toEqual(expectedLines)
308308
})
309+
310+
describe("character limit functionality", () => {
311+
it("returns original content when no character limit provided", () => {
312+
const content = "a".repeat(1000)
313+
expect(truncateOutput(content, undefined, undefined)).toBe(content)
314+
})
315+
316+
it("returns original content when characters are under limit", () => {
317+
const content = "a".repeat(100)
318+
expect(truncateOutput(content, undefined, 200)).toBe(content)
319+
})
320+
321+
it("truncates content by character limit with 20/80 split", () => {
322+
// Create content with 1000 characters
323+
const content = "a".repeat(1000)
324+
325+
// Set character limit to 100
326+
const result = truncateOutput(content, undefined, 100)
327+
328+
// Should keep:
329+
// - First 20 characters (20% of 100)
330+
// - Last 80 characters (80% of 100)
331+
// - Omission indicator in between
332+
const expectedStart = "a".repeat(20)
333+
const expectedEnd = "a".repeat(80)
334+
const expected = expectedStart + "\n[...900 characters omitted...]\n" + expectedEnd
335+
336+
expect(result).toBe(expected)
337+
})
338+
339+
it("prioritizes character limit over line limit", () => {
340+
// Create content with few lines but many characters per line
341+
const longLine = "a".repeat(500)
342+
const content = `${longLine}\n${longLine}\n${longLine}`
343+
344+
// Set both limits - character limit should take precedence
345+
const result = truncateOutput(content, 10, 100)
346+
347+
// Should truncate by character limit, not line limit
348+
const expectedStart = "a".repeat(20)
349+
const expectedEnd = "a".repeat(80)
350+
// Total content: 1502 chars, limit: 100, so 1402 chars omitted
351+
const expected = expectedStart + "\n[...1402 characters omitted...]\n" + expectedEnd
352+
353+
expect(result).toBe(expected)
354+
})
355+
356+
it("falls back to line limit when character limit is satisfied", () => {
357+
// Create content with many short lines
358+
const lines = Array.from({ length: 25 }, (_, i) => `line${i + 1}`)
359+
const content = lines.join("\n")
360+
361+
// Character limit is high enough, so line limit should apply
362+
const result = truncateOutput(content, 10, 10000)
363+
364+
// Should truncate by line limit
365+
const expectedLines = [
366+
"line1",
367+
"line2",
368+
"",
369+
"[...15 lines omitted...]",
370+
"",
371+
"line18",
372+
"line19",
373+
"line20",
374+
"line21",
375+
"line22",
376+
"line23",
377+
"line24",
378+
"line25",
379+
]
380+
expect(result).toBe(expectedLines.join("\n"))
381+
})
382+
383+
it("handles edge case where character limit equals content length", () => {
384+
const content = "exactly100chars".repeat(6) + "1234" // exactly 100 chars
385+
const result = truncateOutput(content, undefined, 100)
386+
expect(result).toBe(content)
387+
})
388+
389+
it("handles very small character limits", () => {
390+
const content = "a".repeat(1000)
391+
const result = truncateOutput(content, undefined, 10)
392+
393+
// 20% of 10 = 2, 80% of 10 = 8
394+
const expected = "aa\n[...990 characters omitted...]\n" + "a".repeat(8)
395+
expect(result).toBe(expected)
396+
})
397+
398+
it("handles character limit with mixed content", () => {
399+
const content = "Hello world! This is a test with mixed content including numbers 123 and symbols @#$%"
400+
const result = truncateOutput(content, undefined, 50)
401+
402+
// 20% of 50 = 10, 80% of 50 = 40
403+
const expectedStart = content.slice(0, 10) // "Hello worl"
404+
const expectedEnd = content.slice(-40) // last 40 chars
405+
const omittedChars = content.length - 50
406+
const expected = expectedStart + `\n[...${omittedChars} characters omitted...]\n` + expectedEnd
407+
408+
expect(result).toBe(expected)
409+
})
410+
})
309411
})
310412

311413
describe("applyRunLengthEncoding", () => {

src/integrations/misc/extract-text.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -136,16 +136,41 @@ export function stripLineNumbers(content: string, aggressive: boolean = false):
136136
* with a clear indicator of how many lines were omitted in between.
137137
*
138138
* @param content The multi-line string to truncate
139-
* @param lineLimit Optional maximum number of lines to keep. If not provided or 0, returns the original content
140-
* @returns The truncated string with an indicator of omitted lines, or the original content if no truncation needed
139+
* @param lineLimit Optional maximum number of lines to keep. If not provided or 0, no line limit is applied
140+
* @param characterLimit Optional maximum number of characters to keep. If not provided or 0, no character limit is applied
141+
* @returns The truncated string with an indicator of omitted content, or the original content if no truncation needed
141142
*
142143
* @example
143144
* // With 10 line limit on 25 lines of content:
144145
* // - Keeps first 2 lines (20% of 10)
145146
* // - Keeps last 8 lines (80% of 10)
146147
* // - Adds "[...15 lines omitted...]" in between
148+
*
149+
* @example
150+
* // With character limit on long single line:
151+
* // - Keeps first 20% of characters
152+
* // - Keeps last 80% of characters
153+
* // - Adds "[...X characters omitted...]" in between
147154
*/
148-
export function truncateOutput(content: string, lineLimit?: number): string {
155+
export function truncateOutput(content: string, lineLimit?: number, characterLimit?: number): string {
156+
// If no limits are specified, return original content
157+
if (!lineLimit && !characterLimit) {
158+
return content
159+
}
160+
161+
// Character limit takes priority over line limit
162+
if (characterLimit && content.length > characterLimit) {
163+
const beforeLimit = Math.floor(characterLimit * 0.2) // 20% of characters before
164+
const afterLimit = characterLimit - beforeLimit // remaining 80% after
165+
166+
const startSection = content.slice(0, beforeLimit)
167+
const endSection = content.slice(-afterLimit)
168+
const omittedChars = content.length - characterLimit
169+
170+
return startSection + `\n[...${omittedChars} characters omitted...]\n` + endSection
171+
}
172+
173+
// If character limit is not exceeded or not specified, check line limit
149174
if (!lineLimit) {
150175
return content
151176
}

src/integrations/terminal/BaseTerminal.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -262,19 +262,24 @@ export abstract class BaseTerminal implements RooTerminal {
262262
}
263263

264264
/**
265-
* Compresses terminal output by applying run-length encoding and truncating to line limit
265+
* Compresses terminal output by applying run-length encoding and truncating to line and character limits
266266
* @param input The terminal output to compress
267+
* @param lineLimit Maximum number of lines to keep
268+
* @param characterLimit Optional maximum number of characters to keep (defaults to 100,000)
267269
* @returns The compressed terminal output
268270
*/
269-
public static compressTerminalOutput(input: string, lineLimit: number): string {
271+
public static compressTerminalOutput(input: string, lineLimit: number, characterLimit?: number): string {
270272
let processedInput = input
271273

272274
if (BaseTerminal.compressProgressBar) {
273275
processedInput = processCarriageReturns(processedInput)
274276
processedInput = processBackspaces(processedInput)
275277
}
276278

277-
return truncateOutput(applyRunLengthEncoding(processedInput), lineLimit)
279+
// Default character limit to prevent context window explosion
280+
const effectiveCharLimit = characterLimit ?? 100_000
281+
282+
return truncateOutput(applyRunLengthEncoding(processedInput), lineLimit, effectiveCharLimit)
278283
}
279284

280285
/**

src/shared/ExtensionMessage.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,7 @@ export type ExtensionState = Pick<
201201
// | "maxReadFileLine" // Optional in GlobalSettings, required here.
202202
| "maxConcurrentFileReads" // Optional in GlobalSettings, required here.
203203
| "terminalOutputLineLimit"
204+
| "terminalOutputCharacterLimit"
204205
| "terminalShellIntegrationTimeout"
205206
| "terminalShellIntegrationDisabled"
206207
| "terminalCommandDelay"

src/shared/WebviewMessage.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ export interface WebviewMessage {
113113
| "deleteMessage"
114114
| "submitEditedMessage"
115115
| "terminalOutputLineLimit"
116+
| "terminalOutputCharacterLimit"
116117
| "terminalShellIntegrationTimeout"
117118
| "terminalShellIntegrationDisabled"
118119
| "terminalCommandDelay"

0 commit comments

Comments
 (0)