fix(coding-agent): built-in tools work like extension tools
Export readToolDefinition / createReadToolDefinition and the equivalent built-in ToolDefinition APIs from @mariozechner/pi-coding-agent.
This commit is contained in:
@@ -3,14 +3,21 @@ 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 { Container, Text, truncateToWidth } from "@mariozechner/pi-tui";
|
||||
import { type Static, Type } from "@sinclair/typebox";
|
||||
import { spawn } from "child_process";
|
||||
import { keyHint } from "../../modes/interactive/components/keybinding-hints.js";
|
||||
import { truncateToVisualLines } from "../../modes/interactive/components/visual-truncate.js";
|
||||
import { theme } from "../../modes/interactive/theme/theme.js";
|
||||
import { waitForChildProcess } from "../../utils/child-process.js";
|
||||
import { getShellConfig, getShellEnv, killProcessTree } from "../../utils/shell.js";
|
||||
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.js";
|
||||
import { getTextOutput, invalidArgText, str } from "./render-utils.js";
|
||||
import { wrapToolDefinition } from "./tool-definition-wrapper.js";
|
||||
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateTail } from "./truncate.js";
|
||||
|
||||
/**
|
||||
* Generate a unique temp file path for bash output
|
||||
* Generate a unique temp file path for bash output.
|
||||
*/
|
||||
function getTempFilePath(): string {
|
||||
const id = randomBytes(8).toString("hex");
|
||||
@@ -31,14 +38,14 @@ export interface BashToolDetails {
|
||||
|
||||
/**
|
||||
* Pluggable operations for the bash tool.
|
||||
* Override these to delegate command execution to remote systems (e.g., SSH).
|
||||
* Override these to delegate command execution to remote systems (for example SSH).
|
||||
*/
|
||||
export interface BashOperations {
|
||||
/**
|
||||
* Execute a command and stream output.
|
||||
* @param command - The command to execute
|
||||
* @param cwd - Working directory
|
||||
* @param options - Execution options
|
||||
* @param command The command to execute
|
||||
* @param cwd Working directory
|
||||
* @param options Execution options
|
||||
* @returns Promise resolving to exit code (null if killed)
|
||||
*/
|
||||
exec: (
|
||||
@@ -56,81 +63,58 @@ export interface BashOperations {
|
||||
/**
|
||||
* Create bash operations using pi's built-in local shell execution backend.
|
||||
*
|
||||
* This is useful for extensions that intercept user_bash and want to keep
|
||||
* pi's standard local shell behavior while still wrapping or rewriting
|
||||
* commands before execution.
|
||||
* 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(): BashOperations {
|
||||
return {
|
||||
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;
|
||||
// Set timeout if provided.
|
||||
if (timeout !== undefined && timeout > 0) {
|
||||
timeoutHandle = setTimeout(() => {
|
||||
timedOut = true;
|
||||
if (child.pid) {
|
||||
killProcessTree(child.pid);
|
||||
}
|
||||
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 abort signal - kill entire process tree
|
||||
// Stream stdout and stderr.
|
||||
child.stdout?.on("data", onData);
|
||||
child.stderr?.on("data", onData);
|
||||
// Handle abort signal by killing the entire process tree.
|
||||
const onAbort = () => {
|
||||
if (child.pid) {
|
||||
killProcessTree(child.pid);
|
||||
}
|
||||
if (child.pid) killProcessTree(child.pid);
|
||||
};
|
||||
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
onAbort();
|
||||
} else {
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
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.
|
||||
waitForChildProcess(child)
|
||||
.then((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 });
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -152,85 +136,182 @@ export interface BashSpawnContext {
|
||||
export type BashSpawnHook = (context: BashSpawnContext) => BashSpawnContext;
|
||||
|
||||
function resolveSpawnContext(command: string, cwd: string, spawnHook?: BashSpawnHook): BashSpawnContext {
|
||||
const baseContext: BashSpawnContext = {
|
||||
command,
|
||||
cwd,
|
||||
env: { ...getShellEnv() },
|
||||
};
|
||||
|
||||
const baseContext: BashSpawnContext = { command, cwd, env: { ...getShellEnv() } };
|
||||
return spawnHook ? spawnHook(baseContext) : baseContext;
|
||||
}
|
||||
|
||||
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) */
|
||||
/** Command prefix prepended to every command (for example shell setup commands) */
|
||||
commandPrefix?: string;
|
||||
/** Hook to adjust command, cwd, or env before execution */
|
||||
spawnHook?: BashSpawnHook;
|
||||
}
|
||||
|
||||
export function createBashTool(cwd: string, options?: BashToolOptions): AgentTool<typeof bashSchema> {
|
||||
const BASH_PREVIEW_LINES = 5;
|
||||
|
||||
type BashRenderState = {
|
||||
startedAt: number | undefined;
|
||||
endedAt: number | undefined;
|
||||
interval: NodeJS.Timeout | undefined;
|
||||
};
|
||||
|
||||
type BashResultRenderState = {
|
||||
cachedWidth: number | undefined;
|
||||
cachedLines: string[] | undefined;
|
||||
cachedSkipped: number | undefined;
|
||||
};
|
||||
|
||||
class BashResultRenderComponent extends Container {
|
||||
state: BashResultRenderState = {
|
||||
cachedWidth: undefined,
|
||||
cachedLines: undefined,
|
||||
cachedSkipped: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
}
|
||||
|
||||
function formatBashCall(args: { command?: string; timeout?: number } | undefined): string {
|
||||
const command = str(args?.command);
|
||||
const timeout = args?.timeout as number | undefined;
|
||||
const timeoutSuffix = timeout ? theme.fg("muted", ` (timeout ${timeout}s)`) : "";
|
||||
const commandDisplay = command === null ? invalidArgText(theme) : command ? command : theme.fg("toolOutput", "...");
|
||||
return theme.fg("toolTitle", theme.bold(`$ ${commandDisplay}`)) + timeoutSuffix;
|
||||
}
|
||||
|
||||
function rebuildBashResultRenderComponent(
|
||||
component: BashResultRenderComponent,
|
||||
result: {
|
||||
content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
|
||||
details?: BashToolDetails;
|
||||
},
|
||||
options: ToolRenderResultOptions,
|
||||
showImages: boolean,
|
||||
startedAt: number | undefined,
|
||||
endedAt: number | undefined,
|
||||
): void {
|
||||
const state = component.state;
|
||||
component.clear();
|
||||
|
||||
const output = getTextOutput(result as any, showImages).trim();
|
||||
|
||||
if (output) {
|
||||
const styledOutput = output
|
||||
.split("\n")
|
||||
.map((line) => theme.fg("toolOutput", line))
|
||||
.join("\n");
|
||||
|
||||
if (options.expanded) {
|
||||
component.addChild(new Text(`\n${styledOutput}`, 0, 0));
|
||||
} else {
|
||||
component.addChild({
|
||||
render: (width: number) => {
|
||||
if (state.cachedLines === undefined || state.cachedWidth !== width) {
|
||||
const preview = truncateToVisualLines(styledOutput, BASH_PREVIEW_LINES, width);
|
||||
state.cachedLines = preview.visualLines;
|
||||
state.cachedSkipped = preview.skippedCount;
|
||||
state.cachedWidth = width;
|
||||
}
|
||||
if (state.cachedSkipped && state.cachedSkipped > 0) {
|
||||
const hint =
|
||||
theme.fg("muted", `... (${state.cachedSkipped} earlier lines,`) +
|
||||
` ${keyHint("app.tools.expand", "to expand")})`;
|
||||
return ["", truncateToWidth(hint, width, "..."), ...(state.cachedLines ?? [])];
|
||||
}
|
||||
return ["", ...(state.cachedLines ?? [])];
|
||||
},
|
||||
invalidate: () => {
|
||||
state.cachedWidth = undefined;
|
||||
state.cachedLines = undefined;
|
||||
state.cachedSkipped = undefined;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const truncation = result.details?.truncation;
|
||||
const fullOutputPath = result.details?.fullOutputPath;
|
||||
if (truncation?.truncated || fullOutputPath) {
|
||||
const warnings: string[] = [];
|
||||
if (fullOutputPath) {
|
||||
warnings.push(`Full output: ${fullOutputPath}`);
|
||||
}
|
||||
if (truncation?.truncated) {
|
||||
if (truncation.truncatedBy === "lines") {
|
||||
warnings.push(`Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`);
|
||||
} else {
|
||||
warnings.push(
|
||||
`Truncated: ${truncation.outputLines} lines shown (${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
component.addChild(new Text(`\n${theme.fg("warning", `[${warnings.join(". ")}]`)}`, 0, 0));
|
||||
}
|
||||
|
||||
if (startedAt !== undefined) {
|
||||
const label = options.isPartial ? "Elapsed" : "Took";
|
||||
const endTime = endedAt ?? Date.now();
|
||||
component.addChild(new Text(`\n${theme.fg("muted", `${label} ${formatDuration(endTime - startedAt)}`)}`, 0, 0));
|
||||
}
|
||||
}
|
||||
|
||||
export function createBashToolDefinition(
|
||||
cwd: string,
|
||||
options?: BashToolOptions,
|
||||
): ToolDefinition<typeof bashSchema, BashToolDetails | undefined, BashRenderState> {
|
||||
const ops = options?.operations ?? createLocalBashOperations();
|
||||
const commandPrefix = options?.commandPrefix;
|
||||
const spawnHook = options?.spawnHook;
|
||||
|
||||
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. Optionally provide a timeout in seconds.`,
|
||||
promptSnippet: "Execute bash commands (ls, grep, find, etc.)",
|
||||
parameters: bashSchema,
|
||||
execute: async (
|
||||
_toolCallId: string,
|
||||
async execute(
|
||||
_toolCallId,
|
||||
{ command, timeout }: { command: string; timeout?: number },
|
||||
signal?: AbortSignal,
|
||||
onUpdate?,
|
||||
) => {
|
||||
// Apply command prefix if configured (e.g., "shopt -s expand_aliases" for alias support)
|
||||
_ctx?,
|
||||
) {
|
||||
const resolvedCommand = commandPrefix ? `${commandPrefix}\n${command}` : command;
|
||||
const spawnContext = resolveSpawnContext(resolvedCommand, cwd, spawnHook);
|
||||
|
||||
if (onUpdate) {
|
||||
onUpdate({ content: [], details: undefined });
|
||||
}
|
||||
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;
|
||||
|
||||
// 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;
|
||||
|
||||
// Start writing to temp file once we exceed the threshold
|
||||
// Start writing to a temp file once output exceeds the in-memory 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 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
|
||||
// Write to temp file if we have one.
|
||||
if (tempFileStream) tempFileStream.write(data);
|
||||
// Keep a rolling buffer of recent output for tail truncation.
|
||||
chunks.push(data);
|
||||
chunksBytes += data.length;
|
||||
|
||||
// Trim old chunks if buffer is too large
|
||||
// Trim old chunks if the rolling buffer grows too large.
|
||||
while (chunksBytes > maxChunksBytes && chunks.length > 1) {
|
||||
const removed = chunks.shift()!;
|
||||
chunksBytes -= removed.length;
|
||||
}
|
||||
|
||||
// Stream partial output to callback (truncated rolling buffer)
|
||||
// Stream partial output using the rolling tail buffer.
|
||||
if (onUpdate) {
|
||||
const fullBuffer = Buffer.concat(chunks);
|
||||
const fullText = fullBuffer.toString("utf-8");
|
||||
@@ -252,34 +333,22 @@ export function createBashTool(cwd: string, options?: BashToolOptions): AgentToo
|
||||
env: spawnContext.env,
|
||||
})
|
||||
.then(({ exitCode }) => {
|
||||
// Close temp file stream
|
||||
if (tempFileStream) {
|
||||
tempFileStream.end();
|
||||
}
|
||||
|
||||
// Combine all buffered chunks
|
||||
// Close temp file stream before building the final result.
|
||||
if (tempFileStream) tempFileStream.end();
|
||||
// Combine the rolling buffer chunks.
|
||||
const fullBuffer = Buffer.concat(chunks);
|
||||
const fullOutput = fullBuffer.toString("utf-8");
|
||||
|
||||
// Apply tail truncation
|
||||
// Apply tail truncation for the final display payload.
|
||||
const truncation = truncateTail(fullOutput);
|
||||
let outputText = truncation.content || "(no output)";
|
||||
|
||||
// Build details with truncation info
|
||||
let details: BashToolDetails | undefined;
|
||||
|
||||
if (truncation.truncated) {
|
||||
details = {
|
||||
truncation,
|
||||
fullOutputPath: tempFilePath,
|
||||
};
|
||||
|
||||
// Build actionable notice
|
||||
// Build truncation details and an actionable notice.
|
||||
details = { truncation, fullOutputPath: tempFilePath };
|
||||
const startLine = truncation.totalLines - truncation.outputLines + 1;
|
||||
const endLine = truncation.totalLines;
|
||||
|
||||
if (truncation.lastLinePartial) {
|
||||
// Edge case: last line alone > 30KB
|
||||
// Edge case: the last line alone is larger than the byte limit.
|
||||
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") {
|
||||
@@ -288,7 +357,6 @@ export function createBashTool(cwd: string, options?: BashToolOptions): AgentToo
|
||||
outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Full output: ${tempFilePath}]`;
|
||||
}
|
||||
}
|
||||
|
||||
if (exitCode !== 0 && exitCode !== null) {
|
||||
outputText += `\n\nCommand exited with code ${exitCode}`;
|
||||
reject(new Error(outputText));
|
||||
@@ -297,15 +365,10 @@ export function createBashTool(cwd: string, options?: BashToolOptions): AgentToo
|
||||
}
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
// Close temp file stream
|
||||
if (tempFileStream) {
|
||||
tempFileStream.end();
|
||||
}
|
||||
|
||||
// Combine all buffered chunks for error output
|
||||
// Close temp file stream and include buffered output in the error message.
|
||||
if (tempFileStream) tempFileStream.end();
|
||||
const fullBuffer = Buffer.concat(chunks);
|
||||
let output = fullBuffer.toString("utf-8");
|
||||
|
||||
if (err.message === "aborted") {
|
||||
if (output) output += "\n\n";
|
||||
output += "Command aborted";
|
||||
@@ -321,8 +384,48 @@ export function createBashTool(cwd: string, options?: BashToolOptions): AgentToo
|
||||
});
|
||||
});
|
||||
},
|
||||
renderCall(args, _theme, context) {
|
||||
const state = context.state;
|
||||
if (context.executionStarted && state.startedAt === undefined) {
|
||||
state.startedAt = Date.now();
|
||||
state.endedAt = undefined;
|
||||
}
|
||||
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
||||
text.setText(formatBashCall(args));
|
||||
return text;
|
||||
},
|
||||
renderResult(result, options, _theme, context) {
|
||||
const state = context.state;
|
||||
if (state.startedAt !== undefined && options.isPartial && !state.interval) {
|
||||
state.interval = setInterval(() => context.invalidate(), 1000);
|
||||
}
|
||||
if (!options.isPartial || context.isError) {
|
||||
state.endedAt ??= Date.now();
|
||||
if (state.interval) {
|
||||
clearInterval(state.interval);
|
||||
state.interval = undefined;
|
||||
}
|
||||
}
|
||||
const component =
|
||||
(context.lastComponent as BashResultRenderComponent | undefined) ?? new BashResultRenderComponent();
|
||||
rebuildBashResultRenderComponent(
|
||||
component,
|
||||
result as any,
|
||||
options,
|
||||
context.showImages,
|
||||
state.startedAt,
|
||||
state.endedAt,
|
||||
);
|
||||
component.invalidate();
|
||||
return component;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Default bash tool using process.cwd() - for backwards compatibility */
|
||||
export function createBashTool(cwd: string, options?: BashToolOptions): AgentTool<typeof bashSchema> {
|
||||
return wrapToolDefinition(createBashToolDefinition(cwd, options));
|
||||
}
|
||||
|
||||
/** Default bash tool using process.cwd() for backwards compatibility. */
|
||||
export const bashToolDefinition = createBashToolDefinition(process.cwd());
|
||||
export const bashTool = createBashTool(process.cwd());
|
||||
|
||||
Reference in New Issue
Block a user