mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-10-07 02:58:15 +00:00
feat: add timeout parameter to execute_command tool (#11622)
feat: add `timeout` parameter to `execute_command` tool Allow the agent to specify a per-command timeout in seconds. When exceeded, the command continues running in the background (like clicking "Run in Background") and the output collected so far is returned, rather than aborting. This is useful for long-running processes like dev servers or file watchers that may never exit on their own. The agent timeout runs independently of the user-configured abort timeout — the user timeout remains active as a safety net even after the agent moves on. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ea7da97a40
commit
318bb928e0
4 changed files with 81 additions and 46 deletions
|
|
@ -459,6 +459,7 @@ export class NativeToolCallParser {
|
|||
nativeArgs = {
|
||||
command: partialArgs.command,
|
||||
cwd: partialArgs.cwd,
|
||||
timeout: partialArgs.timeout,
|
||||
}
|
||||
}
|
||||
break
|
||||
|
|
@ -787,6 +788,7 @@ export class NativeToolCallParser {
|
|||
nativeArgs = {
|
||||
command: args.command,
|
||||
cwd: args.cwd,
|
||||
timeout: args.timeout,
|
||||
} as NativeArgsFor<TName>
|
||||
}
|
||||
break
|
||||
|
|
|
|||
|
|
@ -5,20 +5,26 @@ const EXECUTE_COMMAND_DESCRIPTION = `Request to execute a CLI command on the sys
|
|||
Parameters:
|
||||
- command: (required) The CLI command to execute. This should be valid for the current operating system. Ensure the command is properly formatted and does not contain any harmful instructions.
|
||||
- cwd: (optional) The working directory to execute the command in
|
||||
- timeout: (optional) Timeout in seconds. When exceeded, the command keeps running in the background and you receive the output so far. Set this for commands that may run indefinitely, such as dev servers or file watchers, so you can proceed without waiting for them to exit.
|
||||
|
||||
Example: Executing npm run dev
|
||||
{ "command": "npm run dev", "cwd": null }
|
||||
{ "command": "npm run dev", "cwd": null, "timeout": null }
|
||||
|
||||
Example: Executing ls in a specific directory if directed
|
||||
{ "command": "ls -la", "cwd": "/home/user/projects" }
|
||||
{ "command": "ls -la", "cwd": "/home/user/projects", "timeout": null }
|
||||
|
||||
Example: Using relative paths
|
||||
{ "command": "touch ./testdata/example.file", "cwd": null }`
|
||||
{ "command": "touch ./testdata/example.file", "cwd": null, "timeout": null }
|
||||
|
||||
Example: Running a build with a timeout
|
||||
{ "command": "npm run build", "cwd": null, "timeout": 30 }`
|
||||
|
||||
const COMMAND_PARAMETER_DESCRIPTION = `Shell command to execute`
|
||||
|
||||
const CWD_PARAMETER_DESCRIPTION = `Optional working directory for the command, relative or absolute`
|
||||
|
||||
const TIMEOUT_PARAMETER_DESCRIPTION = `Timeout in seconds. When exceeded, the command continues running in the background and output collected so far is returned. Use this for long-running processes like dev servers, file watchers, or any command that may not exit on its own`
|
||||
|
||||
export default {
|
||||
type: "function",
|
||||
function: {
|
||||
|
|
@ -36,8 +42,12 @@ export default {
|
|||
type: ["string", "null"],
|
||||
description: CWD_PARAMETER_DESCRIPTION,
|
||||
},
|
||||
timeout: {
|
||||
type: ["number", "null"],
|
||||
description: TIMEOUT_PARAMETER_DESCRIPTION,
|
||||
},
|
||||
},
|
||||
required: ["command", "cwd"],
|
||||
required: ["command", "cwd", "timeout"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -26,13 +26,14 @@ class ShellIntegrationError extends Error {}
|
|||
interface ExecuteCommandParams {
|
||||
command: string
|
||||
cwd?: string
|
||||
timeout?: number | null
|
||||
}
|
||||
|
||||
export class ExecuteCommandTool extends BaseTool<"execute_command"> {
|
||||
readonly name = "execute_command" as const
|
||||
|
||||
async execute(params: ExecuteCommandParams, task: Task, callbacks: ToolCallbacks): Promise<void> {
|
||||
const { command, cwd: customCwd } = params
|
||||
const { command, cwd: customCwd, timeout: timeoutSeconds } = params
|
||||
const { handleError, pushToolResult, askApproval } = callbacks
|
||||
|
||||
try {
|
||||
|
|
@ -85,12 +86,16 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> {
|
|||
// Convert seconds to milliseconds for internal use, but skip timeout if command is allowlisted
|
||||
const commandExecutionTimeout = isCommandAllowlisted ? 0 : commandExecutionTimeoutSeconds * 1000
|
||||
|
||||
// Convert agent-specified timeout from seconds to milliseconds
|
||||
const agentTimeout = typeof timeoutSeconds === "number" && timeoutSeconds > 0 ? timeoutSeconds * 1000 : 0
|
||||
|
||||
const options: ExecuteCommandOptions = {
|
||||
executionId,
|
||||
command: canonicalCommand,
|
||||
customCwd,
|
||||
terminalShellIntegrationDisabled,
|
||||
commandExecutionTimeout,
|
||||
agentTimeout,
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -144,6 +149,7 @@ export type ExecuteCommandOptions = {
|
|||
customCwd?: string
|
||||
terminalShellIntegrationDisabled?: boolean
|
||||
commandExecutionTimeout?: number
|
||||
agentTimeout?: number
|
||||
}
|
||||
|
||||
export async function executeCommandInTerminal(
|
||||
|
|
@ -154,6 +160,7 @@ export async function executeCommandInTerminal(
|
|||
customCwd,
|
||||
terminalShellIntegrationDisabled = true,
|
||||
commandExecutionTimeout = 0,
|
||||
agentTimeout = 0,
|
||||
}: ExecuteCommandOptions,
|
||||
): Promise<[boolean, ToolResponse]> {
|
||||
// Convert milliseconds back to seconds for display purposes.
|
||||
|
|
@ -308,49 +315,64 @@ export async function executeCommandInTerminal(
|
|||
const process = terminal.runCommand(command, callbacks)
|
||||
task.terminalProcess = process
|
||||
|
||||
// Implement command execution timeout (skip if timeout is 0).
|
||||
if (commandExecutionTimeout > 0) {
|
||||
let timeoutId: NodeJS.Timeout | undefined
|
||||
let isTimedOut = false
|
||||
// Dual-timeout logic:
|
||||
// - Agent timeout: transitions the command to background (continues running)
|
||||
// - User timeout: aborts the command (kills it)
|
||||
// Both timers run independently — the user timeout remains active as a safety net
|
||||
// even after the agent timeout moves the command to the background.
|
||||
let agentTimeoutId: NodeJS.Timeout | undefined
|
||||
let userTimeoutId: NodeJS.Timeout | undefined
|
||||
let isUserTimedOut = false
|
||||
|
||||
const timeoutPromise = new Promise<void>((_, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
isTimedOut = true
|
||||
task.terminalProcess?.abort()
|
||||
reject(new Error(`Command execution timed out after ${commandExecutionTimeout}ms`))
|
||||
}, commandExecutionTimeout)
|
||||
})
|
||||
try {
|
||||
const racers: Promise<void>[] = [process]
|
||||
|
||||
try {
|
||||
await Promise.race([process, timeoutPromise])
|
||||
} catch (error) {
|
||||
if (isTimedOut) {
|
||||
const status: CommandExecutionStatus = { executionId, status: "timeout" }
|
||||
provider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) })
|
||||
await task.say("error", t("common:errors:command_timeout", { seconds: commandExecutionTimeoutSeconds }))
|
||||
task.didToolFailInCurrentTurn = true
|
||||
task.terminalProcess = undefined
|
||||
|
||||
return [
|
||||
false,
|
||||
`The command was terminated after exceeding a user-configured ${commandExecutionTimeoutSeconds}s timeout. Do not try to re-run the command.`,
|
||||
]
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
|
||||
task.terminalProcess = undefined
|
||||
// Agent timeout: transition to background (command keeps running)
|
||||
if (agentTimeout > 0) {
|
||||
racers.push(
|
||||
new Promise<void>((resolve) => {
|
||||
agentTimeoutId = setTimeout(() => {
|
||||
runInBackground = true
|
||||
process.continue()
|
||||
task.supersedePendingAsk()
|
||||
resolve()
|
||||
}, agentTimeout)
|
||||
}),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// No timeout - just wait for the process to complete.
|
||||
try {
|
||||
await process
|
||||
} finally {
|
||||
task.terminalProcess = undefined
|
||||
|
||||
// User timeout: abort the command (existing behavior)
|
||||
if (commandExecutionTimeout > 0) {
|
||||
racers.push(
|
||||
new Promise<void>((_, reject) => {
|
||||
userTimeoutId = setTimeout(() => {
|
||||
isUserTimedOut = true
|
||||
task.terminalProcess?.abort()
|
||||
reject(new Error(`Command execution timed out after ${commandExecutionTimeout}ms`))
|
||||
}, commandExecutionTimeout)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
await Promise.race(racers)
|
||||
} catch (error) {
|
||||
if (isUserTimedOut) {
|
||||
const status: CommandExecutionStatus = { executionId, status: "timeout" }
|
||||
provider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) })
|
||||
await task.say("error", t("common:errors:command_timeout", { seconds: commandExecutionTimeoutSeconds }))
|
||||
task.didToolFailInCurrentTurn = true
|
||||
task.terminalProcess = undefined
|
||||
|
||||
return [
|
||||
false,
|
||||
`The command was terminated after exceeding a user-configured ${commandExecutionTimeoutSeconds}s timeout. Do not try to re-run the command.`,
|
||||
]
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
clearTimeout(agentTimeoutId)
|
||||
clearTimeout(userTimeoutId)
|
||||
task.terminalProcess = undefined
|
||||
}
|
||||
|
||||
if (shellIntegrationError) {
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ export const toolParamNames = [
|
|||
"new_string", // search_replace and edit_file parameter
|
||||
"replace_all", // edit tool parameter for replacing all occurrences
|
||||
"expected_replacements", // edit_file parameter for multiple occurrences
|
||||
"timeout", // execute_command parameter
|
||||
"artifact_id", // read_command_output parameter
|
||||
"search", // read_command_output parameter for grep-like search
|
||||
"offset", // read_command_output and read_file parameter
|
||||
|
|
@ -93,7 +94,7 @@ export type NativeToolArgs = {
|
|||
read_file: import("@roo-code/types").ReadFileToolParams
|
||||
read_command_output: { artifact_id: string; search?: string; offset?: number; limit?: number }
|
||||
attempt_completion: { result: string }
|
||||
execute_command: { command: string; cwd?: string }
|
||||
execute_command: { command: string; cwd?: string; timeout?: number | null }
|
||||
apply_diff: { path: string; diff: string }
|
||||
edit: { file_path: string; old_string: string; new_string: string; replace_all?: boolean }
|
||||
search_and_replace: { file_path: string; old_string: string; new_string: string; replace_all?: boolean }
|
||||
|
|
@ -168,7 +169,7 @@ export interface McpToolUse {
|
|||
export interface ExecuteCommandToolUse extends ToolUse<"execute_command"> {
|
||||
name: "execute_command"
|
||||
// Pick<Record<ToolParamName, string>, "command"> makes "command" required, but Partial<> makes it optional
|
||||
params: Partial<Pick<Record<ToolParamName, string>, "command" | "cwd">>
|
||||
params: Partial<Pick<Record<ToolParamName, string>, "command" | "cwd" | "timeout">>
|
||||
}
|
||||
|
||||
export interface ReadFileToolUse extends ToolUse<"read_file"> {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue