From a4cff5be69f2e99e429252a937411ec57d945455 Mon Sep 17 00:00:00 2001 From: Rob Colbert Date: Sun, 10 May 2026 07:35:09 -0400 Subject: [PATCH] agent toolbox refactor and updates - reorganized tools into better named directories - added the file & shell tools --- .gitignore | 3 + gadget-drone/src/services/agent.ts | 58 ++++- gadget-drone/src/tools/index.ts | 4 +- .../src/tools/{file => network}/fetch-url.ts | 4 +- gadget-drone/src/tools/network/index.ts | 5 + .../google.ts => network/search-google.ts} | 2 +- .../tools/{file => network}/web-fetcher.ts | 0 .../src/tools/{file => system}/common.ts | 0 .../src/tools/{file => system}/edit.ts | 2 +- gadget-drone/src/tools/system/glob.ts | 185 +++++++++++++++ gadget-drone/src/tools/system/grep.ts | 220 ++++++++++++++++++ .../src/tools/{file => system}/index.ts | 5 +- gadget-drone/src/tools/system/list.ts | 190 +++++++++++++++ .../src/tools/{file => system}/read.ts | 2 +- gadget-drone/src/tools/system/shell.ts | 147 ++++++++++++ .../src/tools/{file => system}/write.ts | 2 +- 16 files changed, 809 insertions(+), 20 deletions(-) rename gadget-drone/src/tools/{file => network}/fetch-url.ts (97%) create mode 100644 gadget-drone/src/tools/network/index.ts rename gadget-drone/src/tools/{search/google.ts => network/search-google.ts} (99%) rename gadget-drone/src/tools/{file => network}/web-fetcher.ts (100%) rename gadget-drone/src/tools/{file => system}/common.ts (100%) rename gadget-drone/src/tools/{file => system}/edit.ts (99%) create mode 100644 gadget-drone/src/tools/system/glob.ts create mode 100644 gadget-drone/src/tools/system/grep.ts rename gadget-drone/src/tools/{file => system}/index.ts (60%) create mode 100644 gadget-drone/src/tools/system/list.ts rename gadget-drone/src/tools/{file => system}/read.ts (99%) create mode 100644 gadget-drone/src/tools/system/shell.ts rename gadget-drone/src/tools/{file => system}/write.ts (99%) diff --git a/.gitignore b/.gitignore index 97ef1eb..f280f3c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ +logfetch +gadget*log + docs/archive node_modules diff --git a/gadget-drone/src/services/agent.ts b/gadget-drone/src/services/agent.ts index e45d620..6fa0b17 100644 --- a/gadget-drone/src/services/agent.ts +++ b/gadget-drone/src/services/agent.ts @@ -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( - turn.provider, - { - modelId: turn.llm, - params: { reasoning, temperature: 0.8, topP: 0.9, topK: 40 }, - }, - chatOptions, - this.makeStreamHandler(socket), - ); + 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: 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)"); diff --git a/gadget-drone/src/tools/index.ts b/gadget-drone/src/tools/index.ts index 5438577..da87800 100644 --- a/gadget-drone/src/tools/index.ts +++ b/gadget-drone/src/tools/index.ts @@ -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"; diff --git a/gadget-drone/src/tools/file/fetch-url.ts b/gadget-drone/src/tools/network/fetch-url.ts similarity index 97% rename from gadget-drone/src/tools/file/fetch-url.ts rename to gadget-drone/src/tools/network/fetch-url.ts index 71192db..749062d 100644 --- a/gadget-drone/src/tools/file/fetch-url.ts +++ b/gadget-drone/src/tools/network/fetch-url.ts @@ -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 = { diff --git a/gadget-drone/src/tools/network/index.ts b/gadget-drone/src/tools/network/index.ts new file mode 100644 index 0000000..d40fa31 --- /dev/null +++ b/gadget-drone/src/tools/network/index.ts @@ -0,0 +1,5 @@ +// Copyright (C) 2026 Rob Colbert +// Licensed under the Apache License, Version 2.0 + +export { GoogleSearchTool } from "./search-google.ts"; +export { FetchUrlTool } from "./fetch-url.ts"; diff --git a/gadget-drone/src/tools/search/google.ts b/gadget-drone/src/tools/network/search-google.ts similarity index 99% rename from gadget-drone/src/tools/search/google.ts rename to gadget-drone/src/tools/network/search-google.ts index 5ee7c93..bc54e78 100644 --- a/gadget-drone/src/tools/search/google.ts +++ b/gadget-drone/src/tools/network/search-google.ts @@ -32,7 +32,7 @@ export class GoogleSearchTool extends DroneTool { } get category(): string { - return "search"; + return "network"; } public definition: IToolDefinition = { diff --git a/gadget-drone/src/tools/file/web-fetcher.ts b/gadget-drone/src/tools/network/web-fetcher.ts similarity index 100% rename from gadget-drone/src/tools/file/web-fetcher.ts rename to gadget-drone/src/tools/network/web-fetcher.ts diff --git a/gadget-drone/src/tools/file/common.ts b/gadget-drone/src/tools/system/common.ts similarity index 100% rename from gadget-drone/src/tools/file/common.ts rename to gadget-drone/src/tools/system/common.ts diff --git a/gadget-drone/src/tools/file/edit.ts b/gadget-drone/src/tools/system/edit.ts similarity index 99% rename from gadget-drone/src/tools/file/edit.ts rename to gadget-drone/src/tools/system/edit.ts index 2479ce1..7446a9d 100644 --- a/gadget-drone/src/tools/file/edit.ts +++ b/gadget-drone/src/tools/system/edit.ts @@ -13,7 +13,7 @@ export class FileEditTool extends DroneTool { } get category(): string { - return "file"; + return "system"; } public definition: IToolDefinition = { diff --git a/gadget-drone/src/tools/system/glob.ts b/gadget-drone/src/tools/system/glob.ts new file mode 100644 index 0000000..5fc2c52 --- /dev/null +++ b/gadget-drone/src/tools/system/glob.ts @@ -0,0 +1,185 @@ +// Copyright (C) 2026 Rob Colbert +// 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 { + 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 { + 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 { + 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 + "/"); + } + } + } + } +} diff --git a/gadget-drone/src/tools/system/grep.ts b/gadget-drone/src/tools/system/grep.ts new file mode 100644 index 0000000..d71daeb --- /dev/null +++ b/gadget-drone/src/tools/system/grep.ts @@ -0,0 +1,220 @@ +// Copyright (C) 2026 Rob Colbert +// 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 { + 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> { + 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 { + 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; + } + } +} diff --git a/gadget-drone/src/tools/file/index.ts b/gadget-drone/src/tools/system/index.ts similarity index 60% rename from gadget-drone/src/tools/file/index.ts rename to gadget-drone/src/tools/system/index.ts index 2d7c618..57f450d 100644 --- a/gadget-drone/src/tools/file/index.ts +++ b/gadget-drone/src/tools/system/index.ts @@ -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"; diff --git a/gadget-drone/src/tools/system/list.ts b/gadget-drone/src/tools/system/list.ts new file mode 100644 index 0000000..c09e940 --- /dev/null +++ b/gadget-drone/src/tools/system/list.ts @@ -0,0 +1,190 @@ +// Copyright (C) 2026 Rob Colbert +// 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 { + 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> { + 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; + } +} diff --git a/gadget-drone/src/tools/file/read.ts b/gadget-drone/src/tools/system/read.ts similarity index 99% rename from gadget-drone/src/tools/file/read.ts rename to gadget-drone/src/tools/system/read.ts index b2a7f6a..b51ef00 100644 --- a/gadget-drone/src/tools/file/read.ts +++ b/gadget-drone/src/tools/system/read.ts @@ -19,7 +19,7 @@ export class FileReadTool extends DroneTool { } get category(): string { - return "file"; + return "system"; } public definition: IToolDefinition = { diff --git a/gadget-drone/src/tools/system/shell.ts b/gadget-drone/src/tools/system/shell.ts new file mode 100644 index 0000000..20f6fae --- /dev/null +++ b/gadget-drone/src/tools/system/shell.ts @@ -0,0 +1,147 @@ +// Copyright (C) 2026 Rob Colbert +// 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 { + 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"); + } +} diff --git a/gadget-drone/src/tools/file/write.ts b/gadget-drone/src/tools/system/write.ts similarity index 99% rename from gadget-drone/src/tools/file/write.ts rename to gadget-drone/src/tools/system/write.ts index e295f9d..55c77f6 100644 --- a/gadget-drone/src/tools/file/write.ts +++ b/gadget-drone/src/tools/system/write.ts @@ -14,7 +14,7 @@ export class FileWriteTool extends DroneTool { } get category(): string { - return "file"; + return "system"; } public definition: IToolDefinition = {