agent toolbox refactor and updates
- reorganized tools into better named directories - added the file & shell tools
This commit is contained in:
parent
73c5345879
commit
a4cff5be69
3
.gitignore
vendored
3
.gitignore
vendored
@ -1,2 +1,5 @@
|
||||
logfetch
|
||||
gadget*log
|
||||
|
||||
docs/archive
|
||||
node_modules
|
||||
|
||||
@ -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)");
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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 = {
|
||||
5
gadget-drone/src/tools/network/index.ts
Normal file
5
gadget-drone/src/tools/network/index.ts
Normal 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";
|
||||
@ -32,7 +32,7 @@ export class GoogleSearchTool extends DroneTool {
|
||||
}
|
||||
|
||||
get category(): string {
|
||||
return "search";
|
||||
return "network";
|
||||
}
|
||||
|
||||
public definition: IToolDefinition = {
|
||||
@ -13,7 +13,7 @@ export class FileEditTool extends DroneTool {
|
||||
}
|
||||
|
||||
get category(): string {
|
||||
return "file";
|
||||
return "system";
|
||||
}
|
||||
|
||||
public definition: IToolDefinition = {
|
||||
185
gadget-drone/src/tools/system/glob.ts
Normal file
185
gadget-drone/src/tools/system/glob.ts
Normal 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 + "/");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
220
gadget-drone/src/tools/system/grep.ts
Normal file
220
gadget-drone/src/tools/system/grep.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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";
|
||||
190
gadget-drone/src/tools/system/list.ts
Normal file
190
gadget-drone/src/tools/system/list.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -19,7 +19,7 @@ export class FileReadTool extends DroneTool {
|
||||
}
|
||||
|
||||
get category(): string {
|
||||
return "file";
|
||||
return "system";
|
||||
}
|
||||
|
||||
public definition: IToolDefinition = {
|
||||
147
gadget-drone/src/tools/system/shell.ts
Normal file
147
gadget-drone/src/tools/system/shell.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
@ -14,7 +14,7 @@ export class FileWriteTool extends DroneTool {
|
||||
}
|
||||
|
||||
get category(): string {
|
||||
return "file";
|
||||
return "system";
|
||||
}
|
||||
|
||||
public definition: IToolDefinition = {
|
||||
Loading…
Reference in New Issue
Block a user