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
This commit is contained in:
Mario Zechner 2026-01-06 22:13:08 +01:00
parent f87fb0a38a
commit 7c99ea54bf
5 changed files with 266 additions and 1 deletions

View file

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

View file

@ -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:

View file

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

View file

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

View file

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