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) {