gadget/gadget-drone/src/tools/system/shell.ts
Rob Colbert a4cff5be69 agent toolbox refactor and updates
- reorganized tools into better named directories
- added the file & shell tools
2026-05-10 07:35:09 -04:00

148 lines
4.5 KiB
TypeScript

// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
// Licensed under the Apache License, Version 2.0
import { exec } from "node:child_process";
import path from "node:path";
import { promisify } from "node:util";
import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai";
import { DroneTool } from "../tool.ts";
import { getProjectRoot, resolveProjectPath, toolError } from "./common.ts";
const execAsync = promisify(exec);
const DEFAULT_TIMEOUT = 30_000;
const MAX_TIMEOUT = 120_000;
export class ShellExecTool extends DroneTool {
get name(): string {
return "shell_exec";
}
get category(): string {
return "system";
}
public definition: IToolDefinition = {
type: "function",
function: {
name: "shell_exec",
description: "Execute a shell command in the project directory using Bash. Returns stdout, stderr, and exit code. For temporary/scratch work, create and use a tmp directory inside the .gadget workspace directory.",
parameters: {
type: "object",
properties: {
command: {
type: "string",
description: "The shell command to execute with Bash.",
},
cwd: {
type: "string",
description: "Working directory for the command, relative to the project root. Defaults to the project root.",
},
timeout: {
type: "number",
description: `Timeout in milliseconds. Default: ${DEFAULT_TIMEOUT / 1000}s, max: ${MAX_TIMEOUT / 1000}s.`,
},
},
required: ["command"],
},
},
};
public async execute(args: IToolArguments, logger: IAiLogger): Promise<string> {
const command = args.command;
if (typeof command !== "string" || command.trim().length === 0) {
return toolError({
code: "MISSING_PARAMETER",
message: "Command must not be empty.",
parameter: "command",
recoveryHint: "Provide a valid shell command to execute with Bash.",
});
}
const projectRoot = getProjectRoot(this.toolbox);
if (!projectRoot) {
return toolError({
code: "OPERATION_NOT_ALLOWED",
message: "No active project workspace is configured for shell execution.",
recoveryHint: "Run this tool during an active Agent work order.",
});
}
let resolvedCwd: string;
if (args.cwd !== undefined && typeof args.cwd === "string") {
const resolved = resolveProjectPath(this.toolbox, args.cwd);
if (typeof resolved === "string") return resolved;
resolvedCwd = resolved.absolutePath;
} else {
resolvedCwd = path.resolve(projectRoot);
}
const timeout = Math.min(
typeof args.timeout === "number" ? args.timeout : DEFAULT_TIMEOUT,
MAX_TIMEOUT,
);
try {
const { stdout, stderr } = await execAsync(command, {
cwd: resolvedCwd,
shell: "/bin/bash",
timeout,
maxBuffer: 1024 * 1024 * 10,
});
return this.formatOutput(command, stdout, stderr, 0);
} catch (error) {
if (error instanceof Error) {
const nodeError = error as NodeJS.ErrnoException & {
code?: string;
stdout?: string;
stderr?: string;
};
if (nodeError.code === "ETIMEDOUT" || nodeError.message.includes("timeout")) {
return toolError({
code: "TIMEOUT",
message: `Command timed out after ${timeout / 1000}s: ${command}`,
recoveryHint: `Increase the timeout parameter (max ${MAX_TIMEOUT / 1000}s) or break the command into smaller steps.`,
});
}
const stdout = nodeError.stdout ?? "";
const stderr = nodeError.stderr ?? nodeError.message;
const exitCode = typeof nodeError.code === "string"
? parseInt(nodeError.code.replace("ERR_", ""), 10)
: 1;
return this.formatOutput(
command,
stdout,
stderr,
isNaN(exitCode) ? 1 : exitCode,
);
}
logger.error("failed to execute command", { command, error: String(error) });
return toolError({
code: "OPERATION_FAILED",
message: `Failed to execute command: ${String(error)}`,
});
}
}
private formatOutput(
command: string,
stdout: string,
stderr: string,
exitCode: number,
): string {
return [
`COMMAND: ${command}`,
`EXIT CODE: ${exitCode}`,
"---[stdout]",
stdout || "N/A",
"---[stderr]",
stderr || "N/A",
].join("\n");
}
}