mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 14:03:49 +00:00
Tool results now use content blocks and can include both text and images. All providers (Anthropic, Google, OpenAI Completions, OpenAI Responses) correctly pass images from tool results to LLMs. - Update ToolResultMessage type to use content blocks - Add placeholder text for image-only tool results in Google/Anthropic - OpenAI providers send tool result + follow-up user message with images - Fix Anthropic JSON parsing for empty tool arguments - Add comprehensive tests for image-only and text+image tool results - Update README with tool result content blocks API
128 lines
3.1 KiB
TypeScript
128 lines
3.1 KiB
TypeScript
import type { AgentTool } from "@mariozechner/pi-ai";
|
|
import { Type } from "@sinclair/typebox";
|
|
import { spawn } from "child_process";
|
|
|
|
const bashSchema = Type.Object({
|
|
command: Type.String({ description: "Bash command to execute" }),
|
|
});
|
|
|
|
export const bashTool: AgentTool<typeof bashSchema> = {
|
|
name: "bash",
|
|
label: "bash",
|
|
description:
|
|
"Execute a bash command in the current working directory. Returns stdout and stderr. Commands run with a 30 second timeout.",
|
|
parameters: bashSchema,
|
|
execute: async (_toolCallId: string, { command }: { command: string }, signal?: AbortSignal) => {
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn("sh", ["-c", command], {
|
|
detached: true,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
|
|
let stdout = "";
|
|
let stderr = "";
|
|
let timedOut = false;
|
|
|
|
// Set timeout
|
|
const timeout = setTimeout(() => {
|
|
timedOut = true;
|
|
onAbort();
|
|
}, 30000);
|
|
|
|
// Collect stdout
|
|
if (child.stdout) {
|
|
child.stdout.on("data", (data) => {
|
|
stdout += data.toString();
|
|
// Limit buffer size
|
|
if (stdout.length > 10 * 1024 * 1024) {
|
|
stdout = stdout.slice(0, 10 * 1024 * 1024);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Collect stderr
|
|
if (child.stderr) {
|
|
child.stderr.on("data", (data) => {
|
|
stderr += data.toString();
|
|
// Limit buffer size
|
|
if (stderr.length > 10 * 1024 * 1024) {
|
|
stderr = stderr.slice(0, 10 * 1024 * 1024);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Handle process exit
|
|
child.on("close", (code) => {
|
|
clearTimeout(timeout);
|
|
if (signal) {
|
|
signal.removeEventListener("abort", onAbort);
|
|
}
|
|
|
|
if (signal?.aborted) {
|
|
let output = "";
|
|
if (stdout) output += stdout;
|
|
if (stderr) {
|
|
if (output) output += "\n";
|
|
output += stderr;
|
|
}
|
|
if (output) output += "\n\n";
|
|
output += "Command aborted";
|
|
reject(new Error(output));
|
|
return;
|
|
}
|
|
|
|
if (timedOut) {
|
|
let output = "";
|
|
if (stdout) output += stdout;
|
|
if (stderr) {
|
|
if (output) output += "\n";
|
|
output += stderr;
|
|
}
|
|
if (output) output += "\n\n";
|
|
output += "Command timed out after 30 seconds";
|
|
reject(new Error(output));
|
|
return;
|
|
}
|
|
|
|
let output = "";
|
|
if (stdout) output += stdout;
|
|
if (stderr) {
|
|
if (output) output += "\n";
|
|
output += stderr;
|
|
}
|
|
|
|
if (code !== 0 && code !== null) {
|
|
if (output) output += "\n\n";
|
|
reject(new Error(`${output}Command exited with code ${code}`));
|
|
} else {
|
|
resolve({ content: [{ type: "text", text: output || "(no output)" }], details: undefined });
|
|
}
|
|
});
|
|
|
|
// Handle abort signal - kill entire process tree
|
|
const onAbort = () => {
|
|
if (child.pid) {
|
|
// Kill the entire process group (negative PID kills all processes in the group)
|
|
try {
|
|
process.kill(-child.pid, "SIGKILL");
|
|
} catch (e) {
|
|
// Fallback to killing just the child if process group kill fails
|
|
try {
|
|
child.kill("SIGKILL");
|
|
} catch (e2) {
|
|
// Process already dead
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
if (signal) {
|
|
if (signal.aborted) {
|
|
onAbort();
|
|
} else {
|
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
}
|
|
}
|
|
});
|
|
},
|
|
};
|