mirror of
https://github.com/harivansh-afk/clanker-agent.git
synced 2026-04-15 08:03:42 +00:00
- Remove onKillHandle from BashOperations interface (implementation detail leak) - Use AbortController + combineAbortSignals to kill from tool level instead - Extract shared buildOutput() to deduplicate .then()/.catch() paths - Cleaner separation: BashOperations stays unchanged, timeouts are tool-level Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
422 lines
14 KiB
TypeScript
422 lines
14 KiB
TypeScript
import { randomBytes } from "node:crypto";
|
|
import { createWriteStream, existsSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
|
import { type Static, Type } from "@sinclair/typebox";
|
|
import { spawn } from "child_process";
|
|
import {
|
|
getShellConfig,
|
|
getShellEnv,
|
|
killProcessTree,
|
|
} from "../../utils/shell.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(), `pi-bash-${id}.log`);
|
|
}
|
|
|
|
/**
|
|
* Default timeout applied when the LLM does not specify one.
|
|
* Prevents indefinite hangs from long-running processes (dev servers, watchers, etc.)
|
|
*/
|
|
const DEFAULT_TIMEOUT_SECONDS = 120;
|
|
|
|
/**
|
|
* If no stdout/stderr output is received for this many seconds, kill the process.
|
|
* Catches processes that are alive but idle (e.g. a backgrounded server after startup).
|
|
*/
|
|
const DEFAULT_NO_OUTPUT_TIMEOUT_SECONDS = 30;
|
|
|
|
const bashSchema = Type.Object({
|
|
command: Type.String({ description: "Bash command to execute" }),
|
|
timeout: Type.Optional(
|
|
Type.Number({
|
|
description: `Timeout in seconds. Defaults to ${DEFAULT_TIMEOUT_SECONDS}s if not specified. Use a higher value for long-running builds or installations.`,
|
|
}),
|
|
),
|
|
});
|
|
|
|
export type BashToolInput = Static<typeof bashSchema>;
|
|
|
|
export interface BashToolDetails {
|
|
truncation?: TruncationResult;
|
|
fullOutputPath?: string;
|
|
}
|
|
|
|
/**
|
|
* Pluggable operations for the bash tool.
|
|
* Override these to delegate command execution to remote systems (e.g., SSH).
|
|
*/
|
|
export interface BashOperations {
|
|
/**
|
|
* Execute a command and stream output.
|
|
* @param command - The command to execute
|
|
* @param cwd - Working directory
|
|
* @param options - Execution options
|
|
* @returns Promise resolving to exit code (null if killed)
|
|
*/
|
|
exec: (
|
|
command: string,
|
|
cwd: string,
|
|
options: {
|
|
onData: (data: Buffer) => void;
|
|
signal?: AbortSignal;
|
|
timeout?: number;
|
|
env?: NodeJS.ProcessEnv;
|
|
},
|
|
) => Promise<{ exitCode: number | null }>;
|
|
}
|
|
|
|
/**
|
|
* Default bash operations using local shell
|
|
*/
|
|
const defaultBashOperations: BashOperations = {
|
|
exec: (command, cwd, { onData, signal, timeout, env }) => {
|
|
return new Promise((resolve, reject) => {
|
|
const { shell, args } = getShellConfig();
|
|
|
|
if (!existsSync(cwd)) {
|
|
reject(
|
|
new Error(
|
|
`Working directory does not exist: ${cwd}\nCannot execute bash commands.`,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
const child = spawn(shell, [...args, command], {
|
|
cwd,
|
|
detached: true,
|
|
env: env ?? getShellEnv(),
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
|
|
let timedOut = false;
|
|
|
|
// Set timeout if provided
|
|
let timeoutHandle: NodeJS.Timeout | undefined;
|
|
if (timeout !== undefined && timeout > 0) {
|
|
timeoutHandle = setTimeout(() => {
|
|
timedOut = true;
|
|
if (child.pid) {
|
|
killProcessTree(child.pid);
|
|
}
|
|
}, timeout * 1000);
|
|
}
|
|
|
|
// Stream stdout and stderr
|
|
if (child.stdout) {
|
|
child.stdout.on("data", onData);
|
|
}
|
|
if (child.stderr) {
|
|
child.stderr.on("data", onData);
|
|
}
|
|
|
|
// Handle shell spawn errors
|
|
child.on("error", (err) => {
|
|
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
if (signal) signal.removeEventListener("abort", onAbort);
|
|
reject(err);
|
|
});
|
|
|
|
// Handle abort signal - kill entire process tree
|
|
const onAbort = () => {
|
|
if (child.pid) {
|
|
killProcessTree(child.pid);
|
|
}
|
|
};
|
|
|
|
if (signal) {
|
|
if (signal.aborted) {
|
|
onAbort();
|
|
} else {
|
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
}
|
|
}
|
|
|
|
// Handle process exit
|
|
child.on("close", (code) => {
|
|
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
if (signal) signal.removeEventListener("abort", onAbort);
|
|
|
|
if (signal?.aborted) {
|
|
reject(new Error("aborted"));
|
|
return;
|
|
}
|
|
|
|
if (timedOut) {
|
|
reject(new Error(`timeout:${timeout}`));
|
|
return;
|
|
}
|
|
|
|
resolve({ exitCode: code });
|
|
});
|
|
});
|
|
},
|
|
};
|
|
|
|
export interface BashSpawnContext {
|
|
command: string;
|
|
cwd: string;
|
|
env: NodeJS.ProcessEnv;
|
|
}
|
|
|
|
export type BashSpawnHook = (context: BashSpawnContext) => BashSpawnContext;
|
|
|
|
function resolveSpawnContext(
|
|
command: string,
|
|
cwd: string,
|
|
spawnHook?: BashSpawnHook,
|
|
): BashSpawnContext {
|
|
const baseContext: BashSpawnContext = {
|
|
command,
|
|
cwd,
|
|
env: { ...getShellEnv() },
|
|
};
|
|
|
|
return spawnHook ? spawnHook(baseContext) : baseContext;
|
|
}
|
|
|
|
/**
|
|
* Combine multiple AbortSignals into one. Aborts when any input signal fires.
|
|
*/
|
|
function combineAbortSignals(
|
|
...signals: (AbortSignal | undefined)[]
|
|
): AbortSignal | undefined {
|
|
const defined = signals.filter((s): s is AbortSignal => s !== undefined);
|
|
if (defined.length === 0) return undefined;
|
|
if (defined.length === 1) return defined[0];
|
|
|
|
const controller = new AbortController();
|
|
for (const sig of defined) {
|
|
if (sig.aborted) {
|
|
controller.abort();
|
|
return controller.signal;
|
|
}
|
|
sig.addEventListener("abort", () => controller.abort(), { once: true });
|
|
}
|
|
return controller.signal;
|
|
}
|
|
|
|
export interface BashToolOptions {
|
|
/** Custom operations for command execution. Default: local shell */
|
|
operations?: BashOperations;
|
|
/** Command prefix prepended to every command (e.g., "shopt -s expand_aliases" for alias support) */
|
|
commandPrefix?: string;
|
|
/** Hook to adjust command, cwd, or env before execution */
|
|
spawnHook?: BashSpawnHook;
|
|
/** Default timeout in seconds when LLM does not specify one. Set to 0 to disable. Default: 120 */
|
|
defaultTimeoutSeconds?: number;
|
|
/** Kill process if no output received for this many seconds. Set to 0 to disable. Default: 30 */
|
|
noOutputTimeoutSeconds?: number;
|
|
}
|
|
|
|
export function createBashTool(
|
|
cwd: string,
|
|
options?: BashToolOptions,
|
|
): AgentTool<typeof bashSchema> {
|
|
const ops = options?.operations ?? defaultBashOperations;
|
|
const commandPrefix = options?.commandPrefix;
|
|
const spawnHook = options?.spawnHook;
|
|
const configDefaultTimeout = options?.defaultTimeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS;
|
|
const configNoOutputTimeout = options?.noOutputTimeoutSeconds ?? DEFAULT_NO_OUTPUT_TIMEOUT_SECONDS;
|
|
|
|
return {
|
|
name: "bash",
|
|
label: "bash",
|
|
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. Default timeout is ${configDefaultTimeout}s - provide a higher timeout for long builds or installations.`,
|
|
parameters: bashSchema,
|
|
execute: async (
|
|
_toolCallId: string,
|
|
{ command, timeout }: { command: string; timeout?: number },
|
|
signal?: AbortSignal,
|
|
onUpdate?,
|
|
) => {
|
|
// Apply default timeout when LLM does not specify one
|
|
const effectiveTimeout = timeout ?? (configDefaultTimeout > 0 ? configDefaultTimeout : undefined);
|
|
|
|
// Apply command prefix if configured (e.g., "shopt -s expand_aliases" for alias support)
|
|
const resolvedCommand = commandPrefix
|
|
? `${commandPrefix}\n${command}`
|
|
: command;
|
|
const spawnContext = resolveSpawnContext(resolvedCommand, cwd, spawnHook);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
// We'll stream to a temp file if output gets large
|
|
let tempFilePath: string | undefined;
|
|
let tempFileStream: ReturnType<typeof createWriteStream> | undefined;
|
|
let totalBytes = 0;
|
|
|
|
// No-output timeout: abort via AbortController when process goes silent.
|
|
// This keeps the kill mechanism at the tool level, not inside BashOperations.
|
|
let noOutputTimer: NodeJS.Timeout | undefined;
|
|
let noOutputTriggered = false;
|
|
const noOutputAbort = configNoOutputTimeout > 0 ? new AbortController() : undefined;
|
|
|
|
// Combine caller signal and no-output signal
|
|
const combinedSignal = noOutputAbort
|
|
? combineAbortSignals(signal, noOutputAbort.signal)
|
|
: signal;
|
|
|
|
const clearNoOutputTimer = () => {
|
|
if (noOutputTimer) {
|
|
clearTimeout(noOutputTimer);
|
|
noOutputTimer = undefined;
|
|
}
|
|
};
|
|
|
|
const resetNoOutputTimer = () => {
|
|
if (!noOutputAbort) return;
|
|
clearNoOutputTimer();
|
|
noOutputTimer = setTimeout(() => {
|
|
noOutputTriggered = true;
|
|
noOutputAbort.abort();
|
|
}, configNoOutputTimeout * 1000);
|
|
};
|
|
|
|
// Keep a rolling buffer of the last chunk for tail truncation
|
|
const chunks: Buffer[] = [];
|
|
let chunksBytes = 0;
|
|
// Keep more than we need so we have enough for truncation
|
|
const maxChunksBytes = DEFAULT_MAX_BYTES * 2;
|
|
|
|
const handleData = (data: Buffer) => {
|
|
totalBytes += data.length;
|
|
|
|
// Reset no-output timer on every chunk of output
|
|
resetNoOutputTimer();
|
|
|
|
// Start writing to temp file once we exceed the threshold
|
|
if (totalBytes > DEFAULT_MAX_BYTES && !tempFilePath) {
|
|
tempFilePath = getTempFilePath();
|
|
tempFileStream = createWriteStream(tempFilePath);
|
|
// Write all buffered chunks to the file
|
|
for (const chunk of chunks) {
|
|
tempFileStream.write(chunk);
|
|
}
|
|
}
|
|
|
|
// Write to temp file if we have one
|
|
if (tempFileStream) {
|
|
tempFileStream.write(data);
|
|
}
|
|
|
|
// Keep rolling buffer of recent data
|
|
chunks.push(data);
|
|
chunksBytes += data.length;
|
|
|
|
// Trim old chunks if buffer is too large
|
|
while (chunksBytes > maxChunksBytes && chunks.length > 1) {
|
|
const removed = chunks.shift()!;
|
|
chunksBytes -= removed.length;
|
|
}
|
|
|
|
// Stream partial output to callback (truncated rolling buffer)
|
|
if (onUpdate) {
|
|
const fullBuffer = Buffer.concat(chunks);
|
|
const fullText = fullBuffer.toString("utf-8");
|
|
const truncation = truncateTail(fullText);
|
|
onUpdate({
|
|
content: [{ type: "text", text: truncation.content || "" }],
|
|
details: {
|
|
truncation: truncation.truncated ? truncation : undefined,
|
|
fullOutputPath: tempFilePath,
|
|
},
|
|
});
|
|
}
|
|
};
|
|
|
|
// Collect output and build final text (shared by success and error paths)
|
|
const buildOutput = (): { text: string; details?: BashToolDetails } => {
|
|
const fullBuffer = Buffer.concat(chunks);
|
|
const fullOutput = fullBuffer.toString("utf-8");
|
|
const truncation = truncateTail(fullOutput);
|
|
let outputText = truncation.content || "(no output)";
|
|
let details: BashToolDetails | undefined;
|
|
|
|
if (truncation.truncated) {
|
|
details = { truncation, fullOutputPath: tempFilePath };
|
|
const startLine = truncation.totalLines - truncation.outputLines + 1;
|
|
const endLine = truncation.totalLines;
|
|
|
|
if (truncation.lastLinePartial) {
|
|
const lastLineSize = formatSize(
|
|
Buffer.byteLength(fullOutput.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}]`;
|
|
}
|
|
}
|
|
|
|
return { text: outputText, details };
|
|
};
|
|
|
|
// Start the no-output timer (will fire if command produces no output at all)
|
|
resetNoOutputTimer();
|
|
|
|
ops
|
|
.exec(spawnContext.command, spawnContext.cwd, {
|
|
onData: handleData,
|
|
signal: combinedSignal,
|
|
timeout: effectiveTimeout,
|
|
env: spawnContext.env,
|
|
})
|
|
.then(({ exitCode }) => {
|
|
clearNoOutputTimer();
|
|
if (tempFileStream) tempFileStream.end();
|
|
|
|
const { text, details } = buildOutput();
|
|
|
|
if (noOutputTriggered) {
|
|
reject(new Error(
|
|
`${text}\n\nProcess was killed after ${configNoOutputTimeout}s of no output. If this is a long-running server or build, use a higher timeout or run the process in the background with '&' and redirect output.`,
|
|
));
|
|
return;
|
|
}
|
|
|
|
if (exitCode !== 0 && exitCode !== null) {
|
|
reject(new Error(`${text}\n\nCommand exited with code ${exitCode}`));
|
|
} else {
|
|
resolve({ content: [{ type: "text", text }], details });
|
|
}
|
|
})
|
|
.catch((err: Error) => {
|
|
clearNoOutputTimer();
|
|
if (tempFileStream) tempFileStream.end();
|
|
|
|
const { text } = buildOutput();
|
|
|
|
if (noOutputTriggered) {
|
|
reject(new Error(
|
|
`${text}\n\nProcess was killed after ${configNoOutputTimeout}s of no output. If this is a long-running server or build, use a higher timeout or run the process in the background with '&' and redirect output.`,
|
|
));
|
|
} else if (err.message === "aborted") {
|
|
reject(new Error(text ? `${text}\n\nCommand aborted` : "Command aborted"));
|
|
} else if (err.message.startsWith("timeout:")) {
|
|
const timeoutSecs = err.message.split(":")[1];
|
|
reject(new Error(text ? `${text}\n\nCommand timed out after ${timeoutSecs} seconds` : `Command timed out after ${timeoutSecs} seconds`));
|
|
} else {
|
|
reject(err);
|
|
}
|
|
});
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Default bash tool using process.cwd() - for backwards compatibility */
|
|
export const bashTool = createBashTool(process.cwd());
|