diff options
Diffstat (limited to 'packages/core/src/tools/shell.ts')
| -rw-r--r-- | packages/core/src/tools/shell.ts | 255 |
1 files changed, 129 insertions, 126 deletions
diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index 4fa08297..0cc727fb 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -10,14 +10,15 @@ import os from 'os'; import crypto from 'crypto'; import { Config } from '../config/config.js'; import { - BaseTool, + BaseDeclarativeTool, + BaseToolInvocation, + ToolInvocation, ToolResult, ToolCallConfirmationDetails, ToolExecuteConfirmationDetails, ToolConfirmationOutcome, Kind, } from './tools.js'; -import { ToolErrorType } from './tool-error.js'; import { SchemaValidator } from '../utils/schemaValidator.js'; import { getErrorMessage } from '../utils/errors.js'; import { summarizeToolOutput } from '../utils/summarizer.js'; @@ -40,120 +41,36 @@ export interface ShellToolParams { directory?: string; } -export class ShellTool extends BaseTool<ShellToolParams, ToolResult> { - static Name: string = 'run_shell_command'; - private allowlist: Set<string> = new Set(); - - constructor(private readonly config: Config) { - super( - ShellTool.Name, - 'Shell', - `This tool executes a given shell command as \`bash -c <command>\`. Command can start background processes using \`&\`. Command is executed as a subprocess that leads its own process group. Command process group can be terminated as \`kill -- -PGID\` or signaled as \`kill -s SIGNAL -- -PGID\`. - - The following information is returned: - - Command: Executed command. - Directory: Directory (relative to project root) where command was executed, or \`(root)\`. - Stdout: Output on stdout stream. Can be \`(empty)\` or partial on error and for any unwaited background processes. - Stderr: Output on stderr stream. Can be \`(empty)\` or partial on error and for any unwaited background processes. - Error: Error or \`(none)\` if no error was reported for the subprocess. - Exit Code: Exit code or \`(none)\` if terminated by signal. - Signal: Signal number or \`(none)\` if no signal was received. - Background PIDs: List of background processes started or \`(none)\`. - Process Group PGID: Process group started or \`(none)\``, - Kind.Execute, - { - type: 'object', - properties: { - command: { - type: 'string', - description: 'Exact bash command to execute as `bash -c <command>`', - }, - description: { - type: 'string', - description: - 'Brief description of the command for the user. Be specific and concise. Ideally a single sentence. Can be up to 3 sentences for clarity. No line breaks.', - }, - directory: { - type: 'string', - description: - '(OPTIONAL) Directory to run the command in, if not the project root directory. Must be relative to the project root directory and must already exist.', - }, - }, - required: ['command'], - }, - false, // output is not markdown - true, // output can be updated - ); +class ShellToolInvocation extends BaseToolInvocation< + ShellToolParams, + ToolResult +> { + constructor( + private readonly config: Config, + params: ShellToolParams, + private readonly allowlist: Set<string>, + ) { + super(params); } - getDescription(params: ShellToolParams): string { - let description = `${params.command}`; + getDescription(): string { + let description = `${this.params.command}`; // append optional [in directory] // note description is needed even if validation fails due to absolute path - if (params.directory) { - description += ` [in ${params.directory}]`; + if (this.params.directory) { + description += ` [in ${this.params.directory}]`; } // append optional (description), replacing any line breaks with spaces - if (params.description) { - description += ` (${params.description.replace(/\n/g, ' ')})`; + if (this.params.description) { + description += ` (${this.params.description.replace(/\n/g, ' ')})`; } return description; } - validateToolParams(params: ShellToolParams): string | null { - const commandCheck = isCommandAllowed(params.command, this.config); - if (!commandCheck.allowed) { - if (!commandCheck.reason) { - console.error( - 'Unexpected: isCommandAllowed returned false without a reason', - ); - return `Command is not allowed: ${params.command}`; - } - return commandCheck.reason; - } - const errors = SchemaValidator.validate( - this.schema.parametersJsonSchema, - params, - ); - if (errors) { - return errors; - } - if (!params.command.trim()) { - return 'Command cannot be empty.'; - } - if (getCommandRoots(params.command).length === 0) { - return 'Could not identify command root to obtain permission from user.'; - } - if (params.directory) { - if (path.isAbsolute(params.directory)) { - return 'Directory cannot be absolute. Please refer to workspace directories by their name.'; - } - const workspaceDirs = this.config.getWorkspaceContext().getDirectories(); - const matchingDirs = workspaceDirs.filter( - (dir) => path.basename(dir) === params.directory, - ); - - if (matchingDirs.length === 0) { - return `Directory '${params.directory}' is not a registered workspace directory.`; - } - - if (matchingDirs.length > 1) { - return `Directory name '${params.directory}' is ambiguous as it matches multiple workspace directories.`; - } - } - return null; - } - async shouldConfirmExecute( - params: ShellToolParams, _abortSignal: AbortSignal, ): Promise<ToolCallConfirmationDetails | false> { - if (this.validateToolParams(params)) { - return false; // skip confirmation, execute call will fail immediately - } - - const command = stripShellWrapper(params.command); + const command = stripShellWrapper(this.params.command); const rootCommands = [...new Set(getCommandRoots(command))]; const commandsToConfirm = rootCommands.filter( (command) => !this.allowlist.has(command), @@ -166,7 +83,7 @@ export class ShellTool extends BaseTool<ShellToolParams, ToolResult> { const confirmationDetails: ToolExecuteConfirmationDetails = { type: 'exec', title: 'Confirm Shell Command', - command: params.command, + command: this.params.command, rootCommand: commandsToConfirm.join(', '), onConfirm: async (outcome: ToolConfirmationOutcome) => { if (outcome === ToolConfirmationOutcome.ProceedAlways) { @@ -178,25 +95,10 @@ export class ShellTool extends BaseTool<ShellToolParams, ToolResult> { } async execute( - params: ShellToolParams, signal: AbortSignal, updateOutput?: (output: string) => void, ): Promise<ToolResult> { - const strippedCommand = stripShellWrapper(params.command); - const validationError = this.validateToolParams({ - ...params, - command: strippedCommand, - }); - if (validationError) { - return { - llmContent: `Could not execute command due to invalid parameters: ${validationError}`, - returnDisplay: validationError, - error: { - message: validationError, - type: ToolErrorType.INVALID_TOOL_PARAMS, - }, - }; - } + const strippedCommand = stripShellWrapper(this.params.command); if (signal.aborted) { return { @@ -224,7 +126,7 @@ export class ShellTool extends BaseTool<ShellToolParams, ToolResult> { const cwd = path.resolve( this.config.getTargetDir(), - params.directory || '', + this.params.directory || '', ); let cumulativeStdout = ''; @@ -324,12 +226,12 @@ export class ShellTool extends BaseTool<ShellToolParams, ToolResult> { // Create a formatted error string for display, replacing the wrapper command // with the user-facing command. const finalError = result.error - ? result.error.message.replace(commandToExecute, params.command) + ? result.error.message.replace(commandToExecute, this.params.command) : '(none)'; llmContent = [ - `Command: ${params.command}`, - `Directory: ${params.directory || '(root)'}`, + `Command: ${this.params.command}`, + `Directory: ${this.params.directory || '(root)'}`, `Stdout: ${result.stdout || '(empty)'}`, `Stderr: ${result.stderr || '(empty)'}`, `Error: ${finalError}`, // Use the cleaned error string. @@ -366,12 +268,12 @@ export class ShellTool extends BaseTool<ShellToolParams, ToolResult> { } const summarizeConfig = this.config.getSummarizeToolOutputConfig(); - if (summarizeConfig && summarizeConfig[this.name]) { + if (summarizeConfig && summarizeConfig[ShellTool.Name]) { const summary = await summarizeToolOutput( llmContent, this.config.getGeminiClient(), signal, - summarizeConfig[this.name].tokenBudget, + summarizeConfig[ShellTool.Name].tokenBudget, ); return { llmContent: summary, @@ -390,3 +292,104 @@ export class ShellTool extends BaseTool<ShellToolParams, ToolResult> { } } } + +export class ShellTool extends BaseDeclarativeTool< + ShellToolParams, + ToolResult +> { + static Name: string = 'run_shell_command'; + private allowlist: Set<string> = new Set(); + + constructor(private readonly config: Config) { + super( + ShellTool.Name, + 'Shell', + `This tool executes a given shell command as \`bash -c <command>\`. Command can start background processes using \`&\`. Command is executed as a subprocess that leads its own process group. Command process group can be terminated as \`kill -- -PGID\` or signaled as \`kill -s SIGNAL -- -PGID\`. + + The following information is returned: + + Command: Executed command. + Directory: Directory (relative to project root) where command was executed, or \`(root)\`. + Stdout: Output on stdout stream. Can be \`(empty)\` or partial on error and for any unwaited background processes. + Stderr: Output on stderr stream. Can be \`(empty)\` or partial on error and for any unwaited background processes. + Error: Error or \`(none)\` if no error was reported for the subprocess. + Exit Code: Exit code or \`(none)\` if terminated by signal. + Signal: Signal number or \`(none)\` if no signal was received. + Background PIDs: List of background processes started or \`(none)\`. + Process Group PGID: Process group started or \`(none)\``, + Kind.Execute, + { + type: 'object', + properties: { + command: { + type: 'string', + description: 'Exact bash command to execute as `bash -c <command>`', + }, + description: { + type: 'string', + description: + 'Brief description of the command for the user. Be specific and concise. Ideally a single sentence. Can be up to 3 sentences for clarity. No line breaks.', + }, + directory: { + type: 'string', + description: + '(OPTIONAL) Directory to run the command in, if not the project root directory. Must be relative to the project root directory and must already exist.', + }, + }, + required: ['command'], + }, + false, // output is not markdown + true, // output can be updated + ); + } + + protected validateToolParams(params: ShellToolParams): string | null { + const commandCheck = isCommandAllowed(params.command, this.config); + if (!commandCheck.allowed) { + if (!commandCheck.reason) { + console.error( + 'Unexpected: isCommandAllowed returned false without a reason', + ); + return `Command is not allowed: ${params.command}`; + } + return commandCheck.reason; + } + const errors = SchemaValidator.validate( + this.schema.parametersJsonSchema, + params, + ); + if (errors) { + return errors; + } + if (!params.command.trim()) { + return 'Command cannot be empty.'; + } + if (getCommandRoots(params.command).length === 0) { + return 'Could not identify command root to obtain permission from user.'; + } + if (params.directory) { + if (path.isAbsolute(params.directory)) { + return 'Directory cannot be absolute. Please refer to workspace directories by their name.'; + } + const workspaceDirs = this.config.getWorkspaceContext().getDirectories(); + const matchingDirs = workspaceDirs.filter( + (dir) => path.basename(dir) === params.directory, + ); + + if (matchingDirs.length === 0) { + return `Directory '${params.directory}' is not a registered workspace directory.`; + } + + if (matchingDirs.length > 1) { + return `Directory name '${params.directory}' is ambiguous as it matches multiple workspace directories.`; + } + } + return null; + } + + protected createInvocation( + params: ShellToolParams, + ): ToolInvocation<ShellToolParams, ToolResult> { + return new ShellToolInvocation(this.config, params, this.allowlist); + } +} |
