From 44c886650e1c15af87ef13f7a2938ba9bfbec2c0 Mon Sep 17 00:00:00 2001 From: Enjoy Kumawat Date: Tue, 23 Jun 2026 01:14:16 +0530 Subject: [PATCH] fix(shell): bound command output sent to the model The shell tool forwarded the full command output to the provider in its tool result. The only mediation was summarizeToolOutput, which is disabled by default, so a command printing a large payload sent it back unbounded (issue #28090 observed a 40319-byte tool result). Add truncateLlmOutput: a deterministic, codepoint-safe head+tail byte bound (MAX_LLM_OUTPUT_BYTES, 32 KiB) applied to the output segment of the tool result on normal completion and on cancellation-with-output. Error/exit-code lines are unaffected since they are appended after the output. Output within the bound is returned unchanged; enabling tool-output summarization remains the smarter, model-driven alternative. --- packages/core/src/tools/shell.test.ts | 36 ++++++++++++++++ packages/core/src/tools/shell.ts | 62 ++++++++++++++++++++++++++- 2 files changed, 96 insertions(+), 2 deletions(-) diff --git a/packages/core/src/tools/shell.test.ts b/packages/core/src/tools/shell.test.ts index e5e9fb27da5..2619664eb87 100644 --- a/packages/core/src/tools/shell.test.ts +++ b/packages/core/src/tools/shell.test.ts @@ -49,6 +49,8 @@ import { ShellTool, OUTPUT_UPDATE_INTERVAL_MS, LIVE_OUTPUT_MAX_BUFFER_CHARS, + MAX_LLM_OUTPUT_BYTES, + truncateLlmOutput, } from './shell.js'; import { debugLogger } from '../index.js'; import { type Config } from '../config/config.js'; @@ -1866,3 +1868,37 @@ EOF`; }); }); }); + +describe('truncateLlmOutput', () => { + it('returns output unchanged when within the byte bound', () => { + const small = 'hello world'; + expect(truncateLlmOutput(small)).toBe(small); + expect(truncateLlmOutput('')).toBe(''); + }); + + it('bounds output above the cap and keeps it within the limit', () => { + const huge = 'x'.repeat(MAX_LLM_OUTPUT_BYTES * 2); + const result = truncateLlmOutput(huge); + expect(Buffer.byteLength(result, 'utf8')).toBeLessThanOrEqual( + MAX_LLM_OUTPUT_BYTES, + ); + expect(result).toContain('shell output truncated'); + }); + + it('preserves head and tail content', () => { + const body = 'y'.repeat(MAX_LLM_OUTPUT_BYTES * 2); + const result = truncateLlmOutput(`HEAD_MARKER${body}TAIL_MARKER`); + expect(result.startsWith('HEAD_MARKER')).toBe(true); + expect(result.endsWith('TAIL_MARKER')).toBe(true); + }); + + it('does not split multi-byte characters and stays within the bound', () => { + const emoji = '😀'.repeat(MAX_LLM_OUTPUT_BYTES); // 4 bytes each + const result = truncateLlmOutput(emoji); + expect(Buffer.byteLength(result, 'utf8')).toBeLessThanOrEqual( + MAX_LLM_OUTPUT_BYTES, + ); + // No replacement characters from a mid-codepoint cut. + expect(result).not.toContain('�'); + }); +}); diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index 9ad657febdf..fe0ebabf326 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -61,6 +61,13 @@ import { wrapUntrusted } from '../utils/textUtils.js'; export const OUTPUT_UPDATE_INTERVAL_MS = 1000; export const LIVE_OUTPUT_MAX_BUFFER_CHARS = 100_000; +// Maximum bytes of command output forwarded to the model in a tool result when +// output summarization (getSummarizeToolOutputConfig) is not enabled. Without +// this bound a single command can send an arbitrarily large payload back to the +// provider (issue #28090). Tunable; enable tool-output summarization for a +// smarter, model-driven bound. +export const MAX_LLM_OUTPUT_BYTES = 32 * 1024; + // Delay so user does not see the output of the process before the process is moved to the background. const BACKGROUND_DELAY_MS = 200; const SHOW_NL_DESCRIPTION_THRESHOLD = 150; @@ -83,6 +90,55 @@ function trimLiveOutputBuffer(output: string): string { return output.slice(startIndex); } +// Takes at most `maxBytes` of UTF-8 output from the start (or end) of a string +// without splitting a multi-byte character. +function takeBytes(output: string, maxBytes: number, fromEnd: boolean): string { + if (maxBytes <= 0) { + return ''; + } + const chars = Array.from(output); + if (fromEnd) { + chars.reverse(); + } + let bytes = 0; + const kept: string[] = []; + for (const ch of chars) { + const size = Buffer.byteLength(ch, 'utf8'); + if (bytes + size > maxBytes) { + break; + } + bytes += size; + kept.push(ch); + } + if (fromEnd) { + kept.reverse(); + } + return kept.join(''); +} + +/** + * Bounds command output sent to the model. If the output exceeds `maxBytes` + * (UTF-8), keeps the head and tail — where command context and trailing errors + * usually live — and replaces the middle with a truncation marker. Returns the + * output unchanged when it is already within the bound. + */ +export function truncateLlmOutput( + output: string, + maxBytes: number = MAX_LLM_OUTPUT_BYTES, +): string { + const totalBytes = Buffer.byteLength(output, 'utf8'); + if (totalBytes <= maxBytes) { + return output; + } + const marker = `\n\n[... shell output truncated: showing ~${maxBytes} of ${totalBytes} bytes. Re-run with a narrower command (e.g. grep/head/tail) or enable tool-output summarization. ...]\n\n`; + const budget = Math.max(0, maxBytes - Buffer.byteLength(marker, 'utf8')); + const headBudget = Math.ceil(budget * 0.75); + const tailBudget = budget - headBudget; + const head = takeBytes(output, headBudget, false); + const tail = takeBytes(output, tailBudget, true); + return `${head}${marker}${tail}`; +} + export interface ShellToolParams { command: string; description?: string; @@ -784,7 +840,7 @@ export class ShellToolInvocation extends BaseToolInvocation< 'Command was cancelled by user before it could complete.'; } if (result.output.trim()) { - llmContent += ` Below is the output before it was cancelled:\n${result.output}`; + llmContent += ` Below is the output before it was cancelled:\n${truncateLlmOutput(result.output)}`; } else { llmContent += ' There was no output before it was cancelled.'; } @@ -798,7 +854,9 @@ export class ShellToolInvocation extends BaseToolInvocation< } else { // Create a formatted error string for display, replacing the wrapper command // with the user-facing command. - const llmContentParts = [`Output: ${result.output || '(empty)'}`]; + const llmContentParts = [ + `Output: ${truncateLlmOutput(result.output) || '(empty)'}`, + ]; if (result.error) { const finalError = result.error.message.replaceAll(