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

@ -1,6 +1,19 @@
import { randomBytes } from "node:crypto";
import { createWriteStream } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { AgentTool } from "@mariozechner/pi-ai";
import { Type } from "@sinclair/typebox";
import type { Executor } from "../sandbox.js";
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateTail } from "./truncate.js";
/**
* Generate a unique temp file path for bash output
*/
function getTempFilePath(): string {
const id = randomBytes(8).toString("hex");
return join(tmpdir(), `mom-bash-${id}.log`);
}
const bashSchema = Type.Object({
label: Type.String({ description: "Brief description of what this command does (shown to user)" }),
@ -8,18 +21,26 @@ const bashSchema = Type.Object({
timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (optional, no default timeout)" })),
});
interface BashToolDetails {
truncation?: TruncationResult;
fullOutputPath?: string;
}
export function createBashTool(executor: Executor): AgentTool<typeof bashSchema> {
return {
name: "bash",
label: "bash",
description:
"Execute a bash command in the current working directory. Returns stdout and stderr. Optionally provide a timeout in seconds.",
description: `Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.`,
parameters: bashSchema,
execute: async (
_toolCallId: string,
{ command, timeout }: { label: string; command: string; timeout?: number },
signal?: AbortSignal,
) => {
// Track output for potential temp file writing
let tempFilePath: string | undefined;
let tempFileStream: ReturnType<typeof createWriteStream> | undefined;
const result = await executor.exec(command, { timeout, signal });
let output = "";
if (result.stdout) output += result.stdout;
@ -28,11 +49,49 @@ export function createBashTool(executor: Executor): AgentTool<typeof bashSchema>
output += result.stderr;
}
if (result.code !== 0) {
throw new Error(`${output}\n\nCommand exited with code ${result.code}`.trim());
const totalBytes = Buffer.byteLength(output, "utf-8");
// Write to temp file if output exceeds limit
if (totalBytes > DEFAULT_MAX_BYTES) {
tempFilePath = getTempFilePath();
tempFileStream = createWriteStream(tempFilePath);
tempFileStream.write(output);
tempFileStream.end();
}
return { content: [{ type: "text", text: output || "(no output)" }], details: undefined };
// Apply tail truncation
const truncation = truncateTail(output);
let outputText = truncation.content || "(no output)";
// 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 > 50KB
const lastLineSize = formatSize(Buffer.byteLength(output.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 (result.code !== 0) {
throw new Error(`${outputText}\n\nCommand exited with code ${result.code}`.trim());
}
return { content: [{ type: "text", text: outputText }], details };
},
};
}