agent toolbox refactor and updates

- reorganized tools into better named directories
- added the file & shell tools
This commit is contained in:
Rob Colbert 2026-05-10 07:35:09 -04:00
parent 73c5345879
commit a4cff5be69
16 changed files with 809 additions and 20 deletions

3
.gitignore vendored
View File

@ -1,2 +1,5 @@
logfetch
gadget*log
docs/archive
node_modules

View File

@ -7,6 +7,7 @@ import env from "../config/env.ts";
import { Socket } from "socket.io-client";
import {
IAiChatOptions,
IAiChatResponse,
IAiResponseStreamFn,
type IContextChatMessage,
} from "@gadget/ai";
@ -26,11 +27,15 @@ import WorkspaceService from "./workspace.ts";
import { GadgetService } from "../lib/service.ts";
import {
AiToolbox,
FetchUrlTool,
FileEditTool,
FileReadTool,
FileWriteTool,
FetchUrlTool,
GlobTool,
GoogleSearchTool,
GrepTool,
ListTool,
ShellExecTool,
type DroneToolboxEnvironment,
} from "../tools/index.ts";
@ -79,11 +84,20 @@ class AgentService extends GadgetService {
ChatSessionMode.Develop,
];
// Network tools — available in all modes for research
this.toolbox.register(new GoogleSearchTool(this.toolbox), readOnlyModes);
this.toolbox.register(new FileReadTool(this.toolbox), readOnlyModes);
this.toolbox.register(new FetchUrlTool(this.toolbox), readOnlyModes);
// System tools — read-only: available in all modes for exploration
this.toolbox.register(new FileReadTool(this.toolbox), readOnlyModes);
this.toolbox.register(new ListTool(this.toolbox), readOnlyModes);
this.toolbox.register(new GrepTool(this.toolbox), readOnlyModes);
this.toolbox.register(new GlobTool(this.toolbox), readOnlyModes);
// System tools — write: restricted to build/test/ship/develop
this.toolbox.register(new FileWriteTool(this.toolbox), writeModes);
this.toolbox.register(new FileEditTool(this.toolbox), writeModes);
this.toolbox.register(new ShellExecTool(this.toolbox), writeModes);
this.log.info("started");
}
@ -158,15 +172,37 @@ class AgentService extends GadgetService {
tools: this.getToolsForMode(turn.mode),
};
const response = await AiService.chat(
let response: IAiChatResponse;
let currentReasoning: boolean | "low" | "medium" | "high" = reasoning;
for (let attempt = 0; ; attempt++) {
try {
response = await AiService.chat(
turn.provider,
{
modelId: turn.llm,
params: { reasoning, temperature: 0.8, topP: 0.9, topK: 40 },
params: { reasoning: currentReasoning, temperature: 0.8, topP: 0.9, topK: 40 },
},
chatOptions,
this.makeStreamHandler(socket),
);
break;
} catch (error) {
if (
attempt === 0 &&
currentReasoning !== false &&
error instanceof Error &&
error.message.includes("empty chat response")
) {
this.log.warn("model returned empty response with reasoning; retrying without reasoning", {
originalError: error.message,
});
currentReasoning = false;
continue;
}
throw error;
}
}
if (response.doneReason === "load" && !response.response && !response.thinking) {
throw new Error("Model failed to respond (still loading or error)");

View File

@ -3,5 +3,5 @@
export { AiToolbox, type DroneToolboxEnvironment } from "./toolbox.ts";
export { DroneTool } from "./tool.ts";
export { GoogleSearchTool } from "./search/google.ts";
export * from "./file/index.ts";
export * from "./system/index.ts";
export * from "./network/index.ts";

View File

@ -3,7 +3,7 @@
import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai";
import { DroneTool } from "../tool.ts";
import { asPositiveInteger, getCacheDir, toolError } from "./common.ts";
import { asPositiveInteger, getCacheDir, toolError } from "../system/common.ts";
import { WebFetcher } from "./web-fetcher.ts";
export class FetchUrlTool extends DroneTool {
@ -12,7 +12,7 @@ export class FetchUrlTool extends DroneTool {
}
get category(): string {
return "file";
return "network";
}
public definition: IToolDefinition = {

View File

@ -0,0 +1,5 @@
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
// Licensed under the Apache License, Version 2.0
export { GoogleSearchTool } from "./search-google.ts";
export { FetchUrlTool } from "./fetch-url.ts";

View File

@ -32,7 +32,7 @@ export class GoogleSearchTool extends DroneTool {
}
get category(): string {
return "search";
return "network";
}
public definition: IToolDefinition = {

View File

@ -13,7 +13,7 @@ export class FileEditTool extends DroneTool {
}
get category(): string {
return "file";
return "system";
}
public definition: IToolDefinition = {

View File

@ -0,0 +1,185 @@
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
// Licensed under the Apache License, Version 2.0
import fs from "node:fs/promises";
import path from "node:path";
import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai";
import { DroneTool } from "../tool.ts";
import { getProjectRoot, resolveProjectPath, toolError } from "./common.ts";
const MAX_RESULTS = 1000;
export class GlobTool extends DroneTool {
get name(): string {
return "glob";
}
get category(): string {
return "system";
}
public definition: IToolDefinition = {
type: "function",
function: {
name: this.name,
description: "Find project files by name using glob pattern matching. Supports patterns like **/*.ts, *.js, src/**/*.tsx.",
parameters: {
type: "object",
properties: {
pattern: {
type: "string",
description: "Glob pattern to match files (e.g., '**/*.ts', 'src/**/*.tsx', '*.json').",
},
root: {
type: "string",
description: "Root directory to search from, relative to the project root. Defaults to project root.",
},
},
required: ["pattern"],
},
},
};
public async execute(args: IToolArguments, logger: IAiLogger): Promise<string> {
const pattern = typeof args.pattern === "string" ? args.pattern : undefined;
if (!pattern || pattern.trim().length === 0) {
return toolError({
code: "MISSING_PARAMETER",
message: "pattern is required.",
parameter: "pattern",
recoveryHint: "Provide a glob pattern like '**/*.ts' or 'src/**/*.tsx'.",
});
}
const projectRoot = getProjectRoot(this.toolbox);
if (!projectRoot) {
return toolError({
code: "OPERATION_NOT_ALLOWED",
message: "No active project workspace is configured for file tools.",
recoveryHint: "Run this tool during an active Agent work order.",
});
}
let root: string;
if (args.root !== undefined && typeof args.root === "string") {
const resolved = resolveProjectPath(this.toolbox, args.root);
if (typeof resolved === "string") return resolved;
root = resolved.absolutePath;
} else {
root = path.resolve(projectRoot);
}
try {
const matches = await this.globMatch(pattern, root, projectRoot);
const limited = matches.slice(0, MAX_RESULTS);
if (matches.length > MAX_RESULTS) {
return [
`Found ${matches.length} files matching "${pattern}" (showing first ${MAX_RESULTS}):`,
"",
...limited,
"",
`... and ${matches.length - MAX_RESULTS} more files`,
].join("\n");
}
return [
`Found ${matches.length} file(s) matching "${pattern}":`,
"",
...matches,
].join("\n");
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error("failed to glob", { pattern, root, error: errorMessage });
return toolError({
code: "OPERATION_FAILED",
message: `Failed to search: ${errorMessage}`,
});
}
}
private async globMatch(pattern: string, root: string, projectRoot: string): Promise<string[]> {
const results: string[] = [];
const parts = pattern.split("/");
const globStarIndex = parts.indexOf("**");
let isRecursive: boolean;
let searchPattern: string;
let resolvedRoot: string;
if (globStarIndex !== -1) {
isRecursive = true;
const beforeParts = parts.slice(0, globStarIndex);
const afterParts = parts.slice(globStarIndex + 1);
if (beforeParts.length > 0) {
const prefixPath = beforeParts.join("/");
resolvedRoot = path.resolve(root, prefixPath);
const rel = path.relative(projectRoot, resolvedRoot);
if (rel.startsWith("..") || path.isAbsolute(rel)) {
return results;
}
} else {
resolvedRoot = root;
}
searchPattern = afterParts.length > 0 ? afterParts.join("/") : "*";
} else {
isRecursive = false;
searchPattern = pattern;
resolvedRoot = root;
}
const regexPattern = this.globToRegex(searchPattern);
await this.recurseDir(resolvedRoot, regexPattern, isRecursive, results, 0, 20, projectRoot);
return results.sort();
}
private globToRegex(pattern: string): RegExp {
let regexStr = pattern
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
.replace(/\*/g, ".*")
.replace(/\?/g, ".");
return new RegExp("^" + regexStr + "$");
}
private async recurseDir(
dir: string,
pattern: RegExp,
recursive: boolean,
results: string[],
depth: number,
maxDepth: number,
projectRoot: string,
): Promise<void> {
if (depth > maxDepth) return;
let entries;
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
const relativePath = path.relative(projectRoot, fullPath);
if (entry.isFile()) {
if (pattern.test(entry.name)) {
results.push(relativePath);
}
} else if (entry.isDirectory()) {
if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "dist" || entry.name === "build") {
continue;
}
if (recursive) {
await this.recurseDir(fullPath, pattern, recursive, results, depth + 1, maxDepth, projectRoot);
} else if (pattern.test(entry.name)) {
results.push(relativePath + "/");
}
}
}
}
}

View File

@ -0,0 +1,220 @@
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
// Licensed under the Apache License, Version 2.0
import fs from "node:fs/promises";
import path from "node:path";
import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai";
import { DroneTool } from "../tool.ts";
import { getProjectRoot, resolveProjectPath, toolError } from "./common.ts";
const MAX_MATCHES = 500;
const MAX_FILE_SIZE = 1024 * 1024;
// TODO: Consider broadening this list or making it user-configurable
// Currently limited to common source/doc extensions for performance.
const SEARCH_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".json", ".md", ".txt"]);
export class GrepTool extends DroneTool {
get name(): string {
return "grep";
}
get category(): string {
return "system";
}
public definition: IToolDefinition = {
type: "function",
function: {
name: this.name,
description: "Search project file contents using regular expressions. Returns matching lines with file paths and line numbers. Supports case-insensitive matching and context lines.",
parameters: {
type: "object",
properties: {
pattern: {
type: "string",
description: "Regular expression pattern to search for. Use standard JavaScript regex syntax.",
},
path: {
type: "string",
description: "File or directory path to search in, relative to the project root.",
},
caseInsensitive: {
type: "boolean",
description: "Case insensitive search (default: false).",
},
contextBefore: {
type: "number",
description: "Number of lines to show before each match (default: 0).",
},
contextAfter: {
type: "number",
description: "Number of lines to show after each match (default: 0).",
},
},
required: ["pattern", "path"],
},
},
};
public async execute(args: IToolArguments, logger: IAiLogger): Promise<string> {
const pattern = typeof args.pattern === "string" ? args.pattern : undefined;
const searchPath = typeof args.path === "string" ? args.path : undefined;
const caseInsensitive = args.caseInsensitive === true;
const contextBefore = typeof args.contextBefore === "number" ? args.contextBefore : 0;
const contextAfter = typeof args.contextAfter === "number" ? args.contextAfter : 0;
if (!pattern || pattern.trim().length === 0) {
return toolError({
code: "MISSING_PARAMETER",
message: "pattern is required.",
parameter: "pattern",
recoveryHint: "Provide a regex pattern to search for.",
});
}
if (!searchPath || searchPath.trim().length === 0) {
return toolError({
code: "MISSING_PARAMETER",
message: "path is required.",
parameter: "path",
recoveryHint: "Provide a file or directory path to search in.",
});
}
const resolved = resolveProjectPath(this.toolbox, searchPath);
if (typeof resolved === "string") return resolved;
let regex: RegExp;
try {
regex = new RegExp(pattern, caseInsensitive ? "i" : "");
} catch (error) {
return toolError({
code: "INVALID_PARAMETER",
message: `Invalid regex pattern: ${error instanceof Error ? error.message : String(error)}`,
parameter: "pattern",
});
}
const projectRoot = getProjectRoot(this.toolbox);
try {
const stat = await fs.stat(resolved.absolutePath);
const matches: Array<{ file: string; line: number; content: string }> = [];
if (stat.isFile()) {
const fileMatches = await this.searchFile(resolved.absolutePath, regex, contextBefore, contextAfter);
matches.push(...fileMatches);
} else if (stat.isDirectory()) {
await this.searchDirectory(resolved.absolutePath, regex, matches, contextBefore, contextAfter);
}
const limited = matches.slice(0, MAX_MATCHES);
if (matches.length === 0) {
return `No matches found for "${pattern}" in ${resolved.displayPath}`;
}
const truncated = matches.length > MAX_MATCHES;
const lines: string[] = [
`Found ${matches.length} match(es) for "${pattern}" in ${resolved.displayPath}${truncated ? ` (showing first ${MAX_MATCHES})` : ""}:`,
"",
];
for (const match of limited) {
const displayFile = projectRoot ? path.relative(projectRoot, match.file) : match.file;
lines.push(`${displayFile}:${match.line}: ${match.content}`);
}
if (truncated) {
lines.push("", `... and ${matches.length - MAX_MATCHES} more matches`);
}
return lines.join("\n");
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error("failed to search", { path: resolved.displayPath, pattern, error: errorMessage });
return toolError({
code: "OPERATION_FAILED",
message: `Failed to search: ${errorMessage}`,
});
}
}
private async searchFile(
filePath: string,
regex: RegExp,
contextBefore: number,
contextAfter: number,
): Promise<Array<{ file: string; line: number; content: string }>> {
const matches: Array<{ file: string; line: number; content: string }> = [];
try {
const stat = await fs.stat(filePath);
if (stat.size > MAX_FILE_SIZE) return matches;
} catch {
return matches;
}
let content: string;
try {
content = await fs.readFile(filePath, "utf-8");
} catch {
return matches;
}
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line !== undefined && regex.test(line)) {
const startLine = Math.max(0, i - contextBefore);
const endLine = Math.min(lines.length - 1, i + contextAfter);
for (let j = startLine; j <= endLine; j++) {
const contextLine = lines[j];
if (contextLine !== undefined) {
matches.push({ file: filePath, line: j + 1, content: contextLine });
}
}
regex.lastIndex = 0;
}
}
return matches;
}
private async searchDirectory(
dir: string,
regex: RegExp,
matches: Array<{ file: string; line: number; content: string }>,
contextBefore: number,
contextAfter: number,
): Promise<void> {
let entries;
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name !== "node_modules" && entry.name !== ".git" && entry.name !== "dist" && entry.name !== "build") {
await this.searchDirectory(fullPath, regex, matches, contextBefore, contextAfter);
}
} else if (entry.isFile()) {
const ext = path.extname(entry.name);
if (SEARCH_EXTENSIONS.has(ext)) {
const fileMatches = await this.searchFile(fullPath, regex, contextBefore, contextAfter);
matches.push(...fileMatches);
}
}
if (matches.length >= MAX_MATCHES * 2) return;
}
}
}

