mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-21 10:00:39 +00:00
mom: Docker sandbox support with --sandbox=docker:container-name option
This commit is contained in:
parent
da26edb2a7
commit
f140f2e432
10 changed files with 885 additions and 814 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue