Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions packages/core/src/tools/shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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('�');
});
});
62 changes: 60 additions & 2 deletions packages/core/src/tools/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Comment on lines +95 to +99

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Calling Array.from(output) on the entire, potentially huge command output can cause severe performance degradation or Out-Of-Memory (OOM) crashes. However, using string.slice() directly to truncate the string before operating on grapheme clusters can split multi-byte Unicode characters (such as emojis or surrogate pairs) at the boundary. To prevent character splitting while maintaining performance, we should use Intl.Segmenter to safely iterate over grapheme clusters up to the limit without loading the entire array into memory.

function takeBytes(output: string, maxBytes: number, fromEnd: boolean): string {
  if (maxBytes <= 0) {
    return '';
  }
  const segmenter = new Intl.Segmenter();
  if (fromEnd) {
    const safeSlice = output.slice(-maxBytes * 2);
    const segments = Array.from(segmenter.segment(safeSlice));
    return segments.slice(-maxBytes).map(s => s.segment).join('');
  } else {
    const segments = segmenter.segment(output);
    let result = '';
    let count = 0;
    for (const { segment } of segments) {
      if (count >= maxBytes) break;
      result += segment;
      count++;
    }
    return result;
  }
}
References
  1. When truncating strings that may contain multi-byte Unicode characters (e.g., emojis), use methods that operate on grapheme clusters (like Intl.Segmenter or Array.from()) instead of UTF-16 code units (string.length, string.slice()) to prevent character splitting.

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;
Expand Down Expand Up @@ -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.';
}
Expand All @@ -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(
Expand Down
Loading