From b813a8b92bf9b906608b9644490c9fec424f8476 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 7 Dec 2025 01:11:31 +0100 Subject: [PATCH] Implement tool result truncation with actionable notices (#134) - read: actionable notices with offset for continuation - First line > 30KB: return empty + bash command suggestion - Hit limit: '[Showing lines X-Y of Z. Use offset=N to continue]' - bash: tail truncation with temp file - Notice includes line range + temp file path - Edge case: last line > 30KB shows partial - grep: pre-truncate match lines to 500 chars - '[... truncated]' suffix on long lines - Notice for match limit and line truncation - find/ls: result/entry limit notices - '[N results limit reached. Use limit=M for more]' - All notices now in text content (LLM sees them) - TUI simplified (notices render as part of output) - Never return partial lines (except bash edge case) --- packages/ai/test/context-overflow.test.ts | 3 +- packages/coding-agent/docs/truncation.md | 235 ++++++++++++++++++ packages/coding-agent/src/tools/bash.ts | 17 +- packages/coding-agent/src/tools/find.ts | 42 ++-- packages/coding-agent/src/tools/grep.ts | 72 ++++-- packages/coding-agent/src/tools/ls.ts | 40 +-- packages/coding-agent/src/tools/read.ts | 59 +++-- packages/coding-agent/src/tools/truncate.ts | 115 +++++---- .../coding-agent/src/tui/tool-execution.ts | 46 +--- 9 files changed, 465 insertions(+), 164 deletions(-) create mode 100644 packages/coding-agent/docs/truncation.md diff --git a/packages/ai/test/context-overflow.test.ts b/packages/ai/test/context-overflow.test.ts index 94f3e4e0..e2fcce04 100644 --- a/packages/ai/test/context-overflow.test.ts +++ b/packages/ai/test/context-overflow.test.ts @@ -118,7 +118,8 @@ describe("Context overflow error handling", () => { describe.skipIf(!process.env.OPENAI_API_KEY)("OpenAI Completions", () => { it("gpt-4o-mini - should detect overflow via isContextOverflow", async () => { - const model = getModel("openai", "gpt-4o-mini"); + const model = { ...getModel("openai", "gpt-4o-mini") }; + model.api = "openai-completions" as any; const result = await testContextOverflow(model, process.env.OPENAI_API_KEY!); logResult(result); diff --git a/packages/coding-agent/docs/truncation.md b/packages/coding-agent/docs/truncation.md new file mode 100644 index 00000000..7651ca54 --- /dev/null +++ b/packages/coding-agent/docs/truncation.md @@ -0,0 +1,235 @@ +# Tool Output Truncation + +## Limits + +- **Line limit**: 2000 lines +- **Byte limit**: 30KB +- **Grep line limit**: 500 chars per match line + +Whichever limit is hit first wins. **Never return partial lines** (except bash edge case). + +--- + +## read + +Head truncation (first N lines). Has offset/limit params for continuation. + +### Scenarios + +**First line > 30KB:** +``` +LLM sees: +[Line 1 is 50KB, exceeds 30KB limit. Use bash to read: head -c 30000 path/to/file] + +Details: +{ truncation: { truncated: true, truncatedBy: "bytes", outputLines: 0, ... } } +``` + +**Hit line limit (2000 lines, < 30KB):** +``` +LLM sees: +[lines 1-2000 content] + +[Showing lines 1-2000 of 5000. Use offset=2001 to continue] + +Details: +{ truncation: { truncated: true, truncatedBy: "lines", outputLines: 2000, totalLines: 5000 } } +``` + +**Hit byte limit (< 2000 lines, 30KB):** +``` +LLM sees: +[lines 1-500 content] + +[Showing lines 1-500 of 5000 (30KB limit). Use offset=501 to continue] + +Details: +{ truncation: { truncated: true, truncatedBy: "bytes", outputLines: 500, totalLines: 5000 } } +``` + +**With offset, hit line limit (e.g., offset=1000):** +``` +LLM sees: +[lines 1000-2999 content] + +[Showing lines 1000-2999 of 5000. Use offset=3000 to continue] + +Details: +{ truncation: { truncatedBy: "lines", ... } } +``` + +**With offset, hit byte limit (e.g., offset=1000, 30KB after 500 lines):** +``` +LLM sees: +[lines 1000-1499 content] + +[Showing lines 1000-1499 of 5000 (30KB limit). Use offset=1500 to continue] + +Details: +{ truncation: { truncatedBy: "bytes", outputLines: 500, ... } } +``` + +**With offset, first line at offset > 30KB (e.g., offset=1000, line 1000 is 50KB):** +``` +LLM sees: +[Line 1000 is 50KB, exceeds 30KB limit. Use bash: sed -n '1000p' file | head -c 30000] + +Details: +{ truncation: { truncated: true, truncatedBy: "bytes", outputLines: 0 } } +``` + +--- + +## bash + +Tail truncation (last N lines). Writes full output to temp file if truncated. + +### Scenarios + +**Hit line limit (2000 lines):** +``` +LLM sees: +[lines 48001-50000 content] + +[Showing lines 48001-50000 of 50000. Full output: /tmp/pi-bash-xxx.log] + +Details: +{ truncation: { truncated: true, truncatedBy: "lines", outputLines: 2000, totalLines: 50000 }, fullOutputPath: "/tmp/..." } +``` + +**Hit byte limit (< 2000 lines, 30KB):** +``` +LLM sees: +[lines 49501-50000 content] + +[Showing lines 49501-50000 of 50000 (30KB limit). Full output: /tmp/pi-bash-xxx.log] + +Details: +{ truncation: { truncatedBy: "bytes", ... }, fullOutputPath: "/tmp/..." } +``` + +**Last line alone > 30KB (edge case, partial OK here):** +``` +LLM sees: +[last 30KB of final line] + +[Showing last 30KB of line 50000 (line is 100KB). Full output: /tmp/pi-bash-xxx.log] + +Details: +{ truncation: { truncatedBy: "bytes", lastLinePartial: true }, fullOutputPath: "/tmp/..." } +``` + +--- + +## grep + +Head truncation. Primary limit: 100 matches. Each match line truncated to 500 chars. + +### Scenarios + +**Hit match limit (100 matches):** +``` +LLM sees: +file.ts:10: matching content here... +file.ts:25: another match... +... + +[100 matches limit reached. Use limit=200 for more, or refine pattern] + +Details: +{ matchLimitReached: 100 } +``` + +**Hit byte limit (< 100 matches, 30KB):** +``` +LLM sees: +[matches that fit in 30KB] + +[30KB limit reached (50 of 100+ matches shown)] + +Details: +{ truncation: { truncatedBy: "bytes", ... } } +``` + +**Match lines truncated (any line > 500 chars):** +``` +LLM sees: +file.ts:10: very long matching content that exceeds 500 chars gets cut off here... [truncated] +file.ts:25: normal match + +[Some lines truncated to 500 chars. Use read tool to see full lines] + +Details: +{ linesTruncated: true } +``` + +--- + +## find + +Head truncation. Primary limit: 1000 results. File paths only (never > 30KB each). + +### Scenarios + +**Hit result limit (1000 results):** +``` +LLM sees: +src/file1.ts +src/file2.ts +[998 more paths] + +[1000 results limit reached. Use limit=2000 for more, or refine pattern] + +Details: +{ resultLimitReached: 1000 } +``` + +**Hit byte limit (unlikely, < 1000 results, 30KB):** +``` +LLM sees: +[paths that fit] + +[30KB limit reached] + +Details: +{ truncation: { truncatedBy: "bytes", ... } } +``` + +--- + +## ls + +Head truncation. Primary limit: 500 entries. Entry names only (never > 30KB each). + +### Scenarios + +**Hit entry limit (500 entries):** +``` +LLM sees: +.gitignore +README.md +src/ +[497 more entries] + +[500 entries limit reached. Use limit=1000 for more] + +Details: +{ entryLimitReached: 500 } +``` + +**Hit byte limit (unlikely):** +``` +LLM sees: +[entries that fit] + +[30KB limit reached] + +Details: +{ truncation: { truncatedBy: "bytes", ... } } +``` + +--- + +## TUI Display + +`tool-execution.ts` reads `details.truncation` and related fields to display truncation notices in warning color. The LLM text content and TUI display show the same information. diff --git a/packages/coding-agent/src/tools/bash.ts b/packages/coding-agent/src/tools/bash.ts index 836e8a79..26a9c90d 100644 --- a/packages/coding-agent/src/tools/bash.ts +++ b/packages/coding-agent/src/tools/bash.ts @@ -6,7 +6,7 @@ import type { AgentTool } from "@mariozechner/pi-ai"; import { Type } from "@sinclair/typebox"; import { spawn, spawnSync } from "child_process"; import { SettingsManager } from "../settings-manager.js"; -import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, type TruncationResult, truncateTail } from "./truncate.js"; +import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateTail } from "./truncate.js"; let cachedShellConfig: { shell: string; args: string[] } | null = null; @@ -256,11 +256,26 @@ export const bashTool: AgentTool = { // Build details with truncation info let details: BashToolDetails | undefined; + if (truncation.truncated) { details = { truncation, fullOutputPath: tempFilePath, }; + + // Build actionable notice + const startLine = truncation.totalLines - truncation.outputLines + 1; + const endLine = truncation.totalLines; + + if (truncation.lastLinePartial) { + // Edge case: last line alone > 30KB + const lastLineSize = formatSize(Buffer.byteLength(fullOutput.split("\n").pop() || "", "utf-8")); + outputText += `\n\n[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize}). Full output: ${tempFilePath}]`; + } else if (truncation.truncatedBy === "lines") { + outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}. Full output: ${tempFilePath}]`; + } else { + outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Full output: ${tempFilePath}]`; + } } if (code !== 0 && code !== null) { diff --git a/packages/coding-agent/src/tools/find.ts b/packages/coding-agent/src/tools/find.ts index cb8a7336..6cc4b0c0 100644 --- a/packages/coding-agent/src/tools/find.ts +++ b/packages/coding-agent/src/tools/find.ts @@ -6,7 +6,7 @@ import { globSync } from "glob"; import { homedir } from "os"; import path from "path"; import { ensureTool } from "../tools-manager.js"; -import { DEFAULT_MAX_BYTES, type TruncationResult, truncateHead } from "./truncate.js"; +import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate.js"; /** * Expand ~ to home directory @@ -160,25 +160,39 @@ export const findTool: AgentTool = { relativized.push(relativePath); } - const rawOutput = relativized.join("\n"); - let details: FindToolDetails | undefined; - // Check if we hit the result limit - const hitResultLimit = relativized.length >= effectiveLimit; + const resultLimitReached = relativized.length >= effectiveLimit; - // Apply byte truncation + // Apply byte truncation (no line limit since we already have result limit) + const rawOutput = relativized.join("\n"); const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER }); - const resultOutput = truncation.content; - // Include truncation info in details (result limit or byte limit) - if (hitResultLimit || truncation.truncated) { - details = { - truncation: truncation.truncated ? truncation : undefined, - resultLimitReached: hitResultLimit ? effectiveLimit : undefined, - }; + let resultOutput = truncation.content; + const details: FindToolDetails = {}; + + // Build notices + const notices: string[] = []; + + if (resultLimitReached) { + notices.push( + `${effectiveLimit} results limit reached. Use limit=${effectiveLimit * 2} for more, or refine pattern`, + ); + details.resultLimitReached = effectiveLimit; } - resolve({ content: [{ type: "text", text: resultOutput }], details }); + if (truncation.truncated) { + notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`); + details.truncation = truncation; + } + + if (notices.length > 0) { + resultOutput += `\n\n[${notices.join(". ")}]`; + } + + resolve({ + content: [{ type: "text", text: resultOutput }], + details: Object.keys(details).length > 0 ? details : undefined, + }); } catch (e: any) { signal?.removeEventListener("abort", onAbort); reject(e); diff --git a/packages/coding-agent/src/tools/grep.ts b/packages/coding-agent/src/tools/grep.ts index 185a12dd..d984a6df 100644 --- a/packages/coding-agent/src/tools/grep.ts +++ b/packages/coding-agent/src/tools/grep.ts @@ -6,7 +6,14 @@ import { readFileSync, type Stats, statSync } from "fs"; import { homedir } from "os"; import path from "path"; import { ensureTool } from "../tools-manager.js"; -import { DEFAULT_MAX_BYTES, type TruncationResult, truncateHead } from "./truncate.js"; +import { + DEFAULT_MAX_BYTES, + formatSize, + GREP_MAX_LINE_LENGTH, + type TruncationResult, + truncateHead, + truncateLine, +} from "./truncate.js"; /** * Expand ~ to home directory @@ -40,12 +47,13 @@ const DEFAULT_LIMIT = 100; interface GrepToolDetails { truncation?: TruncationResult; matchLimitReached?: number; + linesTruncated?: boolean; } export const grepTool: AgentTool = { name: "grep", label: "grep", - description: `Search file contents for a pattern. Returns matching lines with file paths and line numbers. Respects .gitignore. Output is truncated to ${DEFAULT_LIMIT} matches or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first).`, + description: `Search file contents for a pattern. Returns matching lines with file paths and line numbers. Respects .gitignore. Output is truncated to ${DEFAULT_LIMIT} matches or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Long lines are truncated to ${GREP_MAX_LINE_LENGTH} chars.`, parameters: grepSchema, execute: async ( _toolCallId: string, @@ -148,7 +156,8 @@ export const grepTool: AgentTool = { const rl = createInterface({ input: child.stdout }); let stderr = ""; let matchCount = 0; - let truncated = false; + let matchLimitReached = false; + let linesTruncated = false; let aborted = false; let killedDueToLimit = false; const outputLines: string[] = []; @@ -176,7 +185,7 @@ export const grepTool: AgentTool = { stderr += chunk.toString(); }); - const formatBlock = (filePath: string, lineNumber: number) => { + const formatBlock = (filePath: string, lineNumber: number): string[] => { const relativePath = formatPath(filePath); const lines = getFileLines(filePath); if (!lines.length) { @@ -192,10 +201,16 @@ export const grepTool: AgentTool = { const sanitized = lineText.replace(/\r/g, ""); const isMatchLine = current === lineNumber; + // Truncate long lines + const { text: truncatedText, wasTruncated } = truncateLine(sanitized); + if (wasTruncated) { + linesTruncated = true; + } + if (isMatchLine) { - block.push(`${relativePath}:${current}: ${sanitized}`); + block.push(`${relativePath}:${current}: ${truncatedText}`); } else { - block.push(`${relativePath}-${current}- ${sanitized}`); + block.push(`${relativePath}-${current}- ${truncatedText}`); } } @@ -224,7 +239,7 @@ export const grepTool: AgentTool = { } if (matchCount >= effectiveLimit) { - truncated = true; + matchLimitReached = true; stopChild(true); } } @@ -256,22 +271,45 @@ export const grepTool: AgentTool = { return; } - // Apply byte truncation + // Apply byte truncation (no line limit since we already have match limit) const rawOutput = outputLines.join("\n"); const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER }); - const output = truncation.content; - let details: GrepToolDetails | undefined; + let output = truncation.content; + const details: GrepToolDetails = {}; - // Include truncation info in details (match limit or byte limit) - if (truncated || truncation.truncated) { - details = { - truncation: truncation.truncated ? truncation : undefined, - matchLimitReached: truncated ? effectiveLimit : undefined, - }; + // Build notices + const notices: string[] = []; + + if (matchLimitReached) { + notices.push( + `${effectiveLimit} matches limit reached. Use limit=${effectiveLimit * 2} for more, or refine pattern`, + ); + details.matchLimitReached = effectiveLimit; } - settle(() => resolve({ content: [{ type: "text", text: output }], details })); + if (truncation.truncated) { + notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`); + details.truncation = truncation; + } + + if (linesTruncated) { + notices.push( + `Some lines truncated to ${GREP_MAX_LINE_LENGTH} chars. Use read tool to see full lines`, + ); + details.linesTruncated = true; + } + + if (notices.length > 0) { + output += `\n\n[${notices.join(". ")}]`; + } + + settle(() => + resolve({ + content: [{ type: "text", text: output }], + details: Object.keys(details).length > 0 ? details : undefined, + }), + ); }); } catch (err) { settle(() => reject(err as Error)); diff --git a/packages/coding-agent/src/tools/ls.ts b/packages/coding-agent/src/tools/ls.ts index 6dcb4749..9b9c4b56 100644 --- a/packages/coding-agent/src/tools/ls.ts +++ b/packages/coding-agent/src/tools/ls.ts @@ -3,7 +3,7 @@ import { Type } from "@sinclair/typebox"; import { existsSync, readdirSync, statSync } from "fs"; import { homedir } from "os"; import nodePath from "path"; -import { DEFAULT_MAX_BYTES, type TruncationResult, truncateHead } from "./truncate.js"; +import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate.js"; /** * Expand ~ to home directory @@ -76,11 +76,11 @@ export const lsTool: AgentTool = { // Format entries with directory indicators const results: string[] = []; - let truncated = false; + let entryLimitReached = false; for (const entry of entries) { if (results.length >= effectiveLimit) { - truncated = true; + entryLimitReached = true; break; } @@ -107,22 +107,34 @@ export const lsTool: AgentTool = { return; } + // Apply byte truncation (no line limit since we already have entry limit) const rawOutput = results.join("\n"); - let details: LsToolDetails | undefined; - - // Apply byte truncation const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER }); - const output = truncation.content; - // Include truncation info in details (entry limit or byte limit) - if (truncated || truncation.truncated) { - details = { - truncation: truncation.truncated ? truncation : undefined, - entryLimitReached: truncated ? effectiveLimit : undefined, - }; + let output = truncation.content; + const details: LsToolDetails = {}; + + // Build notices + const notices: string[] = []; + + if (entryLimitReached) { + notices.push(`${effectiveLimit} entries limit reached. Use limit=${effectiveLimit * 2} for more`); + details.entryLimitReached = effectiveLimit; } - resolve({ content: [{ type: "text", text: output }], details }); + if (truncation.truncated) { + notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`); + details.truncation = truncation; + } + + if (notices.length > 0) { + output += `\n\n[${notices.join(". ")}]`; + } + + resolve({ + content: [{ type: "text", text: output }], + details: Object.keys(details).length > 0 ? details : undefined, + }); } catch (e: any) { signal?.removeEventListener("abort", onAbort); reject(e); diff --git a/packages/coding-agent/src/tools/read.ts b/packages/coding-agent/src/tools/read.ts index 13c3daca..2634000d 100644 --- a/packages/coding-agent/src/tools/read.ts +++ b/packages/coding-agent/src/tools/read.ts @@ -4,7 +4,7 @@ import { Type } from "@sinclair/typebox"; import { constants } from "fs"; import { access, readFile } from "fs/promises"; import { extname, resolve as resolvePath } from "path"; -import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, type TruncationResult, truncateHead } from "./truncate.js"; +import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateHead } from "./truncate.js"; /** * Expand ~ to home directory @@ -108,43 +108,66 @@ export const readTool: AgentTool = { } else { // Read as text const textContent = await readFile(absolutePath, "utf-8"); - const lines = textContent.split("\n"); + const allLines = textContent.split("\n"); + const totalFileLines = allLines.length; // Apply offset if specified (1-indexed to 0-indexed) const startLine = offset ? Math.max(0, offset - 1) : 0; + const startLineDisplay = startLine + 1; // For display (1-indexed) // Check if offset is out of bounds - if (startLine >= lines.length) { - throw new Error(`Offset ${offset} is beyond end of file (${lines.length} lines total)`); + if (startLine >= allLines.length) { + throw new Error(`Offset ${offset} is beyond end of file (${allLines.length} lines total)`); } // If limit is specified by user, use it; otherwise we'll let truncateHead decide let selectedContent: string; + let userLimitedLines: number | undefined; if (limit !== undefined) { - const endLine = Math.min(startLine + limit, lines.length); - selectedContent = lines.slice(startLine, endLine).join("\n"); + const endLine = Math.min(startLine + limit, allLines.length); + selectedContent = allLines.slice(startLine, endLine).join("\n"); + userLimitedLines = endLine - startLine; } else { - selectedContent = lines.slice(startLine).join("\n"); + selectedContent = allLines.slice(startLine).join("\n"); } // Apply truncation (respects both line and byte limits) const truncation = truncateHead(selectedContent); - let outputText = truncation.content; + let outputText: string; - // Add continuation hint if there's more content after our selection - // (only relevant when user specified limit and there's more in the file) - if (limit !== undefined && startLine + limit < lines.length && !truncation.truncated) { - const remaining = lines.length - (startLine + limit); - outputText += `\n\n[${remaining} more lines in file. Use offset=${startLine + limit + 1} to continue]`; + if (truncation.firstLineExceedsLimit) { + // First line at offset exceeds 30KB - tell model to use bash + const firstLineSize = formatSize(Buffer.byteLength(allLines[startLine], "utf-8")); + outputText = `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Use bash: sed -n '${startLineDisplay}p' ${path} | head -c ${DEFAULT_MAX_BYTES}]`; + details = { truncation }; + } else if (truncation.truncated) { + // Truncation occurred - build actionable notice + const endLineDisplay = startLineDisplay + truncation.outputLines - 1; + const nextOffset = endLineDisplay + 1; + + outputText = truncation.content; + + if (truncation.truncatedBy === "lines") { + outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue]`; + } else { + outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Use offset=${nextOffset} to continue]`; + } + details = { truncation }; + } else if (userLimitedLines !== undefined && startLine + userLimitedLines < allLines.length) { + // User specified limit, there's more content, but no truncation + const endLineDisplay = startLineDisplay + userLimitedLines - 1; + const remaining = allLines.length - (startLine + userLimitedLines); + const nextOffset = startLine + userLimitedLines + 1; + + outputText = truncation.content; + outputText += `\n\n[${remaining} more lines in file. Use offset=${nextOffset} to continue]`; + } else { + // No truncation, no user limit exceeded + outputText = truncation.content; } content = [{ type: "text", text: outputText }]; - - // Include truncation info in details if truncation occurred - if (truncation.truncated) { - details = { truncation }; - } } // Check if aborted after reading diff --git a/packages/coding-agent/src/tools/truncate.ts b/packages/coding-agent/src/tools/truncate.ts index 9f8be37e..94fba575 100644 --- a/packages/coding-agent/src/tools/truncate.ts +++ b/packages/coding-agent/src/tools/truncate.ts @@ -4,10 +4,13 @@ * Truncation is based on two independent limits - whichever is hit first wins: * - Line limit (default: 2000 lines) * - Byte limit (default: 30KB) + * + * Never returns partial lines (except bash tail truncation edge case). */ export const DEFAULT_MAX_LINES = 2000; export const DEFAULT_MAX_BYTES = 30 * 1024; // 30KB +export const GREP_MAX_LINE_LENGTH = 500; // Max chars per grep match line export interface TruncationResult { /** The truncated content */ @@ -20,12 +23,14 @@ export interface TruncationResult { totalLines: number; /** Total number of bytes in the original content */ totalBytes: number; - /** Number of lines in the truncated output */ + /** Number of complete lines in the truncated output */ outputLines: number; /** Number of bytes in the truncated output */ outputBytes: number; - /** Human-readable truncation notice (empty if not truncated) */ - notice: string; + /** Whether the last line was partially truncated (only for tail truncation edge case) */ + lastLinePartial: boolean; + /** Whether the first line exceeded the byte limit (for head truncation) */ + firstLineExceedsLimit: boolean; } export interface TruncationOptions { @@ -38,7 +43,7 @@ export interface TruncationOptions { /** * Format bytes as human-readable size. */ -function formatSize(bytes: number): string { +export function formatSize(bytes: number): string { if (bytes < 1024) { return `${bytes}B`; } else if (bytes < 1024 * 1024) { @@ -48,31 +53,12 @@ function formatSize(bytes: number): string { } } -/** - * Generate a truncation notice. - */ -function makeNotice( - direction: "head" | "tail", - truncatedBy: "lines" | "bytes", - totalLines: number, - totalBytes: number, - outputLines: number, - outputBytes: number, -): string { - const totalSize = formatSize(totalBytes); - const outputSize = formatSize(outputBytes); - const directionText = direction === "head" ? "first" : "last"; - - if (truncatedBy === "lines") { - return `[Truncated: ${totalLines} lines / ${totalSize} total, showing ${directionText} ${outputLines} lines]`; - } else { - return `[Truncated: ${totalLines} lines / ${totalSize} total, showing ${directionText} ${outputSize}]`; - } -} - /** * Truncate content from the head (keep first N lines/bytes). * Suitable for file reads where you want to see the beginning. + * + * Never returns partial lines. If first line exceeds byte limit, + * returns empty content with firstLineExceedsLimit=true. */ export function truncateHead(content: string, options: TruncationOptions = {}): TruncationResult { const maxLines = options.maxLines ?? DEFAULT_MAX_LINES; @@ -92,11 +78,28 @@ export function truncateHead(content: string, options: TruncationOptions = {}): totalBytes, outputLines: totalLines, outputBytes: totalBytes, - notice: "", + lastLinePartial: false, + firstLineExceedsLimit: false, }; } - // Determine which limit we'll hit first + // Check if first line alone exceeds byte limit + const firstLineBytes = Buffer.byteLength(lines[0], "utf-8"); + if (firstLineBytes > maxBytes) { + return { + content: "", + truncated: true, + truncatedBy: "bytes", + totalLines, + totalBytes, + outputLines: 0, + outputBytes: 0, + lastLinePartial: false, + firstLineExceedsLimit: true, + }; + } + + // Collect complete lines that fit const outputLinesArr: string[] = []; let outputBytesCount = 0; let truncatedBy: "lines" | "bytes" = "lines"; @@ -107,12 +110,6 @@ export function truncateHead(content: string, options: TruncationOptions = {}): if (outputBytesCount + lineBytes > maxBytes) { truncatedBy = "bytes"; - // If this is the first line and it alone exceeds maxBytes, include partial - if (i === 0) { - const truncatedLine = truncateStringToBytes(line, maxBytes); - outputLinesArr.push(truncatedLine); - outputBytesCount = Buffer.byteLength(truncatedLine, "utf-8"); - } break; } @@ -136,13 +133,16 @@ export function truncateHead(content: string, options: TruncationOptions = {}): totalBytes, outputLines: outputLinesArr.length, outputBytes: finalOutputBytes, - notice: makeNotice("head", truncatedBy, totalLines, totalBytes, outputLinesArr.length, finalOutputBytes), + lastLinePartial: false, + firstLineExceedsLimit: false, }; } /** * Truncate content from the tail (keep last N lines/bytes). * Suitable for bash output where you want to see the end (errors, final results). + * + * 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; @@ -162,7 +162,8 @@ export function truncateTail(content: string, options: TruncationOptions = {}): totalBytes, outputLines: totalLines, outputBytes: totalBytes, - notice: "", + lastLinePartial: false, + firstLineExceedsLimit: false, }; } @@ -170,6 +171,7 @@ export function truncateTail(content: string, options: TruncationOptions = {}): const outputLinesArr: string[] = []; let outputBytesCount = 0; let truncatedBy: "lines" | "bytes" = "lines"; + let lastLinePartial = false; for (let i = lines.length - 1; i >= 0 && outputLinesArr.length < maxLines; i--) { const line = lines[i]; @@ -177,12 +179,13 @@ export function truncateTail(content: string, options: TruncationOptions = {}): if (outputBytesCount + lineBytes > maxBytes) { truncatedBy = "bytes"; - // If this is the first line we're adding and it alone exceeds maxBytes, include partial + // 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) { - // Take the end of the line const truncatedLine = truncateStringToBytesFromEnd(line, maxBytes); outputLinesArr.unshift(truncatedLine); outputBytesCount = Buffer.byteLength(truncatedLine, "utf-8"); + lastLinePartial = true; } break; } @@ -207,29 +210,11 @@ export function truncateTail(content: string, options: TruncationOptions = {}): totalBytes, outputLines: outputLinesArr.length, outputBytes: finalOutputBytes, - notice: makeNotice("tail", truncatedBy, totalLines, totalBytes, outputLinesArr.length, finalOutputBytes), + lastLinePartial, + firstLineExceedsLimit: false, }; } -/** - * Truncate a string to fit within a byte limit (from the start). - * Handles multi-byte UTF-8 characters correctly. - */ -function truncateStringToBytes(str: string, maxBytes: number): string { - const buf = Buffer.from(str, "utf-8"); - if (buf.length <= maxBytes) { - return str; - } - - // Find a valid UTF-8 boundary - let end = maxBytes; - while (end > 0 && (buf[end] & 0xc0) === 0x80) { - end--; - } - - return buf.slice(0, end).toString("utf-8"); -} - /** * Truncate a string to fit within a byte limit (from the end). * Handles multi-byte UTF-8 characters correctly. @@ -250,3 +235,17 @@ function truncateStringToBytesFromEnd(str: string, maxBytes: number): string { return buf.slice(start).toString("utf-8"); } + +/** + * Truncate a single line to max characters, adding [truncated] suffix. + * Used for grep match lines. + */ +export function truncateLine( + line: string, + maxChars: number = GREP_MAX_LINE_LENGTH, +): { text: string; wasTruncated: boolean } { + if (line.length <= maxChars) { + return { text: line, wasTruncated: false }; + } + return { text: line.slice(0, maxChars) + "... [truncated]", wasTruncated: true }; +} diff --git a/packages/coding-agent/src/tui/tool-execution.ts b/packages/coding-agent/src/tui/tool-execution.ts index 576c3669..6dd9d872 100644 --- a/packages/coding-agent/src/tui/tool-execution.ts +++ b/packages/coding-agent/src/tui/tool-execution.ts @@ -128,15 +128,7 @@ export class ToolExecutionComponent extends Container { } } - // Show truncation notice at the bottom in warning color if present in details - const truncation = this.result.details?.truncation; - const fullOutputPath = this.result.details?.fullOutputPath; - if (truncation?.truncated) { - if (fullOutputPath) { - text += "\n" + theme.fg("warning", `[Full output: ${fullOutputPath}]`); - } - text += "\n" + theme.fg("warning", truncation.notice); - } + // Truncation notice is now in the text content itself, TUI just shows it } } else if (this.toolName === "read") { const path = shortenPath(this.args?.file_path || this.args?.path || ""); @@ -166,11 +158,7 @@ export class ToolExecutionComponent extends Container { text += theme.fg("toolOutput", `\n... (${remaining} more lines)`); } - // Show truncation notice at the bottom in warning color if present in details - const truncation = this.result.details?.truncation; - if (truncation?.truncated) { - text += "\n" + theme.fg("warning", truncation.notice); - } + // Truncation notice is now in the text content itself } } else if (this.toolName === "write") { const path = shortenPath(this.args?.file_path || this.args?.path || ""); @@ -249,15 +237,7 @@ export class ToolExecutionComponent extends Container { } } - // Show truncation notice from details - const entryLimit = this.result.details?.entryLimitReached; - const truncation = this.result.details?.truncation; - if (entryLimit) { - text += "\n" + theme.fg("warning", `[Truncated: ${entryLimit} entries limit reached]`); - } - if (truncation?.truncated) { - text += "\n" + theme.fg("warning", truncation.notice); - } + // Truncation notice is now in the text content itself } } else if (this.toolName === "find") { const pattern = this.args?.pattern || ""; @@ -287,15 +267,7 @@ export class ToolExecutionComponent extends Container { } } - // Show truncation notice from details - const resultLimit = this.result.details?.resultLimitReached; - const truncation = this.result.details?.truncation; - if (resultLimit) { - text += "\n" + theme.fg("warning", `[Truncated: ${resultLimit} results limit reached]`); - } - if (truncation?.truncated) { - text += "\n" + theme.fg("warning", truncation.notice); - } + // Truncation notice is now in the text content itself } } else if (this.toolName === "grep") { const pattern = this.args?.pattern || ""; @@ -329,15 +301,7 @@ export class ToolExecutionComponent extends Container { } } - // Show truncation notice from details - const matchLimit = this.result.details?.matchLimitReached; - const truncation = this.result.details?.truncation; - if (matchLimit) { - text += "\n" + theme.fg("warning", `[Truncated: ${matchLimit} matches limit reached]`); - } - if (truncation?.truncated) { - text += "\n" + theme.fg("warning", truncation.notice); - } + // Truncation notice is now in the text content itself } } else { // Generic tool