Port truncation logic from coding-agent to mom

- Add truncate.ts with 2000 lines / 50KB limits
- Update bash tool with tail truncation and temp file output
- Update read tool with head truncation and offset hints
- Remove redundant context history truncation (tools already provide actionable hints)

fixes #155
This commit is contained in:
Mario Zechner 2025-12-09 16:05:08 +01:00
parent de3fd172a9
commit 02c7f9ea51
5 changed files with 380 additions and 92 deletions

View file

@ -2,6 +2,7 @@ import type { AgentTool, ImageContent, TextContent } from "@mariozechner/pi-ai";
import { Type } from "@sinclair/typebox";
import { extname } from "path";
import type { Executor } from "../sandbox.js";
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateHead } from "./truncate.js";
/**
* Map of file extensions to MIME types for common image formats
@ -29,21 +30,21 @@ const readSchema = Type.Object({
limit: Type.Optional(Type.Number({ description: "Maximum number of lines to read" })),
});
const MAX_LINES = 2000;
const MAX_LINE_LENGTH = 2000;
interface ReadToolDetails {
truncation?: TruncationResult;
}
export function createReadTool(executor: Executor): AgentTool<typeof readSchema> {
return {
name: "read",
label: "read",
description:
"Read the contents of a file. Supports text files and images (jpg, png, gif, webp). Images are sent as attachments. For text files, defaults to first 2000 lines. Use offset/limit for large files.",
description: `Read the contents of a file. Supports text files and images (jpg, png, gif, webp). Images are sent as attachments. For text files, output is truncated to ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Use offset/limit for large files.`,
parameters: readSchema,
execute: async (
_toolCallId: string,
{ path, offset, limit }: { label: string; path: string; offset?: number; limit?: number },
signal?: AbortSignal,
) => {
): Promise<{ content: (TextContent | ImageContent)[]; details: ReadToolDetails | undefined }> => {
const mimeType = isImageFile(path);
if (mimeType) {
@ -58,65 +59,95 @@ export function createReadTool(executor: Executor): AgentTool<typeof readSchema>
content: [
{ type: "text", text: `Read image file [${mimeType}]` },
{ type: "image", data: base64, mimeType },
] as (TextContent | ImageContent)[],
],
details: undefined,
};
}
// Read as text using cat with offset/limit via sed/head/tail
let cmd: string;
const startLine = offset ? Math.max(1, offset) : 1;
const maxLines = limit || MAX_LINES;
// Get total line count first
const countResult = await executor.exec(`wc -l < ${shellEscape(path)}`, { signal });
if (countResult.code !== 0) {
throw new Error(countResult.stderr || `Failed to read file: ${path}`);
}
const totalFileLines = Number.parseInt(countResult.stdout.trim(), 10) + 1; // wc -l counts newlines, not lines
if (startLine === 1) {
cmd = `head -n ${maxLines} ${shellEscape(path)}`;
} else {
cmd = `sed -n '${startLine},${startLine + maxLines - 1}p' ${shellEscape(path)}`;
// Apply offset if specified (1-indexed)
const startLine = offset ? Math.max(1, offset) : 1;
const startLineDisplay = startLine;
// Check if offset is out of bounds
if (startLine > totalFileLines) {
throw new Error(`Offset ${offset} is beyond end of file (${totalFileLines} lines total)`);
}
// Also get total line count
const countResult = await executor.exec(`wc -l < ${shellEscape(path)}`, { signal });
const totalLines = Number.parseInt(countResult.stdout.trim(), 10) || 0;
// Read content with offset
let cmd: string;
if (startLine === 1) {
cmd = `cat ${shellEscape(path)}`;
} else {
cmd = `tail -n +${startLine} ${shellEscape(path)}`;
}
const result = await executor.exec(cmd, { signal });
if (result.code !== 0) {
throw new Error(result.stderr || `Failed to read file: ${path}`);
}
const lines = result.stdout.split("\n");
let selectedContent = result.stdout;
let userLimitedLines: number | undefined;
// Truncate long lines
let hadTruncatedLines = false;
const formattedLines = lines.map((line) => {
if (line.length > MAX_LINE_LENGTH) {
hadTruncatedLines = true;
return line.slice(0, MAX_LINE_LENGTH);
// Apply user limit if specified
if (limit !== undefined) {
const lines = selectedContent.split("\n");
const endLine = Math.min(limit, lines.length);
selectedContent = lines.slice(0, endLine).join("\n");
userLimitedLines = endLine;
}
// Apply truncation (respects both line and byte limits)
const truncation = truncateHead(selectedContent);
let outputText: string;
let details: ReadToolDetails | undefined;
if (truncation.firstLineExceedsLimit) {
// First line at offset exceeds 50KB - tell model to use bash
const firstLineSize = formatSize(Buffer.byteLength(selectedContent.split("\n")[0], "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]`;
}
return line;
});
details = { truncation };
} else if (userLimitedLines !== undefined) {
// User specified limit, check if there's more content
const linesFromStart = startLine - 1 + userLimitedLines;
if (linesFromStart < totalFileLines) {
const remaining = totalFileLines - linesFromStart;
const nextOffset = startLine + userLimitedLines;
let outputText = formattedLines.join("\n");
// Add notices
const notices: string[] = [];
const endLine = startLine + lines.length - 1;
if (hadTruncatedLines) {
notices.push(`Some lines were truncated to ${MAX_LINE_LENGTH} characters for display`);
}
if (endLine < totalLines) {
const remaining = totalLines - endLine;
notices.push(`${remaining} more lines not shown. Use offset=${endLine + 1} to continue reading`);
}
if (notices.length > 0) {
outputText += `\n\n... (${notices.join(". ")})`;
outputText = truncation.content;
outputText += `\n\n[${remaining} more lines in file. Use offset=${nextOffset} to continue]`;
} else {
outputText = truncation.content;
}
} else {
// No truncation, no user limit exceeded
outputText = truncation.content;
}
return {
content: [{ type: "text", text: outputText }] as (TextContent | ImageContent)[],
details: undefined,
content: [{ type: "text", text: outputText }],
details,
};
},
};