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..825221ce06e1 --- /dev/null +++ b/packages/opencode/src/session/tool-timeout.ts @@ -0,0 +1,90 @@ +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` + } +} + +// 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) +} + +/** + * Create a timeout controller for a single tool execution. Returns: + * - `signal`: merged AbortSignal (caller's signal + our timeout timer) + * - `dispose`: cleanup function — must be called in a finally block + * + * The caller passes `signal` into their tool context construction and calls + * `dispose()` to clear the timer after execution completes or fails. + */ +export function createTimeoutController(opts: { tool: string; timeoutMs: number; userSignal?: AbortSignal }): { + signal: AbortSignal | undefined + dispose: () => void +} { + const controller = new AbortController() + const timer = setTimeout( + () => controller.abort(new ToolTimeoutError({ tool: opts.tool, timeoutMs: opts.timeoutMs })), + opts.timeoutMs, + ) + const signal = composeSignals(opts.userSignal, controller.signal) + return { + signal, + dispose: () => clearTimeout(timer), + } +} + +/** + * 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..f1f4480aa908 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,55 @@ 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 }) + const tc = + timeoutMs > 0 + ? ToolTimeout.createTimeoutController({ + tool: item.id, + timeoutMs, + userSignal: options.abortSignal, + }) + : { signal: options.abortSignal, dispose: () => {} } + const ctx = context(args, { ...options, abortSignal: tc.signal }) 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) { + 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 { + tc.dispose() } - return output }), ) }, @@ -398,92 +423,117 @@ 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 tc = + timeoutMs > 0 + ? ToolTimeout.createTimeoutController({ + tool: key, + timeoutMs, + userSignal: opts.abortSignal, + }) + : { signal: opts.abortSignal, dispose: () => {} } + const ctx = context(args, { ...opts, abortSignal: tc.signal }) 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: tc.signal })) + }).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 { + tc.dispose() } - return output }), ) tools[key] = item @@ -497,6 +547,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..4c884aa12be4 --- /dev/null +++ b/packages/opencode/test/session/tool-timeout.test.ts @@ -0,0 +1,191 @@ +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< + Config.Interface["get"] extends () => 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) + }) +}) + +describe("session.tool-timeout.createTimeoutController", () => { + test("fires ToolTimeoutError after timeout", async () => { + const tc = ToolTimeout.createTimeoutController({ tool: "test-tool", timeoutMs: 50 }) + const fired = new Promise((resolve) => { + tc.signal!.addEventListener("abort", () => resolve((tc.signal as AbortSignal).reason)) + }) + const result = await fired + expect(result).toBeInstanceOf(ToolTimeout.ToolTimeoutError) + tc.dispose() + }) + + test("aborted signal has correct tool and timeoutMs", async () => { + const tc = ToolTimeout.createTimeoutController({ tool: "my-tool", timeoutMs: 100 }) + const fired = new Promise((resolve) => { + tc.signal!.addEventListener("abort", () => resolve((tc.signal as AbortSignal).reason)) + }) + const error = (await fired) as ToolTimeout.ToolTimeoutError + expect(error.tool).toBe("my-tool") + expect(error.timeoutMs).toBe(100) + tc.dispose() + }) + + test("composes user signal with timeout signal", () => { + const userController = new AbortController() + const tc = ToolTimeout.createTimeoutController({ + tool: "compound", + timeoutMs: 5000, + userSignal: userController.signal, + }) + // The composed signal should abort when the user aborts + const aborted = new Promise((resolve) => { + tc.signal!.addEventListener("abort", () => resolve()) + }) + userController.abort() + return aborted.then(() => { + tc.dispose() + }) + }) + + test("dispose clears the timer so no abort fires", async () => { + const tc = ToolTimeout.createTimeoutController({ tool: "no-timeout", timeoutMs: 30 }) + tc.dispose() + // Wait longer than the timeout to confirm no abort fires + await new Promise((resolve) => setTimeout(resolve, 80)) + expect(tc.signal?.aborted).toBe(false) + }) + + test("createTimeoutController with non-aborted signal", () => { + const tc = ToolTimeout.createTimeoutController({ tool: "idle", timeoutMs: 999_999 }) + expect(tc.signal).toBeDefined() + expect(tc.signal?.aborted).toBe(false) + tc.dispose() + }) +})