mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
feat(core): Migrate read_many_files, shell, and web_fetch. (#6167)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user