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,13 +1,13 @@
import { Agent, type AgentEvent, ProviderTransport } from "@mariozechner/pi-agent-core";
import { getModel } from "@mariozechner/pi-ai";
import { existsSync, readFileSync, rmSync } from "fs";
import { mkdtemp } from "fs/promises";
import { tmpdir } from "os";
import { existsSync, readFileSync } from "fs";
import { mkdir } from "fs/promises";
import { join } from "path";
import { createExecutor, type SandboxConfig } from "./sandbox.js";
import type { SlackContext } from "./slack.js";
import type { ChannelStore } from "./store.js";
import { momTools, setUploadFunction } from "./tools/index.js";
import { createMomTools, setUploadFunction } from "./tools/index.js";
// Hardcoded model for now
const model = getModel("anthropic", "claude-opus-4-5");
@ -42,7 +42,9 @@ function getRecentMessages(channelDir: string, count: number): string {
return recentLines.join("\n");
}
function buildSystemPrompt(channelDir: string, scratchpadDir: string, recentMessages: string): string {
function buildSystemPrompt(workspacePath: string, channelId: string, recentMessages: string): string {
const channelPath = `${workspacePath}/${channelId}`;
return `You are mom, a helpful Slack bot assistant.
## Communication Style
@ -59,46 +61,36 @@ function buildSystemPrompt(channelDir: string, scratchpadDir: string, recentMess
- Links: <url|text>
- Do NOT use **double asterisks** or [markdown](links)
## Channel Data
The channel's data directory is: ${channelDir}
## Your Workspace
Your working directory is: ${channelPath}
### Message History
- File: ${channelDir}/log.jsonl
- Format: One JSON object per line (JSONL)
- Each line has: {"ts", "user", "userName", "displayName", "text", "attachments", "isBot"}
- "ts" is the Slack timestamp
- "user" is the user ID, "userName" is their handle, "displayName" is their full name
- "attachments" is an array of {"original", "local"} where "local" is the path relative to the working directory
- "isBot" is true for bot responses
This is YOUR computer - you have full control. You can:
- Install tools with the system package manager (apk, apt, etc.)
- Configure tools and save credentials
- Create files and directories as needed
### Channel Data
- Message history: ${channelPath}/log.jsonl (JSONL format)
- Attachments from users: ${channelPath}/attachments/
### Recent Messages (last 50)
Below are the most recent messages. If you need more context, read ${channelDir}/log.jsonl directly.
${recentMessages}
### Attachments
Files shared in the channel are stored in: ${channelDir}/attachments/
The "local" field in attachments points to these files.
## Scratchpad
Your temporary working directory is: ${scratchpadDir}
Use this for any file operations. It will be deleted after you complete.
## Tools
You have access to: read, edit, write, bash, attach tools.
You have access to: bash, read, edit, write, attach tools.
- bash: Run shell commands (this is your main tool)
- read: Read files
- edit: Edit files
- write: Write new files
- bash: Run shell commands
- attach: Attach a file to your response (share files with the user)
- edit: Edit files surgically
- write: Create/overwrite files
- attach: Share a file with the user in Slack
Each tool requires a "label" parameter - this is a brief description of what you're doing that will be shown to the user.
Keep labels short and informative, e.g., "Reading message history" or "Searching for user's previous questions".
Each tool requires a "label" parameter - brief description shown to the user.
## Guidelines
- Be concise and helpful
- If you need more conversation history beyond the recent messages above, read log.jsonl
- Use the scratchpad for any temporary work
- Use bash for most operations
- If you need a tool, install it
- If you need credentials, ask the user
## CRITICAL
- DO NOT USE EMOJIS. KEEP YOUR RESPONSES AS SHORT AS POSSIBLE.
@ -110,129 +102,127 @@ function truncate(text: string, maxLen: number): string {
return text.substring(0, maxLen - 3) + "...";
}
export function createAgentRunner(): AgentRunner {
export function createAgentRunner(sandboxConfig: SandboxConfig): AgentRunner {
let agent: Agent | null = null;
const executor = createExecutor(sandboxConfig);
return {
async run(ctx: SlackContext, channelDir: string, store: ChannelStore): Promise<void> {
// Create scratchpad
const scratchpadDir = await mkdtemp(join(tmpdir(), "mom-scratchpad-"));
// Ensure channel directory exists
await mkdir(channelDir, { recursive: true });
try {
const recentMessages = getRecentMessages(channelDir, 50);
const systemPrompt = buildSystemPrompt(channelDir, scratchpadDir, recentMessages);
const channelId = ctx.message.channel;
const workspacePath = executor.getWorkspacePath(channelDir.replace(`/${channelId}`, ""));
const recentMessages = getRecentMessages(channelDir, 50);
const systemPrompt = buildSystemPrompt(workspacePath, channelId, recentMessages);
// Set up file upload function for the attach tool
setUploadFunction(async (filePath: string, title?: string) => {
await ctx.uploadFile(filePath, title);
});
// Set up file upload function for the attach tool
// For Docker, we need to translate paths back to host
setUploadFunction(async (filePath: string, title?: string) => {
const hostPath = translateToHostPath(filePath, channelDir, workspacePath, channelId);
await ctx.uploadFile(hostPath, title);
});
// Create ephemeral agent
agent = new Agent({
initialState: {
systemPrompt,
model,
thinkingLevel: "off",
tools: momTools,
},
transport: new ProviderTransport({
getApiKey: async () => getAnthropicApiKey(),
}),
});
// Create tools with executor
const tools = createMomTools(executor);
// Subscribe to events
agent.subscribe(async (event: AgentEvent) => {
switch (event.type) {
case "tool_execution_start": {
const args = event.args as { label?: string };
const label = args.label || event.toolName;
// Create ephemeral agent
agent = new Agent({
initialState: {
systemPrompt,
model,
thinkingLevel: "off",
tools,
},
transport: new ProviderTransport({
getApiKey: async () => getAnthropicApiKey(),
}),
});
// Log to console
console.log(`\n[Tool] ${event.toolName}: ${JSON.stringify(event.args)}`);
// Subscribe to events
agent.subscribe(async (event: AgentEvent) => {
switch (event.type) {
case "tool_execution_start": {
const args = event.args as { label?: string };
const label = args.label || event.toolName;
// Log to jsonl
await store.logMessage(ctx.message.channel, {
ts: Date.now().toString(),
user: "bot",
text: `[Tool] ${event.toolName}: ${JSON.stringify(event.args)}`,
attachments: [],
isBot: true,
});
// Log to console
console.log(`\n[Tool] ${event.toolName}: ${JSON.stringify(event.args)}`);
// Show only label to user (italic)
await ctx.respond(`_${label}_`);
break;
}
// Log to jsonl
await store.logMessage(ctx.message.channel, {
ts: Date.now().toString(),
user: "bot",
text: `[Tool] ${event.toolName}: ${JSON.stringify(event.args)}`,
attachments: [],
isBot: true,
});
case "tool_execution_end": {
const resultStr = typeof event.result === "string" ? event.result : JSON.stringify(event.result);
// Log to console
console.log(`[Tool Result] ${event.isError ? "ERROR: " : ""}${truncate(resultStr, 1000)}\n`);
// Log to jsonl
await store.logMessage(ctx.message.channel, {
ts: Date.now().toString(),
user: "bot",
text: `[Tool Result] ${event.toolName}: ${event.isError ? "ERROR: " : ""}${truncate(resultStr, 1000)}`,
attachments: [],
isBot: true,
});
// Show brief status to user (only on error)
if (event.isError) {
await ctx.respond(`_Error: ${truncate(resultStr, 200)}_`);
}
break;
}
case "message_update": {
const ev = event.assistantMessageEvent;
// Stream deltas to console
if (ev.type === "text_delta") {
process.stdout.write(ev.delta);
} else if (ev.type === "thinking_delta") {
process.stdout.write(ev.delta);
}
break;
}
case "message_start":
if (event.message.role === "assistant") {
process.stdout.write("\n");
}
break;
case "message_end":
if (event.message.role === "assistant") {
process.stdout.write("\n");
// Extract text from assistant message
const content = event.message.content;
let text = "";
for (const part of content) {
if (part.type === "text") {
text += part.text;
}
}
if (text.trim()) {
await ctx.respond(text);
}
}
break;
// Show only label to user (italic)
await ctx.respond(`_${label}_`);
break;
}
});
// Run the agent with user's message
await agent.prompt(ctx.message.text || "(attached files)");
} finally {
agent = null;
// Cleanup scratchpad
try {
rmSync(scratchpadDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
case "tool_execution_end": {
const resultStr = typeof event.result === "string" ? event.result : JSON.stringify(event.result);
// Log to console
console.log(`[Tool Result] ${event.isError ? "ERROR: " : ""}${truncate(resultStr, 1000)}\n`);
// Log to jsonl
await store.logMessage(ctx.message.channel, {
ts: Date.now().toString(),
user: "bot",
text: `[Tool Result] ${event.toolName}: ${event.isError ? "ERROR: " : ""}${truncate(resultStr, 1000)}`,
attachments: [],
isBot: true,
});
// Show brief status to user (only on error)
if (event.isError) {
await ctx.respond(`_Error: ${truncate(resultStr, 200)}_`);
}
break;
}
case "message_update": {
const ev = event.assistantMessageEvent;
// Stream deltas to console
if (ev.type === "text_delta") {
process.stdout.write(ev.delta);
} else if (ev.type === "thinking_delta") {
process.stdout.write(ev.delta);
}
break;
}
case "message_start":
if (event.message.role === "assistant") {
process.stdout.write("\n");
}
break;
case "message_end":
if (event.message.role === "assistant") {
process.stdout.write("\n");
// Extract text from assistant message
const content = event.message.content;
let text = "";
for (const part of content) {
if (part.type === "text") {
text += part.text;
}
}
if (text.trim()) {
await ctx.respond(text);
}
}
break;
}
}
});
// Run the agent with user's message
await agent.prompt(ctx.message.text || "(attached files)");
},
abort(): void {
@ -240,3 +230,27 @@ export function createAgentRunner(): AgentRunner {
},
};
}
/**
* Translate container path back to host path for file operations
*/
function translateToHostPath(
containerPath: string,
channelDir: string,
workspacePath: string,
channelId: string,
): string {
if (workspacePath === "/workspace") {
// Docker mode - translate /workspace/channelId/... to host path
const prefix = `/workspace/${channelId}/`;
if (containerPath.startsWith(prefix)) {
return join(channelDir, containerPath.slice(prefix.length));
}
// Maybe it's just /workspace/...
if (containerPath.startsWith("/workspace/")) {
return join(channelDir, "..", containerPath.slice("/workspace/".length));
}
}
// Host mode or already a host path
return containerPath;
}