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) + }) +})