diff --git a/packages/agent-core/src/harness/utils/truncate.ts b/packages/agent-core/src/harness/utils/truncate.ts index 06356cb7bac..c4740ce35f4 100644 --- a/packages/agent-core/src/harness/utils/truncate.ts +++ b/packages/agent-core/src/harness/utils/truncate.ts @@ -44,6 +44,14 @@ export interface TruncationOptions { maxBytes?: number; } +interface ResolvedTruncationInput { + lines: string[]; + totalLines: number; + totalBytes: number; + maxLines: number; + maxBytes: number; +} + interface RuntimeBuffer { byteLength(content: string, encoding: "utf8"): number; } @@ -137,6 +145,50 @@ export function formatSize(bytes: number): string { return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; } +function resolveTruncationInput( + content: string, + options: TruncationOptions, +): ResolvedTruncationInput { + const maxLines = options.maxLines ?? DEFAULT_MAX_LINES; + const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; + const totalBytes = utf8ByteLength(content); + const lines = splitLinesForCounting(content); + return { + lines, + totalLines: lines.length, + totalBytes, + maxLines, + maxBytes, + }; +} + +function buildTruncationResult( + input: ResolvedTruncationInput, + params: { + content: string; + truncated: boolean; + truncatedBy: TruncationResult["truncatedBy"]; + outputLines: number; + outputBytes?: number; + lastLinePartial?: boolean; + firstLineExceedsLimit?: boolean; + }, +): TruncationResult { + return { + content: params.content, + truncated: params.truncated, + truncatedBy: params.truncatedBy, + totalLines: input.totalLines, + totalBytes: input.totalBytes, + outputLines: params.outputLines, + outputBytes: params.outputBytes ?? utf8ByteLength(params.content), + lastLinePartial: params.lastLinePartial ?? false, + firstLineExceedsLimit: params.firstLineExceedsLimit ?? false, + maxLines: input.maxLines, + maxBytes: input.maxBytes, + }; +} + /** * Truncate content from the head (keep first N lines/bytes). * Suitable for file reads where you want to see the beginning. @@ -145,58 +197,39 @@ export function formatSize(bytes: number): string { * returns empty content with firstLineExceedsLimit=true. */ export function truncateHead(content: string, options: TruncationOptions = {}): TruncationResult { - const maxLines = options.maxLines ?? DEFAULT_MAX_LINES; - const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; + const input = resolveTruncationInput(content, options); - const totalBytes = utf8ByteLength(content); - const lines = splitLinesForCounting(content); - const totalLines = lines.length; - - // Check if no truncation needed - if (totalLines <= maxLines && totalBytes <= maxBytes) { - return { + if (input.totalLines <= input.maxLines && input.totalBytes <= input.maxBytes) { + return buildTruncationResult(input, { content, truncated: false, truncatedBy: null, - totalLines, - totalBytes, - outputLines: totalLines, - outputBytes: totalBytes, - lastLinePartial: false, - firstLineExceedsLimit: false, - maxLines, - maxBytes, - }; + outputLines: input.totalLines, + outputBytes: input.totalBytes, + }); } - // Check if first line alone exceeds byte limit - const firstLineBytes = utf8ByteLength(lines[0]); - if (firstLineBytes > maxBytes) { - return { + const firstLineBytes = utf8ByteLength(input.lines[0]); + if (firstLineBytes > input.maxBytes) { + return buildTruncationResult(input, { content: "", truncated: true, truncatedBy: "bytes", - totalLines, - totalBytes, outputLines: 0, outputBytes: 0, - lastLinePartial: false, firstLineExceedsLimit: true, - maxLines, - maxBytes, - }; + }); } - // Collect complete lines that fit const outputLinesArr: string[] = []; let outputBytesCount = 0; - let truncatedBy: "lines" | "bytes" = totalLines > maxLines ? "lines" : "bytes"; + let truncatedBy: "lines" | "bytes" = input.totalLines > input.maxLines ? "lines" : "bytes"; - for (let i = 0; i < lines.length && i < maxLines; i++) { - const line = lines[i]; + for (let i = 0; i < input.lines.length && i < input.maxLines; i++) { + const line = input.lines[i]; const lineBytes = utf8ByteLength(line) + (i > 0 ? 1 : 0); // +1 for newline - if (outputBytesCount + lineBytes > maxBytes) { + if (outputBytesCount + lineBytes > input.maxBytes) { truncatedBy = "bytes"; break; } @@ -205,27 +238,22 @@ export function truncateHead(content: string, options: TruncationOptions = {}): outputBytesCount += lineBytes; } - // If we exited due to line limit - if (totalLines > maxLines && outputLinesArr.length >= maxLines && outputBytesCount <= maxBytes) { + if ( + input.totalLines > input.maxLines && + outputLinesArr.length >= input.maxLines && + outputBytesCount <= input.maxBytes + ) { truncatedBy = "lines"; } const outputContent = outputLinesArr.join("\n"); - const finalOutputBytes = utf8ByteLength(outputContent); - return { + return buildTruncationResult(input, { content: outputContent, truncated: true, truncatedBy, - totalLines, - totalBytes, outputLines: outputLinesArr.length, - outputBytes: finalOutputBytes, - lastLinePartial: false, - firstLineExceedsLimit: false, - maxLines, - maxBytes, - }; + }); } /** @@ -235,46 +263,33 @@ export function truncateHead(content: string, options: TruncationOptions = {}): * May return partial first line if the last line of original content exceeds byte limit. */ export function truncateTail(content: string, options: TruncationOptions = {}): TruncationResult { - const maxLines = options.maxLines ?? DEFAULT_MAX_LINES; - const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; + const input = resolveTruncationInput(content, options); - const totalBytes = utf8ByteLength(content); - const lines = splitLinesForCounting(content); - const totalLines = lines.length; - - // Check if no truncation needed - if (totalLines <= maxLines && totalBytes <= maxBytes) { - return { + if (input.totalLines <= input.maxLines && input.totalBytes <= input.maxBytes) { + return buildTruncationResult(input, { content, truncated: false, truncatedBy: null, - totalLines, - totalBytes, - outputLines: totalLines, - outputBytes: totalBytes, - lastLinePartial: false, - firstLineExceedsLimit: false, - maxLines, - maxBytes, - }; + outputLines: input.totalLines, + outputBytes: input.totalBytes, + }); } - // Work backwards from the end const outputLinesArr: string[] = []; let outputBytesCount = 0; - let truncatedBy: "lines" | "bytes" = totalLines > maxLines ? "lines" : "bytes"; + let truncatedBy: "lines" | "bytes" = input.totalLines > input.maxLines ? "lines" : "bytes"; let lastLinePartial = false; - for (let i = lines.length - 1; i >= 0 && outputLinesArr.length < maxLines; i--) { - const line = lines[i]; + for (let i = input.lines.length - 1; i >= 0 && outputLinesArr.length < input.maxLines; i--) { + const line = input.lines[i]; const lineBytes = utf8ByteLength(line) + (outputLinesArr.length > 0 ? 1 : 0); // +1 for newline - if (outputBytesCount + lineBytes > maxBytes) { + if (outputBytesCount + lineBytes > input.maxBytes) { truncatedBy = "bytes"; // Edge case: if we haven't added ANY lines yet and this line exceeds maxBytes, // take the end of the line (partial) if (outputLinesArr.length === 0) { - const truncatedLine = truncateStringToBytesFromEnd(line, maxBytes); + const truncatedLine = truncateStringToBytesFromEnd(line, input.maxBytes); outputLinesArr.unshift(truncatedLine); outputBytesCount = utf8ByteLength(truncatedLine); lastLinePartial = true; @@ -286,27 +301,23 @@ export function truncateTail(content: string, options: TruncationOptions = {}): outputBytesCount += lineBytes; } - // If we exited due to line limit - if (totalLines > maxLines && outputLinesArr.length >= maxLines && outputBytesCount <= maxBytes) { + if ( + input.totalLines > input.maxLines && + outputLinesArr.length >= input.maxLines && + outputBytesCount <= input.maxBytes + ) { truncatedBy = "lines"; } const outputContent = outputLinesArr.join("\n"); - const finalOutputBytes = utf8ByteLength(outputContent); - return { + return buildTruncationResult(input, { content: outputContent, truncated: true, truncatedBy, - totalLines, - totalBytes, outputLines: outputLinesArr.length, - outputBytes: finalOutputBytes, lastLinePartial, - firstLineExceedsLimit: false, - maxLines, - maxBytes, - }; + }); } /**