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:
Daniel 2026-02-19 23:43:09 -05:00 • committed by GitHub
parent ea7da97a40
commit 318bb928e0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 81 additions and 46 deletions

View file

@ -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

View file

@ -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,
},
},

View file

@ -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) {

View file

@ -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"> {