From 458e2224a182ee3a7e0284ed3009ec94030c0ff7 Mon Sep 17 00:00:00 2001 From: FahadBinHussain Date: Tue, 14 Jul 2026 20:29:49 +0600 Subject: [PATCH] feat(opencode): per-tool execution timeout with abort + session recovery Tools (built-in and MCP) can now hang the agent loop indefinitely when they wedge on a long-running or unresponsive process. This adds a configurable per-tool execution timeout that aborts the call and synthesizes a tool-result so the agent can continue instead of staying stuck on a status=running part forever. Config surface (all optional, in ms, 0 disables): - experimental.tool_timeout (global default, default 600000 / 10 min) - experimental.task_timeout (overrides tool_timeout for the task tool) - agent.tool_timeout (per-agent override, highest precedence) Precedence: agent.tool_timeout > task_timeout (task only) > tool_timeout > DEFAULT_TOOL_TIMEOUT_MS. On timeout the runner fires an AbortController that is composed with the user's existing abort signal via AbortSignal.any, so both Esc interrupts and timeouts surface through the same ctx.abort path. The catch block synthesizes a tool-result with metadata.timeout=true and a message guiding the agent to retry differently. See #20096, #34888, #20216, #34022, #34960. --- packages/core/src/v1/config/agent.ts | 5 + packages/core/src/v1/config/config.ts | 8 + packages/opencode/src/agent/agent.ts | 2 + packages/opencode/src/session/tool-timeout.ts | 61 +++++ packages/opencode/src/session/tools.ts | 249 +++++++++++------- .../test/session/tool-timeout.test.ts | 145 ++++++++++ 6 files changed, 379 insertions(+), 91 deletions(-) create mode 100644 packages/opencode/src/session/tool-timeout.ts create mode 100644 packages/opencode/test/session/tool-timeout.test.ts diff --git a/packages/core/src/v1/config/agent.ts b/packages/core/src/v1/config/agent.ts index b220bd7ef87d..09a0aca1e499 100644 --- a/packages/core/src/v1/config/agent.ts +++ b/packages/core/src/v1/config/agent.ts @@ -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)], @@ -53,6 +57,7 @@ const KNOWN_KEYS = new Set([ "color", "steps", "maxSteps", + "tool_timeout", "options", "permission", "disable", diff --git a/packages/core/src/v1/config/config.ts b/packages/core/src/v1/config/config.ts index 2e773f71e256..2f09d28651fa 100644 --- a/packages/core/src/v1/config/config.ts +++ b/packages/core/src/v1/config/config.ts @@ -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", }), diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 536a642fe49f..58b1938b5388 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -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> @@ -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 ?? {})) } diff --git a/packages/opencode/src/session/tool-timeout.ts b/packages/opencode/src/session/tool-timeout.ts new file mode 100644 index 000000000000..58d1526bda99 --- /dev/null +++ b/packages/opencode/src/session/tool-timeout.ts @@ -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", + { + 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 +}) diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index 0f401c7562fa..6ab0d9d7d326 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -23,6 +23,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { isRecord } from "@/util/record" import { RuntimeFlags } from "@/effect/runtime-flags" +import { ToolTimeout } from "./tool-timeout" const MCP_RESOURCE_TOOLS = { list: "list_mcp_resources", @@ -102,31 +103,62 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { execute(args, options) { return run.promise( Effect.gen(function* () { - const ctx = context(args, options) + const timeoutMs = yield* ToolTimeout.resolve({ tool: item.id, agent: input.agent }) + // Compose the user's abort signal with our own timer-driven controller + // so an Esc interrupt OR a timeout expires both fire the same signal + // that the tool's `execute(ctx.abort)` already listens to. + const controller = timeoutMs > 0 ? new AbortController() : undefined + const timer = + controller && setTimeout( + () => controller.abort(new ToolTimeout.ToolTimeoutError({ tool: item.id, timeoutMs })), + timeoutMs, + ) + const mergedSignal = composeSignals(options.abortSignal, controller?.signal) + const ctx = context(args, { ...options, abortSignal: mergedSignal }) yield* plugin.trigger( "tool.execute.before", { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID }, { args }, ) - const result = yield* item.execute(args, ctx) - const output = { - ...result, - attachments: result.attachments?.map((attachment) => ({ - ...attachment, - id: PartID.ascending(), - sessionID: ctx.sessionID, - messageID: input.processor.message.id, - })), - } - yield* plugin.trigger( - "tool.execute.after", - { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID, args }, - output, - ) - if (options.abortSignal?.aborted) { - yield* input.processor.completeToolCall(options.toolCallId, output) + try { + const result = yield* item.execute(args, ctx) + const output = { + ...result, + attachments: result.attachments?.map((attachment) => ({ + ...attachment, + id: PartID.ascending(), + sessionID: ctx.sessionID, + messageID: input.processor.message.id, + })), + } + yield* plugin.trigger( + "tool.execute.after", + { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID, args }, + output, + ) + if (options.abortSignal?.aborted) { + yield* input.processor.completeToolCall(options.toolCallId, output) + } + return output + } catch (error) { + if (error instanceof ToolTimeout.ToolTimeoutError) { + // Synthesize a tool-result so the agent loop continues instead of + // wedging on a part that stays `status="running"` forever. The + // underlying process may still be alive — the abort signal we + // fired into `ctx.abort` is the cleanup nudge, but completion is + // best-effort. See #20096. + const output = { + title: `Tool timed out after ${timeoutMs}ms`, + metadata: { timeout: true, tool: item.id, timeoutMs }, + output: `Tool "${item.id}" was aborted after ${timeoutMs}ms. The underlying process may still be running. Consider retrying with a different approach, a shorter scope, or splitting the work into smaller steps.`, + } + yield* input.processor.completeToolCall(options.toolCallId, output) + return output + } + throw error + } finally { + if (timer) clearTimeout(timer) } - return output }), ) }, @@ -398,92 +430,116 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { item.execute = (args, opts) => run.promise( Effect.gen(function* () { - const ctx = context(args, opts) + const timeoutMs = yield* ToolTimeout.resolve({ tool: key, agent: input.agent }) + const controller = timeoutMs > 0 ? new AbortController() : undefined + const timer = + controller && setTimeout( + () => controller.abort(new ToolTimeout.ToolTimeoutError({ tool: key, timeoutMs })), + timeoutMs, + ) + const mergedSignal = composeSignals(opts.abortSignal, controller?.signal) + const ctx = context(args, { ...opts, abortSignal: mergedSignal }) yield* plugin.trigger( "tool.execute.before", { tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId }, { args }, ) - const result: Awaited>> = yield* Effect.gen(function* () { - yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] }) - return yield* Effect.promise(() => execute(args, opts)) - }).pipe( - Effect.withSpan("Tool.execute", { - attributes: { - "tool.name": key, - "tool.call_id": opts.toolCallId, - "session.id": ctx.sessionID, - "message.id": input.processor.message.id, - }, - }), - ) - yield* plugin.trigger( - "tool.execute.after", - { tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId, args }, - result, - ) + try { + const result: Awaited>> = yield* Effect.gen(function* () { + yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] }) + return yield* Effect.promise(() => execute(args, { ...opts, abortSignal: mergedSignal })) + }).pipe( + Effect.withSpan("Tool.execute", { + attributes: { + "tool.name": key, + "tool.call_id": opts.toolCallId, + "session.id": ctx.sessionID, + "message.id": input.processor.message.id, + }, + }), + ) + yield* plugin.trigger( + "tool.execute.after", + { tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId, args }, + result, + ) - const textParts: string[] = [] - const attachments: Omit[] = [] - for (const contentItem of result.content) { - if (contentItem.type === "text") textParts.push(contentItem.text) - else if (contentItem.type === "image") { - attachments.push({ - type: "file", - mime: contentItem.mimeType, - url: `data:${contentItem.mimeType};base64,${contentItem.data}`, - }) - } else if (contentItem.type === "resource") { - const { resource } = contentItem - if (resource.text) textParts.push(resource.text) - if (resource.blob) { - const mime = resource.mimeType ?? "application/octet-stream" - const size = base64Size(resource.blob) - if (!SUPPORTED_MCP_RESOURCE_ATTACHMENT_MIMES.has(mime)) { - textParts.push( - `[Binary MCP resource omitted: ${resource.uri} (${mime}, ${formatBytes(size)}) is not a supported attachment type]`, - ) - continue - } - if (size > MAX_MCP_RESOURCE_BLOB_BYTES) { - textParts.push( - `[Binary MCP resource omitted: ${resource.uri} (${mime}, ${formatBytes(size)}) exceeds ${formatBytes(MAX_MCP_RESOURCE_BLOB_BYTES)}]`, - ) - continue - } + const textParts: string[] = [] + const attachments: Omit[] = [] + for (const contentItem of result.content) { + if (contentItem.type === "text") textParts.push(contentItem.text) + else if (contentItem.type === "image") { attachments.push({ type: "file", - mime, - url: `data:${mime};base64,${resource.blob}`, - filename: resource.uri, + mime: contentItem.mimeType, + url: `data:${contentItem.mimeType};base64,${contentItem.data}`, }) + } else if (contentItem.type === "resource") { + const { resource } = contentItem + if (resource.text) textParts.push(resource.text) + if (resource.blob) { + const mime = resource.mimeType ?? "application/octet-stream" + const size = base64Size(resource.blob) + if (!SUPPORTED_MCP_RESOURCE_ATTACHMENT_MIMES.has(mime)) { + textParts.push( + `[Binary MCP resource omitted: ${resource.uri} (${mime}, ${formatBytes(size)}) is not a supported attachment type]`, + ) + continue + } + if (size > MAX_MCP_RESOURCE_BLOB_BYTES) { + textParts.push( + `[Binary MCP resource omitted: ${resource.uri} (${mime}, ${formatBytes(size)}) exceeds ${formatBytes(MAX_MCP_RESOURCE_BLOB_BYTES)}]`, + ) + continue + } + attachments.push({ + type: "file", + mime, + url: `data:${mime};base64,${resource.blob}`, + filename: resource.uri, + }) + } } } - } - const truncated = yield* truncate.output(textParts.join("\n\n"), {}, input.agent) - const metadata = { - ...result.metadata, - truncated: truncated.truncated, - ...(truncated.truncated && { outputPath: truncated.outputPath }), - } + const truncated = yield* truncate.output(textParts.join("\n\n"), {}, input.agent) + const metadata = { + ...result.metadata, + truncated: truncated.truncated, + ...(truncated.truncated && { outputPath: truncated.outputPath }), + } - const output = { - title: "", - metadata, - output: truncated.content, - attachments: attachments.map((attachment) => ({ - ...attachment, - id: PartID.ascending(), - sessionID: ctx.sessionID, - messageID: input.processor.message.id, - })), - content: result.content, - } - if (opts.abortSignal?.aborted) { - yield* input.processor.completeToolCall(opts.toolCallId, output) + const output = { + title: "", + metadata, + output: truncated.content, + attachments: attachments.map((attachment) => ({ + ...attachment, + id: PartID.ascending(), + sessionID: ctx.sessionID, + messageID: input.processor.message.id, + })), + content: result.content, + } + if (opts.abortSignal?.aborted) { + yield* input.processor.completeToolCall(opts.toolCallId, output) + } + return output + } catch (error) { + if (error instanceof ToolTimeout.ToolTimeoutError) { + const output = { + title: `Tool timed out after ${timeoutMs}ms`, + metadata: { timeout: true, tool: key, timeoutMs }, + output: `Tool "${key}" was aborted after ${timeoutMs}ms. The underlying MCP server may still be processing. Consider retrying with a different approach, a shorter scope, or splitting the work.`, + content: [], + } + yield* input.processor.completeToolCall(opts.toolCallId, output) + return output + } + throw error + } finally { + if (timer) clearTimeout(timer) } - return output }), ) tools[key] = item @@ -497,6 +553,17 @@ function toRecord(value: unknown) { return {} } +// Combine the upstream caller's AbortSignal (user-initiated Esc interrupt) with +// our session-level timeout controller so a single `ctx.abort` surfaces both. +// `AbortSignal.any` is Node 20+/Bun-native; when both inputs are missing we +// return undefined so we don't replace nothing with nothing. +function composeSignals(user?: AbortSignal, ours?: AbortSignal): AbortSignal | undefined { + const signals = [user, ours].filter((s): s is AbortSignal => Boolean(s)) + if (signals.length === 0) return undefined + if (signals.length === 1) return signals[0] + return AbortSignal.any(signals) +} + function parseListMcpResourcesArgs(value: unknown) { const args = toRecord(value) return { server: optionalString(args, "server") } diff --git a/packages/opencode/test/session/tool-timeout.test.ts b/packages/opencode/test/session/tool-timeout.test.ts new file mode 100644 index 000000000000..2aefa167b1eb --- /dev/null +++ b/packages/opencode/test/session/tool-timeout.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, test } from "bun:test" +import { Effect, Layer } from "effect" + +import { ToolTimeout } from "../../src/session/tool-timeout" +import type { Agent } from "../../src/agent/agent" +import { Config } from "@/config/config" +import { TestConfig } from "../fixture/config" +import { testEffect } from "../lib/effect" + +const it = testEffect(Layer.empty) + +function makeAgent(overrides: Partial = {}): Agent.Info { + return { + name: "test", + mode: "primary", + permission: [], + options: {}, + ...overrides, + } as Agent.Info +} + +function configLayerWith(experimental: Record | undefined) { + return Layer.succeed( + Config.Service, + TestConfig.make({ + get: () => Effect.succeed({ experimental } as ReturnType Effect.Effect ? () => A : never>), + }), + ) +} + +describe("session.tool-timeout.resolve", () => { + it.effect("uses DEFAULT_TOOL_TIMEOUT_MS when no config is set", () => + Effect.gen(function* () { + const result = yield* ToolTimeout.resolve({ + tool: "read", + agent: makeAgent(), + }).pipe(Effect.provide(configLayerWith(undefined))) + expect(result).toBe(ToolTimeout.DEFAULT_TOOL_TIMEOUT_MS) + }), + ) + + it.effect("uses experimental.tool_timeout when set", () => + Effect.gen(function* () { + const result = yield* ToolTimeout.resolve({ + tool: "read", + agent: makeAgent(), + }).pipe(Effect.provide(configLayerWith({ tool_timeout: 30_000 }))) + expect(result).toBe(30_000) + }), + ) + + it.effect("agent.tool_timeout beats experimental.tool_timeout", () => + Effect.gen(function* () { + const result = yield* ToolTimeout.resolve({ + tool: "read", + agent: makeAgent({ tool_timeout: 15_000 }), + }).pipe(Effect.provide(configLayerWith({ tool_timeout: 30_000 }))) + expect(result).toBe(15_000) + }), + ) + + it.effect("returns 0 when agent.tool_timeout is 0 (disabled)", () => + Effect.gen(function* () { + const result = yield* ToolTimeout.resolve({ + tool: "read", + agent: makeAgent({ tool_timeout: 0 }), + }).pipe(Effect.provide(configLayerWith({ tool_timeout: 30_000 }))) + expect(result).toBe(0) + }), + ) + + it.effect("returns 0 when experimental.tool_timeout is 0 (disabled)", () => + Effect.gen(function* () { + const result = yield* ToolTimeout.resolve({ + tool: "read", + agent: makeAgent(), + }).pipe(Effect.provide(configLayerWith({ tool_timeout: 0 }))) + expect(result).toBe(0) + }), + ) + + it.effect("task tool uses experimental.task_timeout when set", () => + Effect.gen(function* () { + const result = yield* ToolTimeout.resolve({ + tool: "task", + agent: makeAgent(), + }).pipe( + Effect.provide( + configLayerWith({ tool_timeout: 30_000, task_timeout: 120_000 }), + ), + ) + expect(result).toBe(120_000) + }), + ) + + it.effect("task tool falls back to tool_timeout when task_timeout is unset", () => + Effect.gen(function* () { + const result = yield* ToolTimeout.resolve({ + tool: "task", + agent: makeAgent(), + }).pipe(Effect.provide(configLayerWith({ tool_timeout: 45_000 }))) + expect(result).toBe(45_000) + }), + ) + + it.effect("non-task tools ignore experimental.task_timeout", () => + Effect.gen(function* () { + const result = yield* ToolTimeout.resolve({ + tool: "bash", + agent: makeAgent(), + }).pipe( + Effect.provide( + configLayerWith({ tool_timeout: 30_000, task_timeout: 120_000 }), + ), + ) + expect(result).toBe(30_000) + }), + ) + + it.effect("agent.tool_timeout beats task_timeout for the task tool", () => + Effect.gen(function* () { + const result = yield* ToolTimeout.resolve({ + tool: "task", + agent: makeAgent({ tool_timeout: 90_000 }), + }).pipe( + Effect.provide( + configLayerWith({ tool_timeout: 30_000, task_timeout: 120_000 }), + ), + ) + expect(result).toBe(90_000) + }), + ) +}) + +describe("session.tool-timeout.ToolTimeoutError", () => { + test("message includes tool name and timeout", () => { + const error = new ToolTimeout.ToolTimeoutError({ + tool: "bash", + timeoutMs: 42_000, + }) + expect(error.message).toBe('Tool "bash" timed out after 42000ms') + expect(error.tool).toBe("bash") + expect(error.timeoutMs).toBe(42_000) + }) +})