UNPKG

@earendil-works/pi-coding-agent

Version:

Coding agent CLI with read, bash, edit, write tools and session management

363 lines • 17.6 kB
import { constants } from "node:fs"; import { access as fsAccess } from "node:fs/promises"; import { constants as osConstants } from "node:os"; import { spawn } from "child_process"; import { Type } from "typebox"; import { waitForChildProcess } from "../../utils/child-process.js"; import { getShellConfig, getShellEnv, killProcessTree, trackDetachedChildPid, untrackDetachedChildPid, } from "../../utils/shell.js"; import { OutputAccumulator } from "./output-accumulator.js"; import { BASH_UPDATE_THROTTLE_MS, createShellRenderers } from "./renderers/bash.js"; import { wrapToolDefinition } from "./tool-definition-wrapper.js"; import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize } from "./truncate.js"; const MAX_TIMEOUT_MS = 2_147_483_647; /** Output limit of `structuredContent.output`, which programmatic callers such as codemode scripts receive. */ const STRUCTURED_OUTPUT_MAX_BYTES = 1024 * 1024; const MAX_TIMEOUT_SECONDS = MAX_TIMEOUT_MS / 1000; function resolveTimeoutMs(timeout) { if (timeout === undefined) return undefined; if (!Number.isFinite(timeout) || timeout <= 0) { throw new Error("Invalid timeout: must be a finite number of seconds"); } const timeoutMs = timeout * 1000; if (timeoutMs > MAX_TIMEOUT_MS) { throw new Error(`Invalid timeout: maximum is ${MAX_TIMEOUT_SECONDS} seconds`); } return timeoutMs; } const bashSchema = Type.Object({ command: Type.String({ description: "Shell command to execute" }), timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (optional, no default timeout)" })), }); export const bashToolSystemPromptContribution = { snippet: "Execute bash commands (ls, grep, find, etc.)", guidelines: ["You can inspect PI_* environment variables for current model and session details."], }; /** * Result for programmatic callers such as codemode scripts. A non-zero exit code is an error result for the model, but scripts still resolve to this value. * `output` is not limited like the model-facing output: callers decide how much of it reaches the model. */ const bashOutputSchema = Type.Object({ output: Type.String({ description: "Combined stdout and stderr, possibly truncated" }), truncated: Type.Boolean(), full_output_path: Type.Optional(Type.String({ description: "Full output, when truncated" })), exit_code: Type.Number(), wall_time_seconds: Type.Number(), }); /** * Timeout of the local shell backend. Its message keeps the `timeout:<seconds>` form custom operations throw. * `shellExit` is set when the shell itself exited before the deadline, so the tool was only still waiting * because background processes kept writing to the inherited stdout/stderr pipes (#11613). */ class ShellTimeoutError extends Error { shellExit; constructor(timeout, shellExit) { super(`timeout:${timeout}`); this.shellExit = shellExit; } } /** Shared process execution used by the built-in shell tools. */ export function createLocalShellOperations(shellName, resolveShellConfig) { return { exec: async (command, cwd, { onData, signal, timeout, env }) => { const timeoutMs = resolveTimeoutMs(timeout); if (signal?.aborted) { throw new Error("aborted"); } const shellConfig = resolveShellConfig(); try { await fsAccess(cwd, constants.F_OK); } catch { throw new Error(`Working directory does not exist: ${cwd}\nCannot execute ${shellName} commands.`); } const commandFromStdin = shellConfig.commandTransport === "stdin"; const child = spawn(shellConfig.shell, commandFromStdin ? shellConfig.args : [...shellConfig.args, command], { cwd, detached: process.platform !== "win32", env: env ?? getShellEnv(), stdio: [commandFromStdin ? "pipe" : "ignore", "pipe", "pipe"], windowsHide: true, }); if (commandFromStdin) { child.stdin?.on("error", () => { }); child.stdin?.end(command); } if (child.pid) trackDetachedChildPid(child.pid); const startedAt = performance.now(); let shellExitedAfterMs; child.once("exit", () => { shellExitedAfterMs = performance.now() - startedAt; }); let timedOut = false; let exitedBeforeDeadlineAfterMs; let timeoutHandle; const onAbort = () => { if (child.pid) killProcessTree(child.pid); }; try { // Set timeout if provided. if (timeoutMs !== undefined) { timeoutHandle = setTimeout(() => { timedOut = true; exitedBeforeDeadlineAfterMs = shellExitedAfterMs; // Also when the shell already exited: on Unix the group kill still reaches background // processes holding the pipes. Windows cannot find them once the shell pid is gone. if (child.pid) killProcessTree(child.pid); }, timeoutMs); } // Stream stdout and stderr. child.stdout?.on("data", onData); child.stderr?.on("data", onData); // Handle abort signal by killing the entire process tree. if (signal) { if (signal.aborted) onAbort(); else signal.addEventListener("abort", onAbort, { once: true }); } // Handle shell spawn errors and wait for the process to terminate without hanging // on inherited stdio handles held by detached descendants. const exitCode = await waitForChildProcess(child); if (signal?.aborted) { throw new Error("aborted"); } // A signal-killed shell has no exit code. Use the standard shell convention so // callers do not mistake the termination for a successful command. const signalCode = child.signalCode; const resolvedExitCode = exitCode ?? (signalCode ? 128 + (osConstants.signals[signalCode] ?? 0) : 1); if (timedOut) { throw new ShellTimeoutError(timeout, exitedBeforeDeadlineAfterMs !== undefined ? { exitCode: resolvedExitCode, afterSeconds: Math.round(exitedBeforeDeadlineAfterMs / 100) / 10 } : undefined); } return { exitCode: resolvedExitCode }; } finally { if (child.pid) untrackDetachedChildPid(child.pid); if (timeoutHandle) clearTimeout(timeoutHandle); if (signal) signal.removeEventListener("abort", onAbort); } }, }; } /** * Create bash operations using pi's built-in local shell execution backend. * * This is useful for extensions that intercept user_bash and still want pi's * standard local shell behavior while wrapping or rewriting commands. */ export function createLocalBashOperations(options) { return createLocalShellOperations("bash", () => getShellConfig(options?.shellPath)); } function resolveSpawnContext(command, cwd, spawnHook, exposeSessionEnvironment, ctx) { const env = { ...getShellEnv() }; delete env.PI_SESSION_ID; delete env.PI_SESSION_FILE; delete env.PI_PROVIDER; delete env.PI_MODEL; delete env.PI_REASONING_LEVEL; if (exposeSessionEnvironment && ctx) { const model = ctx.model; env.PI_SESSION_ID = ctx.sessionManager.getSessionId(); const sessionFile = ctx.sessionManager.getSessionFile(); if (sessionFile) env.PI_SESSION_FILE = sessionFile; if (model) { env.PI_PROVIDER = model.provider; env.PI_MODEL = model.id; } if (ctx.thinkingLevel) env.PI_REASONING_LEVEL = ctx.thinkingLevel; } const baseContext = { command, cwd, env }; return spawnHook ? spawnHook(baseContext) : baseContext; } export function createShellToolDefinition(cwd, config, options) { const ops = options?.operations ?? createLocalBashOperations({ shellPath: options?.shellPath }); const commandPrefix = options?.commandPrefix; const exposeSessionEnvironment = options?.exposeSessionEnvironment ?? true; const spawnHook = options?.spawnHook; return { name: config.name, label: config.label, description: `Execute a ${config.shellName} 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. Optionally provide a timeout in seconds.`, promptSnippet: config.promptSnippet, promptGuidelines: exposeSessionEnvironment && config.promptGuidelines ? [...config.promptGuidelines] : undefined, parameters: bashSchema, outputSchema: bashOutputSchema, constrainedSampling: { type: "json_schema", strict: "prefer" }, async execute(_toolCallId, { command, timeout }, signal, onUpdate, ctx) { const resolvedCommand = commandPrefix ? `${commandPrefix}\n${command}` : command; const spawnContext = resolveSpawnContext(resolvedCommand, ctx?.cwd || cwd, spawnHook, exposeSessionEnvironment, ctx); const output = new OutputAccumulator({ tempFilePrefix: config.tempFilePrefix }); let acceptingOutput = true; let updateTimer; let updateDirty = false; let lastUpdateAt = 0; const emitOutputUpdate = () => { if (!onUpdate || !updateDirty) return; updateDirty = false; lastUpdateAt = Date.now(); const snapshot = output.snapshot({ persistIfTruncated: true }); onUpdate({ content: [{ type: "text", text: snapshot.content || "" }], details: { truncation: snapshot.truncation.truncated ? snapshot.truncation : undefined, fullOutputPath: snapshot.fullOutputPath, }, }); }; const clearUpdateTimer = () => { if (updateTimer) { clearTimeout(updateTimer); updateTimer = undefined; } }; const scheduleOutputUpdate = () => { if (!onUpdate) return; updateDirty = true; const delay = BASH_UPDATE_THROTTLE_MS - (Date.now() - lastUpdateAt); if (delay <= 0) { clearUpdateTimer(); emitOutputUpdate(); return; } updateTimer ??= setTimeout(() => { updateTimer = undefined; emitOutputUpdate(); }, delay); }; if (onUpdate) { onUpdate({ content: [], details: undefined }); } const handleData = (data) => { if (!acceptingOutput) return; output.append(data); scheduleOutputUpdate(); }; const finishOutput = async () => { acceptingOutput = false; output.finish(); clearUpdateTimer(); emitOutputUpdate(); const snapshot = output.snapshot({ persistIfTruncated: true }); await output.closeTempFile(); return snapshot; }; const formatOutput = (snapshot, emptyText = "(no output)") => { const truncation = snapshot.truncation; let text = snapshot.content || emptyText; let details; if (truncation.truncated) { details = { truncation, fullOutputPath: snapshot.fullOutputPath }; const startLine = truncation.totalLines - truncation.outputLines + 1; const endLine = truncation.totalLines; if (truncation.lastLinePartial) { const lastLineSize = formatSize(output.getLastLineBytes()); text += `\n\n[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize}). Full output: ${snapshot.fullOutputPath}]`; } else if (truncation.truncatedBy === "lines") { text += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}. Full output: ${snapshot.fullOutputPath}]`; } else { text += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Full output: ${snapshot.fullOutputPath}]`; } } return { text, details }; }; const appendStatus = (text, status) => `${text ? `${text}\n\n` : ""}${status}`; const startedAt = performance.now(); try { let exitCode; try { const result = await ops.exec(spawnContext.command, spawnContext.cwd, { onData: handleData, signal, timeout, env: spawnContext.env, }); exitCode = result.exitCode; } catch (err) { const snapshot = await finishOutput(); const { text } = formatOutput(snapshot, ""); if (err instanceof Error && err.message === "aborted") { throw new Error(appendStatus(text, "Command aborted")); } if (err instanceof Error && err.message.startsWith("timeout:")) { const timeoutSecs = err.message.split(":")[1]; let status = `Command timed out after ${timeoutSecs} seconds`; if (err instanceof ShellTimeoutError && err.shellExit) { // Both facts matter: the command's real result, and that its background processes // were still writing output when the timeout hit (#11613). status += `. The shell exited with code ${err.shellExit.exitCode} after ${err.shellExit.afterSeconds} seconds, but background processes it started kept writing output until the timeout.`; } throw new Error(appendStatus(text, status)); } throw err; } const snapshot = await finishOutput(); const { text: outputText, details } = formatOutput(snapshot); if (exitCode === null) { throw new Error(appendStatus(outputText, "Command terminated without an exit code")); } const wallTimeSeconds = Math.round((performance.now() - startedAt) / 100) / 10; const fullOutput = await output.readFullOutput(STRUCTURED_OUTPUT_MAX_BYTES); const structuredContent = { output: fullOutput.content, truncated: fullOutput.truncated, ...(fullOutput.truncated && snapshot.fullOutputPath ? { full_output_path: snapshot.fullOutputPath } : {}), exit_code: exitCode, wall_time_seconds: wallTimeSeconds, }; if (exitCode !== 0) { return { content: [{ type: "text", text: appendStatus(outputText, `Command exited with code ${exitCode}`) }], details, structuredContent, isError: true, }; } return { content: [{ type: "text", text: outputText }], details, structuredContent }; } finally { clearUpdateTimer(); } }, ...createShellRenderers(config.prompt), }; } const bashToolConfig = { name: "bash", label: "bash", shellName: "bash", prompt: "$", promptSnippet: bashToolSystemPromptContribution.snippet, promptGuidelines: bashToolSystemPromptContribution.guidelines, tempFilePrefix: "pi-bash", }; export function createBashToolDefinition(cwd, options) { return createShellToolDefinition(cwd, bashToolConfig, options); } export function createBashTool(cwd, options) { const definition = createBashToolDefinition(cwd, options); const tool = wrapToolDefinition(definition); Object.assign(tool, { promptSnippet: definition.promptSnippet, promptGuidelines: definition.promptGuidelines, }); return tool; } //# sourceMappingURL=bash.js.map