feat(core): Migrate read_many_files, shell, and web_fetch. (#6167)

This commit is contained in:
joshualitt
2025-08-13 12:27:09 -07:00
committed by GitHub
parent 904f4623b6
commit c0c0e9b7a0
6 changed files with 503 additions and 464 deletions

View File

@@ -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);
}
}