From 9850f01894f88e01d4d3b0cd9d176853e26b341c Mon Sep 17 00:00:00 2001 From: Christian Gunderman Date: Mon, 2 Mar 2026 18:48:56 -0800 Subject: [PATCH] feat(core): centralize line truncation and apply to all tools --- .../core/src/scheduler/tool-executor.test.ts | 14 +-- packages/core/src/scheduler/tool-executor.ts | 24 +++--- packages/core/src/utils/textUtils.ts | 86 +++++++++++++++++++ 3 files changed, 104 insertions(+), 20 deletions(-) diff --git a/packages/core/src/scheduler/tool-executor.test.ts b/packages/core/src/scheduler/tool-executor.test.ts index d5f92806f5..e21e1f51c9 100644 --- a/packages/core/src/scheduler/tool-executor.test.ts +++ b/packages/core/src/scheduler/tool-executor.test.ts @@ -40,23 +40,17 @@ vi.mock('../core/coreToolHookTriggers.js', () => ({ executeToolWithHooks: vi.fn(), })); // Mock runInDevTraceSpan -const runInDevTraceSpan = vi.hoisted(() => - vi.fn(async (opts, fn) => { +vi.mock('../telemetry/trace.js', () => ({ + runInDevTraceSpan: vi.fn(async (opts, fn) => { const metadata = { attributes: opts.attributes || {} }; return fn({ metadata, endSpan: vi.fn(), }); }), -); +})); -vi.mock('../index.js', async (importOriginal) => { - const actual = await importOriginal>(); - return { - ...actual, - runInDevTraceSpan, - }; -}); +import { runInDevTraceSpan } from '../telemetry/trace.js'; describe('ToolExecutor', () => { let config: Config; diff --git a/packages/core/src/scheduler/tool-executor.ts b/packages/core/src/scheduler/tool-executor.ts index e358c53c8b..904d893b67 100644 --- a/packages/core/src/scheduler/tool-executor.ts +++ b/packages/core/src/scheduler/tool-executor.ts @@ -5,19 +5,17 @@ */ import type { - ToolCallRequestInfo, - ToolCallResponseInfo, ToolResult, - Config, ToolResultDisplay, ToolLiveOutput, -} from '../index.js'; -import { - ToolErrorType, - ToolOutputTruncatedEvent, - logToolOutputTruncated, - runInDevTraceSpan, -} from '../index.js'; +} from '../tools/tools.js'; +import type { Config } from '../config/config.js'; +import { ToolErrorType } from '../tools/tool-error.js'; +import { logToolOutputTruncated } from '../telemetry/loggers.js'; +import { ToolOutputTruncatedEvent } from '../telemetry/types.js'; +import { runInDevTraceSpan } from '../telemetry/trace.js'; +import { truncateLongLines } from '../utils/textUtils.js'; +import { DEFAULT_MAX_LINE_LENGTH } from '../utils/constants.js'; import { SHELL_TOOL_NAME } from '../tools/tool-names.js'; import { DiscoveredMCPTool } from '../tools/mcp-tool.js'; import { ShellToolInvocation } from '../tools/shell.js'; @@ -34,6 +32,8 @@ import type { ErroredToolCall, SuccessfulToolCall, CancelledToolCall, + ToolCallRequestInfo, + ToolCallResponseInfo, } from './types.js'; import { CoreToolCallStatus } from './types.js'; import { @@ -237,6 +237,10 @@ export class ToolExecutor { const toolName = call.request.originalRequestName || call.request.name; const callId = call.request.callId; + if (typeof content === 'string') { + content = truncateLongLines(content, DEFAULT_MAX_LINE_LENGTH); + } + if (typeof content === 'string' && toolName === SHELL_TOOL_NAME) { const threshold = this.config.getTruncateToolOutputThreshold(); diff --git a/packages/core/src/utils/textUtils.ts b/packages/core/src/utils/textUtils.ts index 1066896bc4..d2f7b2877d 100644 --- a/packages/core/src/utils/textUtils.ts +++ b/packages/core/src/utils/textUtils.ts @@ -83,6 +83,92 @@ export function truncateString( return str.slice(0, maxLength) + suffix; } +/** + * Options for line truncation. + */ +export interface TruncateLineOptions { + maxLength: number; + centerIndex?: number; + includeStats?: boolean; +} + +/** + * Truncates a single line, optionally centering around a specific index. + */ +export function truncateLine( + line: string, + options: TruncateLineOptions, +): string { + const { maxLength, centerIndex, includeStats } = options; + const originalLength = line.length; + + if (originalLength <= maxLength) { + return line; + } + + let truncated: string; + let start = 0; + let end = maxLength; + + if (centerIndex !== undefined) { + const halfLength = Math.floor(maxLength / 2); + start = Math.max(0, centerIndex - halfLength); + end = start + maxLength; + + if (end > originalLength) { + end = originalLength; + start = Math.max(0, end - maxLength); + } + + const prefix = start > 0 ? '... ' : ''; + const suffix = end < originalLength ? ' ...' : ''; + truncated = prefix + line.substring(start, end) + suffix; + } else { + truncated = line.substring(0, maxLength) + ' ...'; + } + + if (includeStats) { + const stats = + centerIndex !== undefined + ? `[Truncated: showing characters ${start} to ${end} of ${originalLength}]` + : `[Truncated to ${maxLength} characters (total length: ${originalLength})]`; + + if (centerIndex !== undefined) { + // For centered, we put stats at both ends if they are truncated + const prefix = start > 0 ? `${stats} ... ` : ''; + const suffix = end < originalLength ? ` ... ${stats}` : ''; + truncated = prefix + line.substring(start, end) + suffix; + } else { + truncated = line.substring(0, maxLength) + ` ${stats}`; + } + } + + return truncated; +} + +/** + * Truncates all lines in a string that exceed the maximum length. + */ +export function truncateLongLines( + text: string, + maxLength: number, + includeStats = true, +): string { + if (!text) return text; + const lines = text.split('\n'); + let modified = false; + + const processed = lines.map((line) => { + if (line.length > maxLength) { + modified = true; + return truncateLine(line, { maxLength, includeStats }); + } + return line; + }); + + return modified ? processed.join('\n') : text; +} + /** * Safely replaces placeholders in a template string with values from a replacements object. * This performs a single-pass replacement to prevent double-interpolation attacks.