summaryrefslogtreecommitdiff
path: root/packages/core/src/tools/shell.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/core/src/tools/shell.ts')
-rw-r--r--packages/core/src/tools/shell.ts255
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);
+ }
+}