Skip to content

Commit 0e0c986

Browse files
roomotedaniel-lxs
authored andcommitted
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 e297026 commit 0e0c986

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
@@ -85,6 +85,7 @@ export const globalSettingsSchema = z.object({
8585
maxReadFileLine: z.number().optional(),
8686

8787
terminalOutputLineLimit: z.number().optional(),
88+
terminalOutputCharacterLimit: z.number().optional(),
8889
terminalShellIntegrationTimeout: z.number().optional(),
8990
terminalShellIntegrationDisabled: z.boolean().optional(),
9091
terminalCommandDelay: z.number().optional(),
@@ -227,6 +228,7 @@ export const EVALS_SETTINGS: RooCodeSettings = {
227228
soundVolume: 0.5,
228229

229230
terminalOutputLineLimit: 500,
231+
terminalOutputCharacterLimit: 100000,
230232
terminalShellIntegrationTimeout: 30000,
231233
terminalCommandDelay: 0,
232234
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
@@ -87,6 +91,7 @@ export async function executeCommandTool(
8791
customCwd,
8892
terminalShellIntegrationDisabled,
8993
terminalOutputLineLimit,
94+
terminalOutputCharacterLimit,
9095
commandExecutionTimeout,
9196
}
9297

@@ -133,6 +138,7 @@ export type ExecuteCommandOptions = {
133138
customCwd?: string
134139
terminalShellIntegrationDisabled?: boolean
135140
terminalOutputLineLimit?: number
141+
terminalOutputCharacterLimit?: number
136142
commandExecutionTimeout?: number
137143
}
138144

@@ -144,6 +150,7 @@ export async function executeCommand(
144150
customCwd,
145151
terminalShellIntegrationDisabled = false,
146152
terminalOutputLineLimit = 500,
153+
terminalOutputCharacterLimit = 100000,
147154
commandExecutionTimeout = 0,
148155
}: ExecuteCommandOptions,
149156
): Promise<[boolean, ToolResponse]> {
@@ -179,7 +186,11 @@ export async function executeCommand(
179186
const callbacks: RooTerminalCallbacks = {
180187
onLine: async (lines: string, process: RooTerminalProcess) => {
181188
accumulatedOutput += lines
182-
const compressedOutput = Terminal.compressTerminalOutput(accumulatedOutput, terminalOutputLineLimit)
189+
const compressedOutput = Terminal.compressTerminalOutput(
190+
accumulatedOutput,
191+
terminalOutputLineLimit,
192+
terminalOutputCharacterLimit,
193+
)
183194
const status: CommandExecutionStatus = { executionId, status: "output", output: compressedOutput }
184195
clineProvider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) })
185196

@@ -198,7 +209,11 @@ export async function executeCommand(
198209
} catch (_error) {}
199210
},
200211
onCompleted: (output: string | undefined) => {
201-
result = Terminal.compressTerminalOutput(output ?? "", terminalOutputLineLimit)
212+
result = Terminal.compressTerminalOutput(
213+
output ?? "",
214+
terminalOutputLineLimit,
215+
terminalOutputCharacterLimit,
216+
)
202217
cline.say("command_output", result)
203218
completed = true
204219
},

src/core/webview/ClineProvider.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1393,6 +1393,7 @@ export class ClineProvider
13931393
cachedChromeHostUrl,
13941394
writeDelayMs,
13951395
terminalOutputLineLimit,
1396+
terminalOutputCharacterLimit,
13961397
terminalShellIntegrationTimeout,
13971398
terminalShellIntegrationDisabled,
13981399
terminalCommandDelay,
@@ -1493,6 +1494,7 @@ export class ClineProvider
14931494
cachedChromeHostUrl: cachedChromeHostUrl,
14941495
writeDelayMs: writeDelayMs ?? DEFAULT_WRITE_DELAY_MS,
14951496
terminalOutputLineLimit: terminalOutputLineLimit ?? 500,
1497+
terminalOutputCharacterLimit: terminalOutputCharacterLimit ?? 100000,
14961498
terminalShellIntegrationTimeout: terminalShellIntegrationTimeout ?? Terminal.defaultShellIntegrationTimeout,
14971499
terminalShellIntegrationDisabled: terminalShellIntegrationDisabled ?? false,
14981500
terminalCommandDelay: terminalCommandDelay ?? 0,
@@ -1662,6 +1664,7 @@ export class ClineProvider
16621664
fuzzyMatchThreshold: stateValues.fuzzyMatchThreshold ?? 1.0,
16631665
writeDelayMs: stateValues.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS,
16641666
terminalOutputLineLimit: stateValues.terminalOutputLineLimit ?? 500,
1667+
terminalOutputCharacterLimit: stateValues.terminalOutputCharacterLimit ?? 100000,
16651668
terminalShellIntegrationTimeout:
16661669
stateValues.terminalShellIntegrationTimeout ?? Terminal.defaultShellIntegrationTimeout,
16671670
terminalShellIntegrationDisabled: stateValues.terminalShellIntegrationDisabled ?? false,

src/core/webview/webviewMessageHandler.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1052,6 +1052,10 @@ export const webviewMessageHandler = async (
10521052
await updateGlobalState("terminalOutputLineLimit", message.value)
10531053
await provider.postStateToWebview()
10541054
break
1055+
case "terminalOutputCharacterLimit":
1056+
await updateGlobalState("terminalOutputCharacterLimit", message.value)
1057+
await provider.postStateToWebview()
1058+
break
10551059
case "terminalShellIntegrationTimeout":
10561060
await updateGlobalState("terminalShellIntegrationTimeout", message.value)
10571061
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
@@ -205,6 +205,7 @@ export type ExtensionState = Pick<
205205
// | "maxReadFileLine" // Optional in GlobalSettings, required here.
206206
| "maxConcurrentFileReads" // Optional in GlobalSettings, required here.
207207
| "terminalOutputLineLimit"
208+
| "terminalOutputCharacterLimit"
208209
| "terminalShellIntegrationTimeout"
209210
| "terminalShellIntegrationDisabled"
210211
| "terminalCommandDelay"

src/shared/WebviewMessage.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ export interface WebviewMessage {
116116
| "submitEditedMessage"
117117
| "editMessageConfirm"
118118
| "terminalOutputLineLimit"
119+
| "terminalOutputCharacterLimit"
119120
| "terminalShellIntegrationTimeout"
120121
| "terminalShellIntegrationDisabled"
121122
| "terminalCommandDelay"

0 commit comments

Comments
 (0)