View File

@ -4,4 +4,7 @@
export { FileReadTool } from "./read.ts";
export { FileWriteTool } from "./write.ts";
export { FileEditTool } from "./edit.ts";
export { FetchUrlTool } from "./fetch-url.ts";
export { ShellExecTool } from "./shell.ts";
export { ListTool } from "./list.ts";
export { GrepTool } from "./grep.ts";
export { GlobTool } from "./glob.ts";

View File

@ -0,0 +1,190 @@
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
// Licensed under the Apache License, Version 2.0
import fs from "node:fs/promises";
import path from "node:path";
import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai";
import { DroneTool } from "../tool.ts";
import { getProjectRoot, resolveProjectPath, toolError } from "./common.ts";
const MAX_ENTRIES = 1000;
export class ListTool extends DroneTool {
get name(): string {
return "list";
}
get category(): string {
return "system";
}
public definition: IToolDefinition = {
type: "function",
function: {
name: this.name,
description: "List project directory contents with optional filtering. Shows file types, sizes, and modification dates. Supports filtering by glob pattern.",
parameters: {
type: "object",
properties: {
path: {
type: "string",
description: "Directory path to list, relative to the project root. Defaults to project root.",
},
pattern: {
type: "string",
description: "Optional glob pattern to filter results (e.g., '*.ts', 'src/**/*').",
},
recursive: {
type: "boolean",
description: "List subdirectories recursively (default: false).",
},
showHidden: {
type: "boolean",
description: "Show hidden files (files starting with dot) (default: false).",
},
maxDepth: {
type: "number",
description: "Maximum directory depth for recursive listing (default: 3).",
},
},
required: [],
},
},
};
public async execute(args: IToolArguments, logger: IAiLogger): Promise<string> {
const projectRoot = getProjectRoot(this.toolbox);
if (!projectRoot) {
return toolError({
code: "OPERATION_NOT_ALLOWED",
message: "No active project workspace is configured for file tools.",
recoveryHint: "Run this tool during an active Agent work order.",
});
}
let targetPath: string;
if (args.path !== undefined && typeof args.path === "string") {
const resolved = resolveProjectPath(this.toolbox, args.path);
if (typeof resolved === "string") return resolved;
targetPath = resolved.absolutePath;
} else {
targetPath = path.resolve(projectRoot);
}
const patternStr = typeof args.pattern === "string" ? args.pattern : undefined;
let pattern: RegExp | undefined;
if (patternStr) {
try {
const globPattern = patternStr
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
.replace(/\*/g, ".*")
.replace(/\?/g, ".");
pattern = new RegExp("^" + globPattern + "$");
} catch {
// invalid glob pattern — skip filtering
}
}
const recursive = args.recursive === true;
const showHidden = args.showHidden === true;
const maxDepth = typeof args.maxDepth === "number" ? args.maxDepth : 3;
const displayPath = path.relative(projectRoot, targetPath) || ".";
try {
const stat = await fs.stat(targetPath);
if (!stat.isDirectory()) {
return toolError({
code: "INVALID_PARAMETER",
message: `"${displayPath}" is not a directory.`,
parameter: "path",
recoveryHint: "Provide a directory path to list.",
});
}
const entries = await this.listDirectory(targetPath, pattern, recursive, showHidden, 0, maxDepth, projectRoot);
const limited = entries.slice(0, MAX_ENTRIES);
if (entries.length === 0) {
return `No entries found in "${displayPath}"`;
}
const truncated = entries.length > MAX_ENTRIES;
const lines: string[] = [
`Contents of "${displayPath}" (${truncated ? `showing first ${MAX_ENTRIES} of ` : ""}${entries.length} entries):`,
"",
];
for (const entry of limited) {
const typeIndicator = entry.isDirectory ? "d" : entry.isSymlink ? "l" : "-";
const size = entry.isDirectory ? "-" : entry.size.toString();
const modified = entry.modified ? new Date(entry.modified).toISOString().split("T")[0] : "-";
lines.push(`${typeIndicator} ${size.padStart(10)} ${modified} ${entry.name}`);
}
if (truncated) {
lines.push("", `... and ${entries.length - MAX_ENTRIES} more entries`);
}
return lines.join("\n");
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error("failed to list directory", { path: displayPath, error: errorMessage });
return toolError({
code: "OPERATION_FAILED",
message: `Failed to list directory: ${errorMessage}`,
});
}
}
private async listDirectory(
dir: string,
pattern: RegExp | undefined,
recursive: boolean,
showHidden: boolean,
depth: number,
maxDepth: number,
projectRoot: string,
): Promise<Array<{ name: string; isDirectory: boolean; isSymlink: boolean; size: number; modified: Date | null }>> {
const results: Array<{ name: string; isDirectory: boolean; isSymlink: boolean; size: number; modified: Date | null }> = [];
let entries;
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
return results;
}
for (const entry of entries) {
if (!showHidden && entry.name.startsWith(".")) continue;
if (entry.name === "node_modules" || entry.name === ".git") continue;
const fullPath = path.join(dir, entry.name);
const relativePath = path.relative(projectRoot, fullPath);
if (pattern && !pattern.test(relativePath) && !pattern.test(entry.name)) continue;
let stat;
try {
stat = await fs.stat(fullPath);
} catch {
continue;
}
results.push({
name: relativePath,
isDirectory: entry.isDirectory(),
isSymlink: entry.isSymbolicLink(),
size: stat.size,
modified: stat.mtime,
});
if (recursive && entry.isDirectory() && depth < maxDepth) {
const subResults = await this.listDirectory(fullPath, pattern, recursive, showHidden, depth + 1, maxDepth, projectRoot);
results.push(...subResults);
}
}
return results;
}
}

View File

@ -19,7 +19,7 @@ export class FileReadTool extends DroneTool {
}
get category(): string {
return "file";
return "system";
}
public definition: IToolDefinition = {

View File

@ -0,0 +1,147 @@
// 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");
}
}

View File

@ -14,7 +14,7 @@ export class FileWriteTool extends DroneTool {
}
get category(): string {
return "file";
return "system";
}
public definition: IToolDefinition = {