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)
This commit is contained in:
Mario Zechner 2025-12-07 01:11:31 +01:00
parent de77cd1419
commit b813a8b92b
9 changed files with 465 additions and 164 deletions

View file

@ -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<typeof bashSchema> = {
// 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) {

View file

@ -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<typeof findSchema> = {
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);

View file

@ -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<typeof grepSchema> = {
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<typeof grepSchema> = {
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<typeof grepSchema> = {
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<typeof grepSchema> = {
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<typeof grepSchema> = {
}
if (matchCount >= effectiveLimit) {
truncated = true;
matchLimitReached = true;
stopChild(true);
}
}
@ -256,22 +271,45 @@ export const grepTool: AgentTool<typeof grepSchema> = {
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));

View file

@ -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<typeof lsSchema> = {
// 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<typeof lsSchema> = {
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);

View file

@ -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<typeof readSchema> = {
} 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

View file

@ -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 };
}