| import { constants } from "node:fs"; |
| import { access as fsAccess } from "node:fs/promises"; |
| import type { AgentTool } from "@earendil-works/pi-agent-core"; |
| import { spawn } from "child_process"; |
| import { type Static, Type } from "typebox"; |
| import { waitForChildProcess } from "../../utils/child-process.ts"; |
| import { |
| getShellConfig, |
| getShellEnv, |
| killProcessTree, |
| type ShellConfig, |
| trackDetachedChildPid, |
| untrackDetachedChildPid, |
| } from "../../utils/shell.ts"; |
| import type { ExtensionContext, ToolDefinition } from "../extensions/types.ts"; |
| import { OutputAccumulator } from "./output-accumulator.ts"; |
| import { BASH_UPDATE_THROTTLE_MS, createShellRenderers } from "./renderers/bash.ts"; |
| import { wrapToolDefinition } from "./tool-definition-wrapper.ts"; |
| import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult } from "./truncate.ts"; |
|
|
| const MAX_TIMEOUT_MS = 2_147_483_647; |
| const MAX_TIMEOUT_SECONDS = MAX_TIMEOUT_MS / 1000; |
|
|
| function resolveTimeoutMs(timeout: number | undefined): number | undefined { |
| 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."], |
| } as const; |
|
|
| export type BashToolInput = Static<typeof bashSchema>; |
|
|
| export interface BashToolDetails { |
| truncation?: TruncationResult; |
| fullOutputPath?: string; |
| } |
|
|
| |
| |
| |
| |
| export interface BashOperations { |
| |
| |
| |
| |
| |
| |
| |
| exec: ( |
| command: string, |
| cwd: string, |
| options: { |
| onData: (data: Buffer) => void; |
| signal?: AbortSignal; |
| timeout?: number; |
| env?: NodeJS.ProcessEnv; |
| }, |
| ) => Promise<{ exitCode: number | null }>; |
| } |
|
|
| |
| export function createLocalShellOperations(shellName: string, resolveShellConfig: () => ShellConfig): BashOperations { |
| 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); |
| let timedOut = false; |
| let timeoutHandle: NodeJS.Timeout | undefined; |
| const onAbort = () => { |
| if (child.pid) killProcessTree(child.pid); |
| }; |
|
|
| try { |
| |
| if (timeoutMs !== undefined) { |
| timeoutHandle = setTimeout(() => { |
| timedOut = true; |
| if (child.pid) killProcessTree(child.pid); |
| }, timeoutMs); |
| } |
| |
| child.stdout?.on("data", onData); |
| child.stderr?.on("data", onData); |
| |
| if (signal) { |
| if (signal.aborted) onAbort(); |
| else signal.addEventListener("abort", onAbort, { once: true }); |
| } |
| |
| |
| const exitCode = await waitForChildProcess(child); |
| if (signal?.aborted) { |
| throw new Error("aborted"); |
| } |
| if (timedOut) { |
| throw new Error(`timeout:${timeout}`); |
| } |
| return { exitCode }; |
| } finally { |
| if (child.pid) untrackDetachedChildPid(child.pid); |
| if (timeoutHandle) clearTimeout(timeoutHandle); |
| if (signal) signal.removeEventListener("abort", onAbort); |
| } |
| }, |
| }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| export function createLocalBashOperations(options?: { shellPath?: string }): BashOperations { |
| return createLocalShellOperations("bash", () => getShellConfig(options?.shellPath)); |
| } |
|
|
| export interface BashSpawnContext { |
| command: string; |
| cwd: string; |
| env: NodeJS.ProcessEnv; |
| } |
|
|
| export type BashSpawnHook = (context: BashSpawnContext) => BashSpawnContext; |
|
|
| function resolveSpawnContext( |
| command: string, |
| cwd: string, |
| spawnHook: BashSpawnHook | undefined, |
| exposeSessionEnvironment: boolean, |
| ctx: ExtensionContext | undefined, |
| ): BashSpawnContext { |
| 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: BashSpawnContext = { command, cwd, env }; |
| return spawnHook ? spawnHook(baseContext) : baseContext; |
| } |
|
|
| export interface BashToolOptions { |
| |
| operations?: BashOperations; |
| |
| commandPrefix?: string; |
| |
| shellPath?: string; |
| |
| exposeSessionEnvironment?: boolean; |
| |
| spawnHook?: BashSpawnHook; |
| } |
|
|
| export type BashRenderState = { |
| startedAt: number | undefined; |
| endedAt: number | undefined; |
| interval: NodeJS.Timeout | undefined; |
| }; |
|
|
| export interface ShellToolConfig { |
| name: string; |
| label: string; |
| shellName: string; |
| prompt: string; |
| promptSnippet: string; |
| promptGuidelines?: readonly string[]; |
| tempFilePrefix: string; |
| } |
|
|
| export function createShellToolDefinition( |
| cwd: string, |
| config: ShellToolConfig, |
| options?: BashToolOptions, |
| ): ToolDefinition<typeof bashSchema, BashToolDetails | undefined, BashRenderState> { |
| 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, |
| constrainedSampling: { type: "json_schema", strict: "prefer" }, |
| async execute( |
| _toolCallId, |
| { command, timeout }: { command: string; timeout?: number }, |
| signal?: AbortSignal, |
| onUpdate?, |
| ctx?: ExtensionContext, |
| ) { |
| 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: NodeJS.Timeout | undefined; |
| 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: Buffer) => { |
| 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: Awaited<ReturnType<typeof finishOutput>>, emptyText = "(no output)") => { |
| const truncation = snapshot.truncation; |
| let text = snapshot.content || emptyText; |
| let details: BashToolDetails | undefined; |
| 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: string, status: string) => `${text ? `${text}\n\n` : ""}${status}`; |
|
|
| try { |
| let exitCode: number | null; |
| 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]; |
| throw new Error(appendStatus(text, `Command timed out after ${timeoutSecs} seconds`)); |
| } |
| throw err; |
| } |
|
|
| const snapshot = await finishOutput(); |
| const { text: outputText, details } = formatOutput(snapshot); |
| if (exitCode !== 0 && exitCode !== null) { |
| throw new Error(appendStatus(outputText, `Command exited with code ${exitCode}`)); |
| } |
| return { content: [{ type: "text", text: outputText }], details }; |
| } finally { |
| clearUpdateTimer(); |
| } |
| }, |
| ...createShellRenderers(config.prompt), |
| }; |
| } |
|
|
| const bashToolConfig: ShellToolConfig = { |
| name: "bash", |
| label: "bash", |
| shellName: "bash", |
| prompt: "$", |
| promptSnippet: bashToolSystemPromptContribution.snippet, |
| promptGuidelines: bashToolSystemPromptContribution.guidelines, |
| tempFilePrefix: "pi-bash", |
| }; |
|
|
| export function createBashToolDefinition( |
| cwd: string, |
| options?: BashToolOptions, |
| ): ToolDefinition<typeof bashSchema, BashToolDetails | undefined, BashRenderState> { |
| return createShellToolDefinition(cwd, bashToolConfig, options); |
| } |
|
|
| export function createBashTool(cwd: string, options?: BashToolOptions): AgentTool<typeof bashSchema> { |
| const definition = createBashToolDefinition(cwd, options); |
| const tool = wrapToolDefinition(definition); |
| Object.assign(tool, { |
| promptSnippet: definition.promptSnippet, |
| promptGuidelines: definition.promptGuidelines, |
| }); |
| return tool; |
| } |
|
|