From 7c99ea54bf6b772c9bc660de708b1e6231ac8876 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 6 Jan 2026 22:13:08 +0100 Subject: [PATCH] Export truncation utilities for custom tools, add truncated-tool example - Export truncateHead, truncateTail, truncateLine, formatSize, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES from package - Add examples/extensions/truncated-tool.ts showing proper output truncation with custom rendering - Document output truncation best practices in docs/extensions.md --- packages/coding-agent/CHANGELOG.md | 6 + packages/coding-agent/docs/extensions.md | 51 +++++ .../examples/extensions/truncated-tool.ts | 192 ++++++++++++++++++ packages/coding-agent/src/core/tools/index.ts | 11 +- packages/coding-agent/src/index.ts | 7 + 5 files changed, 266 insertions(+), 1 deletion(-) create mode 100644 packages/coding-agent/examples/extensions/truncated-tool.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d435c41b..4b8e810b 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +### Added + +- Exported truncation utilities for custom tools: `truncateHead`, `truncateTail`, `truncateLine`, `formatSize`, `DEFAULT_MAX_BYTES`, `DEFAULT_MAX_LINES`, `TruncationOptions`, `TruncationResult` +- New example `truncated-tool.ts` demonstrating proper output truncation with custom rendering for extensions +- Documentation for output truncation best practices in `docs/extensions.md` + ## [0.37.4] - 2026-01-06 ### Added diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 4226df91..a88f73e4 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -857,6 +857,57 @@ pi.registerTool({ **Important:** Use `StringEnum` from `@mariozechner/pi-ai` for string enums. `Type.Union`/`Type.Literal` doesn't work with Google's API. +### Output Truncation + +**Tools MUST truncate their output** to avoid overwhelming the LLM context. Large outputs can cause: +- Context overflow errors (prompt too long) +- Compaction failures +- Degraded model performance + +The built-in limit is **50KB** (~10k tokens) and **2000 lines**, whichever is hit first. Use the exported truncation utilities: + +```typescript +import { + truncateHead, // Keep first N lines/bytes (good for file reads, search results) + truncateTail, // Keep last N lines/bytes (good for logs, command output) + formatSize, // Human-readable size (e.g., "50KB", "1.5MB") + DEFAULT_MAX_BYTES, // 50KB + DEFAULT_MAX_LINES, // 2000 +} from "@mariozechner/pi-coding-agent"; + +async execute(toolCallId, params, onUpdate, ctx, signal) { + const output = await runCommand(); + + // Apply truncation + const truncation = truncateHead(output, { + maxLines: DEFAULT_MAX_LINES, + maxBytes: DEFAULT_MAX_BYTES, + }); + + let result = truncation.content; + + if (truncation.truncated) { + // Write full output to temp file + const tempFile = writeTempFile(output); + + // Inform the LLM where to find complete output + result += `\n\n[Output truncated: ${truncation.outputLines} of ${truncation.totalLines} lines`; + result += ` (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}).`; + result += ` Full output saved to: ${tempFile}]`; + } + + return { content: [{ type: "text", text: result }] }; +} +``` + +**Key points:** +- Use `truncateHead` for content where the beginning matters (search results, file reads) +- Use `truncateTail` for content where the end matters (logs, command output) +- Always inform the LLM when output is truncated and where to find the full version +- Document the truncation limits in your tool's description + +See [examples/extensions/truncated-tool.ts](../examples/extensions/truncated-tool.ts) for a complete example wrapping `rg` (ripgrep) with proper truncation. + ### Multiple Tools One extension can register multiple tools with shared state: diff --git a/packages/coding-agent/examples/extensions/truncated-tool.ts b/packages/coding-agent/examples/extensions/truncated-tool.ts new file mode 100644 index 00000000..e211ca0f --- /dev/null +++ b/packages/coding-agent/examples/extensions/truncated-tool.ts @@ -0,0 +1,192 @@ +/** + * Truncated Tool Example - Demonstrates proper output truncation for custom tools + * + * Custom tools MUST truncate their output to avoid overwhelming the LLM context. + * The built-in limit is 50KB (~10k tokens) and 2000 lines, whichever is hit first. + * + * This example shows how to: + * 1. Use the built-in truncation utilities + * 2. Write full output to a temp file when truncated + * 3. Inform the LLM where to find the complete output + * 4. Custom rendering of tool calls and results + * + * The `rg` tool here wraps ripgrep with proper truncation. Compare this to the + * built-in `grep` tool in src/core/tools/grep.ts for a more complete implementation. + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { + DEFAULT_MAX_BYTES, + DEFAULT_MAX_LINES, + formatSize, + type TruncationResult, + truncateHead, +} from "@mariozechner/pi-coding-agent"; +import { Text } from "@mariozechner/pi-tui"; +import { Type } from "@sinclair/typebox"; +import { execSync } from "child_process"; +import { mkdtempSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; + +const RgParams = Type.Object({ + pattern: Type.String({ description: "Search pattern (regex)" }), + path: Type.Optional(Type.String({ description: "Directory to search (default: current directory)" })), + glob: Type.Optional(Type.String({ description: "File glob pattern, e.g. '*.ts'" })), +}); + +interface RgDetails { + pattern: string; + path?: string; + glob?: string; + matchCount: number; + truncation?: TruncationResult; + fullOutputPath?: string; +} + +export default function (pi: ExtensionAPI) { + pi.registerTool({ + name: "rg", + label: "ripgrep", + // Document the truncation limits in the tool description so the LLM knows + description: `Search file contents using ripgrep. Output is truncated to ${DEFAULT_MAX_LINES} lines or ${formatSize(DEFAULT_MAX_BYTES)} (whichever is hit first). If truncated, full output is saved to a temp file.`, + parameters: RgParams, + + async execute(_toolCallId, params, _onUpdate, ctx) { + const { pattern, path: searchPath, glob } = params; + + // Build the ripgrep command + const args = ["rg", "--line-number", "--color=never"]; + if (glob) args.push("--glob", glob); + args.push(pattern); + args.push(searchPath || "."); + + let output: string; + try { + output = execSync(args.join(" "), { + cwd: ctx.cwd, + encoding: "utf-8", + maxBuffer: 100 * 1024 * 1024, // 100MB buffer to capture full output + }); + } catch (err: any) { + // ripgrep exits with 1 when no matches found + if (err.status === 1) { + return { + content: [{ type: "text", text: "No matches found" }], + details: { pattern, path: searchPath, glob, matchCount: 0 } as RgDetails, + }; + } + throw new Error(`ripgrep failed: ${err.message}`); + } + + if (!output.trim()) { + return { + content: [{ type: "text", text: "No matches found" }], + details: { pattern, path: searchPath, glob, matchCount: 0 } as RgDetails, + }; + } + + // Apply truncation using built-in utilities + // truncateHead keeps the first N lines/bytes (good for search results) + // truncateTail keeps the last N lines/bytes (good for logs/command output) + const truncation = truncateHead(output, { + maxLines: DEFAULT_MAX_LINES, + maxBytes: DEFAULT_MAX_BYTES, + }); + + // Count matches (each non-empty line with a match) + const matchCount = output.split("\n").filter((line) => line.trim()).length; + + const details: RgDetails = { + pattern, + path: searchPath, + glob, + matchCount, + }; + + let resultText = truncation.content; + + if (truncation.truncated) { + // Save full output to a temp file so LLM can access it if needed + const tempDir = mkdtempSync(join(tmpdir(), "pi-rg-")); + const tempFile = join(tempDir, "output.txt"); + writeFileSync(tempFile, output); + + details.truncation = truncation; + details.fullOutputPath = tempFile; + + // Add truncation notice - this helps the LLM understand the output is incomplete + const truncatedLines = truncation.totalLines - truncation.outputLines; + const truncatedBytes = truncation.totalBytes - truncation.outputBytes; + + resultText += `\n\n[Output truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`; + resultText += ` (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}).`; + resultText += ` ${truncatedLines} lines (${formatSize(truncatedBytes)}) omitted.`; + resultText += ` Full output saved to: ${tempFile}]`; + } + + return { + content: [{ type: "text", text: resultText }], + details, + }; + }, + + // Custom rendering of the tool call (shown before/during execution) + renderCall(args, theme) { + let text = theme.fg("toolTitle", theme.bold("rg ")); + text += theme.fg("accent", `"${args.pattern}"`); + if (args.path) { + text += theme.fg("muted", ` in ${args.path}`); + } + if (args.glob) { + text += theme.fg("dim", ` --glob ${args.glob}`); + } + return new Text(text, 0, 0); + }, + + // Custom rendering of the tool result + renderResult(result, { expanded, isPartial }, theme) { + const details = result.details as RgDetails | undefined; + + // Handle streaming/partial results + if (isPartial) { + return new Text(theme.fg("warning", "Searching..."), 0, 0); + } + + // No matches + if (!details || details.matchCount === 0) { + return new Text(theme.fg("dim", "No matches found"), 0, 0); + } + + // Build result display + let text = theme.fg("success", `${details.matchCount} matches`); + + // Show truncation warning if applicable + if (details.truncation?.truncated) { + text += theme.fg("warning", " (truncated)"); + } + + // In expanded view, show the actual matches + if (expanded) { + const content = result.content[0]; + if (content?.type === "text") { + // Show first 20 lines in expanded view, or all if fewer + const lines = content.text.split("\n").slice(0, 20); + for (const line of lines) { + text += `\n${theme.fg("dim", line)}`; + } + if (content.text.split("\n").length > 20) { + text += `\n${theme.fg("muted", "... (use read tool to see full output)")}`; + } + } + + // Show temp file path if truncated + if (details.fullOutputPath) { + text += `\n${theme.fg("dim", `Full output: ${details.fullOutputPath}`)}`; + } + } + + return new Text(text, 0, 0); + }, + }); +} diff --git a/packages/coding-agent/src/core/tools/index.ts b/packages/coding-agent/src/core/tools/index.ts index 908fbf3e..c617f8be 100644 --- a/packages/coding-agent/src/core/tools/index.ts +++ b/packages/coding-agent/src/core/tools/index.ts @@ -4,7 +4,16 @@ export { createFindTool, type FindToolDetails, findTool } from "./find.js"; export { createGrepTool, type GrepToolDetails, grepTool } from "./grep.js"; export { createLsTool, type LsToolDetails, lsTool } from "./ls.js"; export { createReadTool, type ReadToolDetails, type ReadToolOptions, readTool } from "./read.js"; -export type { TruncationResult } from "./truncate.js"; +export { + DEFAULT_MAX_BYTES, + DEFAULT_MAX_LINES, + formatSize, + type TruncationOptions, + type TruncationResult, + truncateHead, + truncateLine, + truncateTail, +} from "./truncate.js"; export { createWriteTool, writeTool } from "./write.js"; import type { AgentTool } from "@mariozechner/pi-agent-core"; diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index e3630cf6..97e35e82 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -171,9 +171,12 @@ export { type BashToolDetails, bashTool, codingTools, + DEFAULT_MAX_BYTES, + DEFAULT_MAX_LINES, editTool, type FindToolDetails, findTool, + formatSize, type GrepToolDetails, grepTool, type LsToolDetails, @@ -182,7 +185,11 @@ export { type ReadToolOptions, readTool, type ToolsOptions, + type TruncationOptions, type TruncationResult, + truncateHead, + truncateLine, + truncateTail, writeTool, } from "./core/tools/index.js"; // Main entry point