Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/core/src/v1/config/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ const AgentSchema = Schema.StructWithRest(
description: "Maximum number of agentic iterations before forcing text-only response",
}),
maxSteps: Schema.optional(PositiveInt).annotate({ description: "@deprecated Use 'steps' field instead." }),
tool_timeout: Schema.optional(PositiveInt).annotate({
description:
"Per-agent override for tool execution timeout in ms. 0 disables. Beats experimental.tool_timeout. See #20096.",
}),
permission: Schema.optional(ConfigPermissionV1.Info),
}),
[Schema.Record(Schema.String, Schema.Any)],
Expand All @@ -53,6 +57,7 @@ const KNOWN_KEYS = new Set([
"color",
"steps",
"maxSteps",
"tool_timeout",
"options",
"permission",
"disable",
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/v1/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,14 @@ export const Info = Schema.Struct({
mcp_timeout: Schema.optional(PositiveInt).annotate({
description: "Timeout in milliseconds for model context protocol (MCP) requests",
}),
tool_timeout: Schema.optional(PositiveInt).annotate({
description:
"Default execution timeout in ms for any tool call. When a tool runs longer than this, opencode aborts it and synthesizes a tool-result so the agent loop can continue instead of wedging. 0 disables. Default: 600000 (10 min). See #20096.",
}),
task_timeout: Schema.optional(PositiveInt).annotate({
description:
"Execution timeout in ms for the `task` (subagent) tool. Falls back to experimental.tool_timeout when unset. 0 disables. See #20096.",
}),
policies: Schema.optional(Schema.mutable(Schema.Array(ConfigExperimental.Policy))).annotate({
description: "Policy statements applied to supported resources, such as provider access",
}),
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export const Info = Schema.Struct({
prompt: Schema.optional(Schema.String),
options: Schema.Record(Schema.String, Schema.Unknown),
steps: Schema.optional(Schema.Finite),
tool_timeout: Schema.optional(Schema.Finite),
}).annotate({ identifier: "Agent" })
export type Info = DeepMutable<Schema.Schema.Type<typeof Info>>

Expand Down Expand Up @@ -289,6 +290,7 @@ const layer = Layer.effect(
item.hidden = value.hidden ?? item.hidden
item.name = value.name ?? item.name
item.steps = value.steps ?? item.steps
item.tool_timeout = value.tool_timeout ?? item.tool_timeout
item.options = mergeDeep(item.options, value.options ?? {})
item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {}))
}
Expand Down
61 changes: 61 additions & 0 deletions packages/opencode/src/session/tool-timeout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
export * as ToolTimeout from "./tool-timeout"

import { Schema } from "effect"
import { Effect } from "effect"
import { Agent } from "@/agent/agent"
import { Config } from "@/config/config"

/**
* Raised when a tool call exceeds its configured execution deadline. The runner
* catches this to synthesize a tool-result so the agent loop can continue
* instead of wedging on a `status="running"` part that never resolves.
*
* See https://github.com/anomalyco/opencode/issues/20096 — the documented LLM
* `timeout` covers provider requests but opencode had no equivalent for the
* tool-side execution path, so a hung tool (e.g. an MCP server, a bash command
* that spawns a daemon, a detached browser session) blocked the session
* indefinitely.
*/
export class ToolTimeoutError extends Schema.TaggedErrorClass<ToolTimeoutError>()(
"ToolTimeoutError",
{
tool: Schema.String,
timeoutMs: Schema.Number,
},
) {
override get message() {
return `Tool "${this.tool}" timed out after ${this.timeoutMs}ms`
}
}

/**
* Per-tool execution deadline in milliseconds. `0` disables the timeout for
* this tool entirely (the agent's underlying `abort` signal still fires on
* user-initiated interrupt via Esc).
*/
export const DEFAULT_TOOL_TIMEOUT_MS = 600_000

/**
* Resolve the per-call execution timeout for the given (tool, agent) pair.
*
* Precedence (highest wins):
* 1. Per-agent `agent.tool_timeout`
* 2. `experimental.task_timeout` (only for the `task` tool)
* 3. Global `experimental.tool_timeout`
* 4. Hardcoded `DEFAULT_TOOL_TIMEOUT_MS` (10 min)
*
* Returns `0` when the effective config value is `0`, which the runner
* interprets as "disable the timeout entirely".
*/
export const resolve = Effect.fnUntraced(function* (input: {
tool: string
agent: Agent.Info
}) {
const service = yield* Config.Service
const cfg = yield* service.get()
const experimental = cfg.experimental
const globalDefault = experimental?.tool_timeout ?? DEFAULT_TOOL_TIMEOUT_MS
if (input.agent.tool_timeout !== undefined) return input.agent.tool_timeout
if (input.tool === "task" && experimental?.task_timeout !== undefined) return experimental.task_timeout
return globalDefault
})
Loading
Loading