mom: Docker sandbox support with --sandbox=docker:container-name option

This commit is contained in:
Mario Zechner 2025-11-26 01:06:00 +01:00
parent da26edb2a7
commit f140f2e432
10 changed files with 885 additions and 814 deletions

View file

@ -1,22 +1,7 @@
import * as os from "node:os";
import type { AgentTool, ImageContent, TextContent } from "@mariozechner/pi-ai";
import { Type } from "@sinclair/typebox";
import { constants } from "fs";
import { access, readFile } from "fs/promises";
import { extname, resolve as resolvePath } from "path";
/**
* Expand ~ to home directory
*/
function expandPath(filePath: string): string {
if (filePath === "~") {
return os.homedir();
}
if (filePath.startsWith("~/")) {
return os.homedir() + filePath.slice(1);
}
return filePath;
}
import { extname } from "path";
import type { Executor } from "../sandbox.js";
/**
* Map of file extensions to MIME types for common image formats
@ -47,133 +32,96 @@ const readSchema = Type.Object({
const MAX_LINES = 2000;
const MAX_LINE_LENGTH = 2000;
export const readTool: AgentTool<typeof readSchema> = {
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.",
parameters: readSchema,
execute: async (
_toolCallId: string,
{ path, offset, limit }: { label: string; path: string; offset?: number; limit?: number },
signal?: AbortSignal,
) => {
const absolutePath = resolvePath(expandPath(path));
const mimeType = isImageFile(absolutePath);
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.",
parameters: readSchema,
execute: async (
_toolCallId: string,
{ path, offset, limit }: { label: string; path: string; offset?: number; limit?: number },
signal?: AbortSignal,
) => {
const mimeType = isImageFile(path);
return new Promise<{ content: (TextContent | ImageContent)[]; details: undefined }>((resolve, reject) => {
// Check if already aborted
if (signal?.aborted) {
reject(new Error("Operation aborted"));
return;
}
let aborted = false;
// Set up abort handler
const onAbort = () => {
aborted = true;
reject(new Error("Operation aborted"));
};
if (signal) {
signal.addEventListener("abort", onAbort, { once: true });
}
// Perform the read operation
(async () => {
try {
// Check if file exists
await access(absolutePath, constants.R_OK);
// Check if aborted before reading
if (aborted) {
return;
}
// Read the file based on type
let content: (TextContent | ImageContent)[];
if (mimeType) {
// Read as image (binary)
const buffer = await readFile(absolutePath);
const base64 = buffer.toString("base64");
content = [
{ type: "text", text: `Read image file [${mimeType}]` },
{ type: "image", data: base64, mimeType },
];
} else {
// Read as text
const textContent = await readFile(absolutePath, "utf-8");
const lines = textContent.split("\n");
// Apply offset and limit (matching Claude Code Read tool behavior)
const startLine = offset ? Math.max(0, offset - 1) : 0; // 1-indexed to 0-indexed
const maxLines = limit || MAX_LINES;
const endLine = Math.min(startLine + maxLines, lines.length);
// Check if offset is out of bounds
if (startLine >= lines.length) {
throw new Error(`Offset ${offset} is beyond end of file (${lines.length} lines total)`);
}
// Get the relevant lines
const selectedLines = lines.slice(startLine, endLine);
// Truncate long lines and track which were truncated
let hadTruncatedLines = false;
const formattedLines = selectedLines.map((line) => {
if (line.length > MAX_LINE_LENGTH) {
hadTruncatedLines = true;
return line.slice(0, MAX_LINE_LENGTH);
}
return line;
});
let outputText = formattedLines.join("\n");
// Add notices
const notices: string[] = [];
if (hadTruncatedLines) {
notices.push(`Some lines were truncated to ${MAX_LINE_LENGTH} characters for display`);
}
if (endLine < lines.length) {
const remaining = lines.length - endLine;
notices.push(`${remaining} more lines not shown. Use offset=${endLine + 1} to continue reading`);
}
if (notices.length > 0) {
outputText += `\n\n... (${notices.join(". ")})`;
}
content = [{ type: "text", text: outputText }];
}
// Check if aborted after reading
if (aborted) {
return;
}
// Clean up abort handler
if (signal) {
signal.removeEventListener("abort", onAbort);
}
resolve({ content, details: undefined });
} catch (error: unknown) {
// Clean up abort handler
if (signal) {
signal.removeEventListener("abort", onAbort);
}
if (!aborted) {
reject(error);
}
if (mimeType) {
// Read as image (binary) - use base64
const result = await executor.exec(`base64 < ${shellEscape(path)}`, { signal });
if (result.code !== 0) {
throw new Error(result.stderr || `Failed to read file: ${path}`);
}
})();
});
},
};
const base64 = result.stdout.replace(/\s/g, ""); // Remove whitespace from base64
return {
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;
if (startLine === 1) {
cmd = `head -n ${maxLines} ${shellEscape(path)}`;
} else {
cmd = `sed -n '${startLine},${startLine + maxLines - 1}p' ${shellEscape(path)}`;
}
// Also get total line count
const countResult = await executor.exec(`wc -l < ${shellEscape(path)}`, { signal });
const totalLines = Number.parseInt(countResult.stdout.trim(), 10) || 0;
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");
// 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);
}
return line;
});
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(". ")})`;
}
return {
content: [{ type: "text", text: outputText }] as (TextContent | ImageContent)[],
details: undefined,
};
},
};
}
function shellEscape(s: string): string {
return `'${s.replace(/'/g, "'\\''")}'`;
}