diff --git a/packages/core/src/v1/config/config.ts b/packages/core/src/v1/config/config.ts index 651de36fae5a..c6b5eab7aa79 100644 --- a/packages/core/src/v1/config/config.ts +++ b/packages/core/src/v1/config/config.ts @@ -146,6 +146,12 @@ export const Info = Schema.Struct({ description: "Thresholds for truncating tool output. When output exceeds either limit, the full text is written to the truncation directory and a preview is returned.", }), + tool_timeout: Schema.optional( + Schema.Record(Schema.String, Schema.Union([PositiveInt, Schema.Literal(false)])), + ).annotate({ + description: + "Time limits in milliseconds for tool calls, or false for no limit. A call still running past its limit is aborted and ends with an error the model sees. 'default' applies to every tool (default: 1800000, 30 minutes); any other key is a tool ID (built-in, plugin or MCP, e.g. 'bash' or 'myserver_search') and replaces every other limit for that tool. read, write, edit, grep and glob default to 600000 (10 minutes), or 'default' when that is lower. question and task have no limit unless set here.", + }), compaction: Schema.optional( Schema.Struct({ auto: Schema.optional(Schema.Boolean).annotate({ diff --git a/packages/core/src/v1/config/mcp.ts b/packages/core/src/v1/config/mcp.ts index 0a2aeff12fb0..c4937592f24e 100644 --- a/packages/core/src/v1/config/mcp.ts +++ b/packages/core/src/v1/config/mcp.ts @@ -3,6 +3,11 @@ export * as ConfigMCPV1 from "./mcp" import { Schema } from "effect" import { PositiveInt } from "../../schema" +const ToolTimeout = Schema.optional(Schema.Union([PositiveInt, Schema.Literal(false)])).annotate({ + description: + "Default time limit in ms for each call to this server's tools, or false for none. Replaces tool_timeout.default for these tools; a tool_timeout entry naming the tool replaces this. Unlike timeout, it is not reset by progress notifications.", +}) + export const Local = Schema.Struct({ type: Schema.Literal("local").annotate({ description: "Type of MCP server connection" }), command: Schema.mutable(Schema.Array(Schema.String)).annotate({ @@ -20,6 +25,7 @@ export const Local = Schema.Struct({ timeout: Schema.optional(PositiveInt).annotate({ description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.", }), + tool_timeout: ToolTimeout, }).annotate({ identifier: "McpLocalConfig" }) export type Local = Schema.Schema.Type @@ -56,6 +62,7 @@ export const Remote = Schema.Struct({ timeout: Schema.optional(PositiveInt).annotate({ description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.", }), + tool_timeout: ToolTimeout, }).annotate({ identifier: "McpRemoteConfig" }) export type Remote = Schema.Schema.Type diff --git a/packages/opencode/src/config/v2-compat.ts b/packages/opencode/src/config/v2-compat.ts index 9e4e0bf54089..f936e26cd7ea 100644 --- a/packages/opencode/src/config/v2-compat.ts +++ b/packages/opencode/src/config/v2-compat.ts @@ -41,6 +41,7 @@ const Server = Schema.Union([ disabled: Schema.optional(Schema.Boolean), codemode: Schema.optional(Schema.Boolean), timeout: Schema.optional(Timeout), + tool_timeout: Schema.optional(Schema.Union([PositiveInt, Schema.Literal(false)])), }), Schema.Struct({ type: Schema.Literal("remote"), @@ -50,6 +51,7 @@ const Server = Schema.Union([ disabled: Schema.optional(Schema.Boolean), codemode: Schema.optional(Schema.Boolean), timeout: Schema.optional(Timeout), + tool_timeout: Schema.optional(Schema.Union([PositiveInt, Schema.Literal(false)])), }), ]) const Selection = Schema.Union([ diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 05f12fa2ee45..d97fe983bb7c 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -159,6 +159,10 @@ export interface McpTool { readonly def: MCPToolDef readonly client: MCPClient readonly timeout?: number + /** The configured server name, as written in the `mcp` config. */ + readonly server: string + /** The server's `tool_timeout`: default time limit in ms for a call to its tools, `false` for none. */ + readonly toolTimeout?: number | false } export interface Interface { @@ -680,8 +684,17 @@ const layer = Layer.effect( continue } const timeout = requestTimeout(s, clientName, mcpConfig, defaultTimeout) + const toolTimeout = + s.config[clientName]?.tool_timeout ?? + (mcpConfig && isMcpConfigured(mcpConfig) ? mcpConfig.tool_timeout : undefined) for (const def of listed) { - result[McpCatalog.toolName(clientName, def.name)] = { def, client, timeout } + result[McpCatalog.toolName(clientName, def.name)] = { + def, + client, + timeout, + server: clientName, + toolTimeout, + } } } return result diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 0f85d44f209b..e22e9a03d188 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1238,6 +1238,7 @@ const layer = Layer.effect( Effect.provideService(MCP.Service, mcp), Effect.provideService(Truncate.Service, truncate), Effect.provideService(RuntimeFlags.Service, flags), + Effect.provideService(Config.Service, config), ) if (lastUser.format?.type === "json_schema") { diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index 99f7aec4fdfd..0d3c2773d07d 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -9,6 +9,8 @@ import { Tool } from "@/tool/tool" import { ToolJsonSchema } from "@/tool/json-schema" import { ToolRegistry } from "@/tool/registry" import { Truncate } from "@/tool/truncate" +import { ToolTimeout } from "@/tool/timeout" +import { Config } from "@/config/config" import { Plugin } from "@/plugin" import type { TaskPromptOps } from "@/tool/task" @@ -55,10 +57,27 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { const mcp = yield* MCP.Service const truncate = yield* Truncate.Service const flags = yield* RuntimeFlags.Service - - const context = (args: Record, options: ToolExecutionOptions): Tool.Context => ({ + const config = yield* Config.Service + const limits = (yield* config.get()).tool_timeout + + // Every tool call runs under a hard time limit; see ToolTimeout.resolve for + // how a call's limit is chosen. + const guard = ( + options: ToolExecutionOptions, + limit: { tool: string; declared?: ToolTimeout.Limit; call?: number }, + execute: (call: ToolTimeout.Call) => Effect.Effect, + ) => + ToolTimeout.run(ToolTimeout.resolve({ ...limit, config: limits }), options.abortSignal, (call) => + run.fork(execute(call)), + ) + + const context = ( + args: Record, + options: ToolExecutionOptions, + call: ToolTimeout.Call, + ): Tool.Context => ({ sessionID: input.session.id, - abort: options.abortSignal!, + abort: call.signal, messageID: input.processor.message.id, callID: options.toolCallId, extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck, promptOps: input.promptOps }, @@ -79,14 +98,16 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { } }), ask: (req) => - permission - .ask({ - ...req, - sessionID: input.session.id, - tool: { messageID: input.processor.message.id, callID: options.toolCallId }, - ruleset: Permission.merge(input.agent.permission, input.session.permission ?? []), - }) - .pipe(Effect.orDie), + call.hold( + permission + .ask({ + ...req, + sessionID: input.session.id, + tool: { messageID: input.processor.message.id, callID: options.toolCallId }, + ruleset: Permission.merge(input.agent.permission, input.session.permission ?? []), + }) + .pipe(Effect.orDie), + ), }) for (const item of yield* registry.tools({ @@ -100,9 +121,14 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { description: item.description, inputSchema: jsonSchema(schema), execute(args, options) { - return run.promise( + const limit = { + tool: item.id, + declared: item.timeout === undefined ? undefined : { ms: item.timeout, source: "tool default" }, + call: item.callTimeout?.(toRecord(args)), + } + return guard(options, limit, (call) => Effect.gen(function* () { - const ctx = context(args, options) + const ctx = context(args, options, call) yield* plugin.trigger( "tool.execute.before", { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID }, @@ -153,10 +179,10 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { }), ), execute(args, opts) { - return run.promise( + return guard(opts, { tool: MCP_RESOURCE_TOOLS.list }, (call) => Effect.gen(function* () { const parsed = parseListMcpResourcesArgs(args) - const ctx = context(toRecord(args), opts) + const ctx = context(toRecord(args), opts, call) const clients = yield* mcp.clients() const resourceServers = Object.entries(clients) .filter((entry) => !!entry[1].getServerCapabilities()?.resources) @@ -236,10 +262,10 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { }), ), execute(args, opts) { - return run.promise( + return guard(opts, { tool: MCP_RESOURCE_TOOLS.listTemplates }, (call) => Effect.gen(function* () { const parsed = parseListMcpResourcesArgs(args) - const ctx = context(toRecord(args), opts) + const ctx = context(toRecord(args), opts, call) const clients = yield* mcp.clients() const resourceServers = Object.entries(clients) .filter((entry) => !!entry[1].getServerCapabilities()?.resources) @@ -323,10 +349,10 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { }), ), execute(args, opts) { - return run.promise( + return guard(opts, { tool: MCP_RESOURCE_TOOLS.read }, (call) => Effect.gen(function* () { const parsed = parseReadMcpResourceArgs(args) - const ctx = context(toRecord(args), opts) + const ctx = context(toRecord(args), opts, call) const clients = yield* mcp.clients() const client = clients[parsed.server] if (!client) { @@ -395,10 +421,17 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { const schema = yield* Effect.promise(() => Promise.resolve(asSchema(item.inputSchema).jsonSchema)) const transformed = ProviderTransform.schema(input.model, { ...schema, properties: schema.properties ?? {} }) item.inputSchema = jsonSchema(transformed) + const limit = { + tool: key, + declared: + entry.toolTimeout === undefined + ? undefined + : { ms: entry.toolTimeout, source: `config: mcp.${entry.server}.tool_timeout` }, + } item.execute = (args, opts) => - run.promise( + guard(opts, limit, (call) => Effect.gen(function* () { - const ctx = context(args, opts) + const ctx = context(args, opts, call) yield* plugin.trigger( "tool.execute.before", { tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId }, @@ -406,7 +439,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { ) const result: Awaited>> = yield* Effect.gen(function* () { yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] }) - return yield* Effect.promise(() => execute(args, opts)) + return yield* Effect.promise(() => execute(args, { ...opts, abortSignal: call.signal })) }).pipe( Effect.withSpan("Tool.execute", { attributes: { diff --git a/packages/opencode/src/tool/glob.ts b/packages/opencode/src/tool/glob.ts index 40d3a27d3cba..d51ed5750948 100644 --- a/packages/opencode/src/tool/glob.ts +++ b/packages/opencode/src/tool/glob.ts @@ -47,7 +47,7 @@ export const GlobTool = Tool.define( }) const limit = 100 - const files = yield* ripgrep.glob({ cwd: search, pattern: params.pattern, limit }) + const files = yield* ripgrep.glob({ cwd: search, pattern: params.pattern, limit, signal: ctx.abort }) const truncated = files.length === limit const output = [] diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index 6ea67124a6f9..95e92b672739 100644 --- a/packages/opencode/src/tool/grep.ts +++ b/packages/opencode/src/tool/grep.ts @@ -65,6 +65,7 @@ export const GrepTool = Tool.define( pattern: params.pattern, include: params.include, limit: 100, + signal: ctx.abort, }) if (result.length === 0) return empty diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 9167cb3ea6bc..6520812e08a0 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -140,6 +140,9 @@ const layer = Layer.effect( parameters, jsonSchema, description: def.description, + // Plugins are untyped at runtime; anything but a positive number or `false` declares nothing. + timeout: + def.timeout === false || (typeof def.timeout === "number" && def.timeout > 0) ? def.timeout : undefined, execute: (args, toolCtx) => Effect.gen(function* () { // Bridge the host's Effect-based `ask` into a Promise-returning @@ -333,6 +336,8 @@ const layer = Layer.effect( jsonSchema, execute: tool.execute, formatValidationError: tool.formatValidationError, + timeout: tool.timeout, + callTimeout: tool.callTimeout, } }), { concurrency: "unbounded" }, diff --git a/packages/opencode/src/tool/shell.ts b/packages/opencode/src/tool/shell.ts index 1e4423e01774..a321bf38baf9 100644 --- a/packages/opencode/src/tool/shell.ts +++ b/packages/opencode/src/tool/shell.ts @@ -606,6 +606,7 @@ export const ShellTool = Tool.define( return { description: prompt.description, parameters: prompt.parameters, + callTimeout: (args) => (typeof args.timeout === "number" ? args.timeout : defaultTimeoutMs), execute: (params: Parameters, ctx: Tool.Context) => Effect.gen(function* () { const instanceCtx = yield* InstanceState.context diff --git a/packages/opencode/src/tool/timeout.ts b/packages/opencode/src/tool/timeout.ts new file mode 100644 index 000000000000..4a255a1cbcaa --- /dev/null +++ b/packages/opencode/src/tool/timeout.ts @@ -0,0 +1,160 @@ +import { Effect, Fiber } from "effect" + +/** A time limit in milliseconds, or `false` for no limit. */ +export type Setting = number | false + +export interface Limit { + readonly ms: Setting + /** Where the limit came from, quoted in the error a call that exceeds it ends with. */ + readonly source: string +} + +export interface Call { + /** Aborts when the turn is aborted or the call exceeds its limit. */ + readonly signal: AbortSignal + /** Stops the clock while `effect` runs, so time spent waiting on a person does not count. */ + readonly hold: (effect: Effect.Effect) => Effect.Effect +} + +export const DEFAULT_MS = 30 * 60 * 1000 + +/** Room a tool's own per-call timeout gets to stop the work and report it before the hard limit ends the call. */ +export const CALL_GRACE_MS = 60 * 1000 + +// Tools that legitimately wait on a person or on a whole subagent run. These +// stand in for a declaration from the tool's provider, so a plugin's own +// `timeout` replaces them and a user's `tool_timeout.` beats both. +const UNLIMITED = new Set(["question", "task", "call_omo_agent"]) + +/** Limit for built-in file tools, which never legitimately run this long: a call that does is hung on I/O. */ +export const FILE_TOOL_MS = 10 * 60 * 1000 + +// A lower `tool_timeout.default` still applies to these; a higher one does not lift them. +const FILE_TOOLS = new Set(["read", "write", "edit", "grep", "glob"]) + +/** + * The most specific setting wins; each level is only a default for the one + * below it: + * + * 1. the user's `tool_timeout.`, higher or lower; + * 2. the tool provider's default: a plugin tool's `timeout`, an MCP server's + * `tool_timeout`, the built-in exemptions, or the file tools' 10 minutes; + * 3. the user's `tool_timeout.default`, else 30 minutes. + * + * A per-call timeout the tool itself takes (bash's `timeout` argument) raises + * levels 2 and 3 to that timeout plus a grace period, never level 1. + */ +export function resolve(input: { + tool: string + config?: Readonly> + declared?: Limit + call?: number +}): Limit { + if (input.config && input.tool !== "default" && Object.hasOwn(input.config, input.tool)) + return { ms: input.config[input.tool], source: `config: tool_timeout.${input.tool}` } + const limit = fallback(input) + if (input.call === undefined || limit.ms === false) return limit + const raised = input.call + CALL_GRACE_MS + if (raised <= limit.ms) return limit + return { ms: raised, source: "the call's own timeout argument" } +} + +/** + * Runs the call `fork` starts under `limit`. A call still running when the + * limit elapses is ended the way an abort ends it: its signal aborts, its fiber + * is interrupted, and the returned promise rejects with the limit and its + * source without waiting for the tool to notice. An abort of `parent` is + * forwarded to the call's signal and otherwise left to the tool, as before. + */ +export function run( + limit: Limit, + parent: AbortSignal | undefined, + fork: (call: Call) => Fiber.Fiber, +): Promise { + const controller = new AbortController() + const forward = () => controller.abort(parent?.reason) + if (parent?.aborted) forward() + parent?.addEventListener("abort", forward, { once: true }) + const join = (fiber: Fiber.Fiber) => Effect.runPromise(Fiber.join(fiber)) + const limitMs = limit.ms + if (limitMs === false) { + return join(fork({ signal: controller.signal, hold: (effect) => effect })).finally(() => + parent?.removeEventListener("abort", forward), + ) + } + + const state = { + remaining: limitMs, + started: Date.now(), + holds: 0, + timer: undefined as ReturnType | undefined, + fiber: undefined as Fiber.Fiber | undefined, + } + const expired = Promise.withResolvers() + const arm = () => { + state.started = Date.now() + state.timer = setTimeout(() => { + const error = new Error(`tool call exceeded its ${format(limitMs)} limit (${limit.source})`) + controller.abort(error) + if (state.fiber) Effect.runFork(Fiber.interrupt(state.fiber)) + expired.reject(error) + }, state.remaining) + } + const pause = () => { + state.holds++ + if (state.holds !== 1) return + clearTimeout(state.timer) + state.remaining = Math.max(0, state.remaining - (Date.now() - state.started)) + } + const resume = () => { + state.holds-- + if (state.holds === 0 && !controller.signal.aborted) arm() + } + // Armed before forking: the fiber may reach `hold` synchronously, and pausing must find the timer. + arm() + state.fiber = fork({ + signal: controller.signal, + hold: (effect) => + Effect.acquireUseRelease( + Effect.sync(pause), + () => effect, + () => Effect.sync(resume), + ), + }) + + return Promise.race([join(state.fiber), expired.promise]).finally(() => { + clearTimeout(state.timer) + parent?.removeEventListener("abort", forward) + }) +} + +export function format(ms: number) { + return ( + ( + [ + [Math.floor(ms / 3_600_000), "h"], + [Math.floor((ms % 3_600_000) / 60_000), "m"], + [Math.floor((ms % 60_000) / 1000), "s"], + [ms % 1000, "ms"], + ] as const + ) + .filter((part) => part[0] > 0) + .map((part) => `${part[0]}${part[1]}`) + .join("") || "0ms" + ) +} + +function fallback(input: { tool: string; config?: Readonly>; declared?: Limit }): Limit { + if (input.declared) return input.declared + if (UNLIMITED.has(input.tool)) return { ms: false, source: "built-in default" } + const configured = input.config?.default + if (FILE_TOOLS.has(input.tool) && (configured === undefined || configured === false || configured > FILE_TOOL_MS)) + return { + ms: FILE_TOOL_MS, + source: `built-in default for ${input.tool}, set tool_timeout.${input.tool} to change it`, + } + if (configured !== undefined) return { ms: configured, source: "config: tool_timeout.default" } + return { ms: DEFAULT_MS, source: "default" } +} + +export * as ToolTimeout from "./timeout" diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index e5e7802858ca..cd49c4264794 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -62,6 +62,10 @@ export interface Def< jsonSchema?: JSONSchema7 execute(args: Schema.Schema.Type, ctx: Context): Effect.Effect> formatValidationError?(error: unknown): string + /** Default time limit in ms (`false`: none) the tool declares for its calls; the user's `tool_timeout.` overrides it. */ + timeout?: number | false + /** The timeout this call asks for itself, which raises its time limit (see `ToolTimeout.resolve`). */ + callTimeout?(args: Record): number | undefined } export type DefWithoutID< Parameters extends Schema.Decoder = Schema.Decoder, diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index da6e0f8d036f..a730c5d294fb 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -8,6 +8,7 @@ import { EventV2Bridge } from "@/event-v2-bridge" import { expect } from "bun:test" import { Cause, Deferred, Duration, Effect, Exit, Fiber, Layer } from "effect" import path from "path" +import { closeSync, constants, openSync } from "fs" import { fileURLToPath } from "url" import { NamedError } from "@opencode-ai/core/util/error" import { Agent as AgentSvc } from "../../src/agent/agent" @@ -2468,3 +2469,118 @@ noLLMServer.instance( }), 30_000, ) + +const runOneTool = Effect.fn("test.runOneTool")(function* ( + config: (url: string) => Partial, + tool: string, + input: Record, +) { + const { llm } = yield* useServerConfig(config) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ + title: "Tool time limit", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + yield* prompt.prompt({ sessionID: chat.id, agent: "build", noReply: true, parts: [{ type: "text", text: "go" }] }) + yield* llm.tool(tool, input) + yield* llm.text("after the tool") + const result = yield* prompt.loop({ sessionID: chat.id }) + const msgs = yield* MessageV2.filterCompactedEffect(chat.id) + return { llm, result, part: toolPart(msgs.flatMap((msg) => msg.parts)) } +}) + +it.instance( + "a plugin tool that never finishes is ended at its declared limit and the turn continues", + () => + Effect.gen(function* () { + const { directory } = yield* TestInstance + yield* writeText( + path.join(directory, ".opencode", "tool", "hang.ts"), + [ + "export default {", + " description: 'never finishes',", + " args: {},", + " timeout: 300,", + " execute: () => new Promise(() => {}),", + "}", + "", + ].join("\n"), + ) + const { llm, result, part } = yield* runOneTool(providerCfg, "hang", {}) + + expect(part?.state.status).toBe("error") + if (part?.state.status === "error") + expect(part.state.error).toBe("tool call exceeded its 300ms limit (tool default)") + expect(yield* llm.calls).toBe(2) + expect(JSON.stringify((yield* llm.inputs)[1])).toContain("tool call exceeded its 300ms limit (tool default)") + expect(result.parts.some((item) => item.type === "text" && item.text === "after the tool")).toBe(true) + }), + 30_000, +) + +unix( + "bash's own timeout raises its limit above tool_timeout.default", + () => + Effect.gen(function* () { + const { part } = yield* runOneTool((url) => ({ ...providerCfg(url), tool_timeout: { default: 300 } }), "bash", { + command: "sleep 1; printf slept", + timeout: 5000, + description: "sleep", + }) + expect(part?.state.status).toBe("completed") + if (part?.state.status === "completed") expect(part.state.output).toContain("slept") + }), + { git: true }, + 30_000, +) + +unix( + "a read hung opening a FIFO ends at a lower tool_timeout.default and the turn continues", + () => + Effect.gen(function* () { + const { directory } = yield* TestInstance + const fifo = path.join(directory, "hang.fifo") + expect(Bun.spawnSync(["mkfifo", fifo]).exitCode).toBe(0) + // The blocked open holds a filesystem worker thread; opening the write end releases it. + yield* Effect.addFinalizer(() => + Effect.try({ + try: () => closeSync(openSync(fifo, constants.O_WRONLY | constants.O_NONBLOCK)), + catch: (cause) => cause, + }).pipe(Effect.ignore), + ) + const started = Date.now() + const { llm, result, part } = yield* runOneTool( + (url) => ({ ...providerCfg(url), tool_timeout: { default: 300 } }), + "read", + { filePath: fifo }, + ) + + expect(part?.state.status).toBe("error") + if (part?.state.status === "error") + expect(part.state.error).toBe("tool call exceeded its 300ms limit (config: tool_timeout.default)") + expect(Date.now() - started).toBeLessThan(10_000) + expect(yield* llm.calls).toBe(2) + expect(result.parts.some((item) => item.type === "text" && item.text === "after the tool")).toBe(true) + }), + 30_000, +) + +unix( + "tool_timeout.bash ends a bash call even when the call asks for longer", + () => + Effect.gen(function* () { + const started = Date.now() + const { part } = yield* runOneTool((url) => ({ ...providerCfg(url), tool_timeout: { bash: 300 } }), "bash", { + command: "sleep 20", + timeout: 60_000, + description: "sleep", + }) + expect(part?.state.status).toBe("error") + if (part?.state.status === "error") + expect(part.state.error).toBe("tool call exceeded its 300ms limit (config: tool_timeout.bash)") + expect(Date.now() - started).toBeLessThan(10_000) + }), + { git: true }, + 30_000, +) diff --git a/packages/opencode/test/session/tools.test.ts b/packages/opencode/test/session/tools.test.ts index f365f684e6c3..c3cf78221c6f 100644 --- a/packages/opencode/test/session/tools.test.ts +++ b/packages/opencode/test/session/tools.test.ts @@ -17,6 +17,8 @@ import { Plugin } from "@/plugin" import { RuntimeFlags } from "@/effect/runtime-flags" import { Effect, Layer, Schema } from "effect" import { testEffect } from "../lib/effect" +import { TestConfig } from "../fixture/config" +import { ConfigV1 } from "@opencode-ai/core/v1/config/config" const callID = "call-test" const sessionID = SessionID.make("ses_test") @@ -67,6 +69,7 @@ const layer = Layer.mergeAll( Layer.succeed(MCP.Service, fakeMcp()), Layer.succeed(Truncate.Service, fakeTruncate), RuntimeFlags.layer(), + TestConfig.layer(), Layer.succeed( ToolRegistry.Service, ToolRegistry.Service.of({ @@ -165,3 +168,157 @@ it.effect("preserves running tool start time across metadata updates", () => } }), ) + +function limitLayer(input: { + config?: Partial + tools?: Tool.Def[] + mcp?: Record + ask?: Permission.Interface["ask"] +}) { + return Layer.mergeAll( + Layer.succeed(Plugin.Service, fakePlugin), + Layer.succeed( + Permission.Service, + Permission.Service.of({ ...fakePermission, ask: input.ask ?? fakePermission.ask }), + ), + Layer.succeed( + MCP.Service, + MCP.Service.of({ + tools: () => Effect.succeed(input.mcp ?? {}), + clients: () => Effect.succeed({}), + } as Partial as MCP.Interface), + ), + Layer.succeed(Truncate.Service, fakeTruncate), + RuntimeFlags.layer(), + TestConfig.layer({ get: () => Effect.succeed(input.config ?? {}) }), + Layer.succeed( + ToolRegistry.Service, + ToolRegistry.Service.of({ + ids: () => Effect.succeed((input.tools ?? []).map((tool) => tool.id)), + all: () => Effect.succeed([]), + named: () => Effect.die("unused"), + tools: () => Effect.succeed(input.tools ?? []), + }), + ), + ) +} + +const limitProcessor = { + message: { + id: messageID, + sessionID, + role: "assistant", + parentID: MessageID.ascending(), + agent: "build", + mode: "build", + path: { cwd: "/tmp", root: "/tmp" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: ModelV2.ID.make("test-model"), + providerID: ProviderV2.ID.make("test"), + time: { created: 1 }, + } satisfies SessionV1.Assistant, + updateToolCall: () => Effect.die("unused"), + completeToolCall: () => Effect.void, +} satisfies Pick + +const callTool = Effect.fn("test.callTool")(function* (name: string, args: Record = {}) { + const tools = yield* SessionTools.resolve({ + agent, + model, + session: { id: sessionID, permission: [] } as unknown as Session.Info, + processor: limitProcessor, + bypassAgentCheck: false, + messages: [], + promptOps: {} as never, + }) + const execute = tools[name]?.execute + if (!execute) throw new Error(`${name} tool is missing execute`) + return yield* Effect.promise(() => + Promise.resolve( + execute(args, { toolCallId: callID, abortSignal: new AbortController().signal, messages: [] }), + ).then( + (value) => ({ ok: true as const, value }), + (error: unknown) => ({ ok: false as const, error: error instanceof Error ? error.message : String(error) }), + ), + ) +}) + +// A server whose tool never answers until the request is cancelled, the way a wedged worker behaves. +function hangingMcp(toolTimeout?: number | false) { + const seen = { signal: undefined as AbortSignal | undefined } + const tool: MCP.McpTool = { + def: { name: "wait", description: "never answers", inputSchema: { type: "object", properties: {} } }, + client: { + callTool: (_params: unknown, _schema: unknown, options: { signal?: AbortSignal }) => { + seen.signal = options.signal + return new Promise((_resolve, reject) => + options.signal?.addEventListener("abort", () => reject(options.signal?.reason)), + ) + }, + } as unknown as MCP.McpTool["client"], + server: "slow", + toolTimeout, + } + return { seen, mcp: { slow_wait: tool } } +} + +const mcpDeclared = hangingMcp(150) +testEffect(limitLayer({ mcp: mcpDeclared.mcp })).live("ends an MCP tool call at its server's tool_timeout", () => + Effect.gen(function* () { + const result = yield* callTool("slow_wait") + expect(result).toEqual({ ok: false, error: "tool call exceeded its 150ms limit (config: mcp.slow.tool_timeout)" }) + expect(mcpDeclared.seen.signal?.aborted).toBe(true) + }), +) + +const mcpOverridden = hangingMcp(false) +testEffect(limitLayer({ mcp: mcpOverridden.mcp, config: { tool_timeout: { slow_wait: 100 } } })).live( + "a user's tool_timeout entry beats an MCP server's tool_timeout", + () => + Effect.gen(function* () { + const result = yield* callTool("slow_wait") + expect(result).toEqual({ + ok: false, + error: "tool call exceeded its 100ms limit (config: tool_timeout.slow_wait)", + }) + }), +) + +const hangingPlugin: Tool.Def = { + id: "hang", + description: "never finishes", + parameters: Schema.Struct({}), + jsonSchema: { type: "object", properties: {} }, + timeout: 100, + execute: () => Effect.never, +} +testEffect(limitLayer({ tools: [hangingPlugin], config: { tool_timeout: { default: 60_000 } } })).live( + "ends a hanging tool at its declared limit, not the global one", + () => + Effect.gen(function* () { + const result = yield* callTool("hang") + expect(result).toEqual({ ok: false, error: "tool call exceeded its 100ms limit (tool default)" }) + }), +) + +const asking: Tool.Def = { + id: "asking", + description: "waits on a permission prompt", + parameters: Schema.Struct({}), + jsonSchema: { type: "object", properties: {} }, + timeout: 100, + execute: (_args, ctx) => + Effect.gen(function* () { + yield* ctx.ask({ permission: "asking", patterns: ["*"], always: ["*"], metadata: {} }) + return { title: "", metadata: {}, output: "approved" } + }), +} +testEffect(limitLayer({ tools: [asking], ask: () => Effect.promise(() => Bun.sleep(300)) })).live( + "time waiting on a permission prompt does not count toward the limit", + () => + Effect.gen(function* () { + const result = yield* callTool("asking") + expect(result.ok).toBe(true) + }), +) diff --git a/packages/opencode/test/tool/code-mode-integration.test.ts b/packages/opencode/test/tool/code-mode-integration.test.ts index 671acd896222..fa359071f0ca 100644 --- a/packages/opencode/test/tool/code-mode-integration.test.ts +++ b/packages/opencode/test/tool/code-mode-integration.test.ts @@ -135,7 +135,7 @@ async function buildTool() { const listed = (await client.listTools()).tools as MCPToolDef[] const mcpTools: Record = {} for (const def of listed) { - mcpTools[McpCatalog.toolName(SERVER, def.name)] = { def, client: client as unknown as Client } + mcpTools[McpCatalog.toolName(SERVER, def.name)] = { def, client: client as unknown as Client, server: SERVER } } const layer = Layer.mergeAll( diff --git a/packages/opencode/test/tool/code-mode.test.ts b/packages/opencode/test/tool/code-mode.test.ts index 34b3faa610d7..a4bec66288e9 100644 --- a/packages/opencode/test/tool/code-mode.test.ts +++ b/packages/opencode/test/tool/code-mode.test.ts @@ -34,6 +34,7 @@ function mcpTool( client: { callTool: async (params: { arguments?: Record }) => handler(params.arguments ?? {}), } as unknown as MCP.McpTool["client"], + server: "test", } } @@ -203,6 +204,7 @@ describe("code mode execute", () => { inputSchema: { type: "object", properties: { value: { type: "string" }, count: { type: "number" } } }, } as MCPToolDef, client: { callTool: async () => ({ content: [] }) } as unknown as MCP.McpTool["client"], + server: "alpha", } } tools["zeta_only_tool"] = mcpTool("only_tool", () => "", { diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index c8c5fac59559..1c90abb01348 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -73,6 +73,7 @@ const withCodeMode = testEffect( inputSchema: { type: "object", properties: { city: { type: "string" } }, required: ["city"] }, } as MCPToolDef, client: {} as MCP.McpTool["client"], + server: "weather", }, }), clients: () => Effect.succeed({ weather: {} as any }), diff --git a/packages/opencode/test/tool/timeout.test.ts b/packages/opencode/test/tool/timeout.test.ts new file mode 100644 index 000000000000..dc1a314bc15d --- /dev/null +++ b/packages/opencode/test/tool/timeout.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { ToolTimeout } from "@/tool/timeout" + +const plugin = (ms: number | false): ToolTimeout.Limit => ({ ms, source: "tool default" }) + +describe("ToolTimeout.resolve", () => { + test("falls back to 30 minutes", () => { + expect(ToolTimeout.resolve({ tool: "webfetch" })).toEqual({ ms: 30 * 60 * 1000, source: "default" }) + }) + + test("the configured default replaces 30 minutes, higher or lower", () => { + for (const ms of [60_000, 4 * 3_600_000, false] as const) + expect(ToolTimeout.resolve({ tool: "webfetch", config: { default: ms } })).toEqual({ + ms, + source: "config: tool_timeout.default", + }) + }) + + test("a declared tool default beats the global limit, higher or lower", () => { + for (const ms of [1000, 8 * 3_600_000, false] as const) + expect(ToolTimeout.resolve({ tool: "slow", config: { default: 60_000 }, declared: plugin(ms) })).toEqual( + plugin(ms), + ) + }) + + test("a user's per-tool setting beats a declared default, higher or lower", () => { + for (const ms of [500, 10 * 3_600_000, false] as const) + expect( + ToolTimeout.resolve({ tool: "slow", config: { default: 60_000, slow: ms }, declared: plugin(5000) }), + ).toEqual({ ms, source: "config: tool_timeout.slow" }) + }) + + test("question, task and call_omo_agent have no limit unless configured per tool", () => { + for (const tool of ["question", "task", "call_omo_agent"]) { + expect(ToolTimeout.resolve({ tool, config: { default: 60_000 } })).toEqual({ + ms: false, + source: "built-in default", + }) + expect(ToolTimeout.resolve({ tool, config: { [tool]: 3_600_000 } })).toEqual({ + ms: 3_600_000, + source: `config: tool_timeout.${tool}`, + }) + } + }) + + test("the built-in file tools default to 10 minutes, or a lower tool_timeout.default", () => { + for (const tool of ["read", "write", "edit", "grep", "glob"]) { + const builtIn = { + ms: ToolTimeout.FILE_TOOL_MS, + source: `built-in default for ${tool}, set tool_timeout.${tool} to change it`, + } + expect(ToolTimeout.FILE_TOOL_MS).toBe(10 * 60 * 1000) + expect(ToolTimeout.resolve({ tool })).toEqual(builtIn) + expect(ToolTimeout.resolve({ tool, config: { default: 3_600_000 } })).toEqual(builtIn) + expect(ToolTimeout.resolve({ tool, config: { default: false } })).toEqual(builtIn) + expect(ToolTimeout.resolve({ tool, config: { default: 60_000 } })).toEqual({ + ms: 60_000, + source: "config: tool_timeout.default", + }) + expect(ToolTimeout.resolve({ tool, config: { [tool]: 3_600_000 } })).toEqual({ + ms: 3_600_000, + source: `config: tool_timeout.${tool}`, + }) + } + expect(ToolTimeout.resolve({ tool: "read", declared: plugin(5000) })).toEqual(plugin(5000)) + }) + + test("a plugin's own declaration replaces a built-in exemption", () => { + expect(ToolTimeout.resolve({ tool: "call_omo_agent", declared: plugin(120_000) })).toEqual(plugin(120_000)) + }) + + test("a per-call timeout raises the limit to that timeout plus grace", () => { + expect(ToolTimeout.resolve({ tool: "bash", call: 2 * 3_600_000 })).toEqual({ + ms: 2 * 3_600_000 + ToolTimeout.CALL_GRACE_MS, + source: "the call's own timeout argument", + }) + expect(ToolTimeout.resolve({ tool: "bash", config: { default: 1000 }, call: 2000 }).ms).toBe( + 2000 + ToolTimeout.CALL_GRACE_MS, + ) + }) + + test("a per-call timeout never lowers the limit or lifts a user's per-tool setting", () => { + expect(ToolTimeout.resolve({ tool: "bash", call: 1000 })).toEqual({ ms: 30 * 60 * 1000, source: "default" }) + expect(ToolTimeout.resolve({ tool: "bash", config: { default: false }, call: 1000 }).ms).toBe(false) + expect(ToolTimeout.resolve({ tool: "bash", config: { bash: 5000 }, call: 3_600_000 })).toEqual({ + ms: 5000, + source: "config: tool_timeout.bash", + }) + }) + + test("only own config keys name tools", () => { + expect(ToolTimeout.resolve({ tool: "constructor", config: {} }).source).toBe("default") + }) +}) + +describe("ToolTimeout.format", () => { + test("reads like a duration", () => { + expect(ToolTimeout.format(30 * 60 * 1000)).toBe("30m") + expect(ToolTimeout.format(5_400_500)).toBe("1h30m500ms") + expect(ToolTimeout.format(250)).toBe("250ms") + }) +}) + +describe("ToolTimeout.run", () => { + test("ends a call that never finishes, aborting its signal and interrupting its fiber", async () => { + const seen = { signal: undefined as AbortSignal | undefined, interrupted: false } + const result = ToolTimeout.run({ ms: 50, source: "tool default" }, undefined, (call) => { + seen.signal = call.signal + return Effect.runFork(Effect.never.pipe(Effect.onInterrupt(() => Effect.sync(() => (seen.interrupted = true))))) + }) + await expect(result).rejects.toThrow("tool call exceeded its 50ms limit (tool default)") + expect(seen.signal?.aborted).toBe(true) + await Bun.sleep(10) + expect(seen.interrupted).toBe(true) + }) + + test("returns the result of a call that finishes in time", async () => { + const result = await ToolTimeout.run({ ms: 1000, source: "default" }, undefined, () => + Effect.runFork(Effect.succeed("ok")), + ) + expect(result).toBe("ok") + }) + + test("time spent in hold does not count", async () => { + const result = await ToolTimeout.run({ ms: 100, source: "default" }, undefined, (call) => + Effect.runFork(call.hold(Effect.promise(() => Bun.sleep(250))).pipe(Effect.as("answered"))), + ) + expect(result).toBe("answered") + }) + + test("forwards an abort of the turn without ending the call itself", async () => { + const parent = new AbortController() + const result = ToolTimeout.run({ ms: false, source: "default" }, parent.signal, (call) => + Effect.runFork( + Effect.callback((resume) => { + call.signal.addEventListener("abort", () => resume(Effect.succeed("stopped by the turn"))) + }), + ), + ) + parent.abort() + expect(await result).toBe("stopped by the turn") + }) +}) diff --git a/packages/plugin/src/tool.ts b/packages/plugin/src/tool.ts index 9c6daa34d04a..db5f8ad1c073 100644 --- a/packages/plugin/src/tool.ts +++ b/packages/plugin/src/tool.ts @@ -45,6 +45,14 @@ export type ToolResult = export function tool(input: { description: string args: Args + /** + * Default time limit for a call, in milliseconds, or `false` for none. + * A call still running past it is aborted through `context.abort` and ends + * with an error. When omitted, the global `tool_timeout.default` applies + * (30 minutes unless configured); a user's `tool_timeout.` overrides + * either. + */ + timeout?: number | false execute(args: z.infer>, context: ToolContext): Promise }) { return input diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 7c04dac47346..db857fdb1603 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1840,6 +1840,10 @@ export type McpLocalConfig = { } enabled?: boolean timeout?: number + /** + * Default time limit in ms for each call to this server's tools, or false for none. Replaces tool_timeout.default for these tools; a tool_timeout entry naming the tool replaces this. Unlike timeout, it is not reset by progress notifications. + */ + tool_timeout?: number | false } export type McpOAuthConfig = { @@ -1868,6 +1872,10 @@ export type McpRemoteConfig = { */ oauth?: McpOAuthConfig | false timeout?: number + /** + * Default time limit in ms for each call to this server's tools, or false for none. Replaces tool_timeout.default for these tools; a tool_timeout entry naming the tool replaces this. Unlike timeout, it is not reset by progress notifications. + */ + tool_timeout?: number | false } /** @@ -2014,6 +2022,9 @@ export type Config = { max_lines?: number max_bytes?: number } + tool_timeout?: { + [key: string]: number | false + } compaction?: { auto?: boolean prune?: boolean diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index c7263dabbc99..cb8be1a535b1 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -359,6 +359,35 @@ You can manage the tools an LLM can use through the `tools` option. --- +### Tool time limits + +Every tool call runs under a time limit. A call still running when its limit elapses is aborted, and it ends with an error the model sees, like `tool call exceeded its 30m limit (config: tool_timeout.default)`. The turn continues. Use `tool_timeout` to change the limits. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "tool_timeout": { + "default": 600000, + "webfetch": 60000, + "my_slow_tool": false + } +} +``` + +- `default` - Limit in milliseconds for every tool (default: `1800000`, 30 minutes). +- Any other key is a tool ID: a built-in, plugin or MCP tool, for example `bash` or `myserver_search`. It sets that tool's limit, higher or lower than every other setting. +- `false` means no limit. + +The most specific setting wins: + +1. A `tool_timeout` entry naming the tool. +2. The default the tool's provider declares: a [custom tool's `timeout`](/docs/custom-tools#time-limit), or an MCP server's [`tool_timeout`](/docs/mcp-servers). `question` and `task` have no limit by default. `read`, `write`, `edit`, `grep` and `glob` have 10 minutes, or `tool_timeout.default` when that is lower: a call that runs this long is hung on I/O, for example reading a named pipe nothing writes to. +3. `tool_timeout.default`, else 30 minutes. + +A tool that takes its own timeout per call, like `bash`, gets that timeout plus a minute of grace when it is longer than the limit from levels 2 or 3. A `tool_timeout` entry naming the tool still applies. Time spent waiting for a permission prompt does not count. + +--- + ### Models You can configure the providers and models you want to use in your OpenCode config through the `provider`, `model` and `small_model` options. diff --git a/packages/web/src/content/docs/custom-tools.mdx b/packages/web/src/content/docs/custom-tools.mdx index d89922b1f876..4a046123d986 100644 --- a/packages/web/src/content/docs/custom-tools.mdx +++ b/packages/web/src/content/docs/custom-tools.mdx @@ -157,6 +157,27 @@ Use `context.worktree` for the git worktree root. --- +### Time limit + +A call still running past its time limit is aborted through `context.abort` and ends with an error, so a tool that hangs cannot stall the session. The limit is [`tool_timeout.default`](/docs/config#tool-time-limits), 30 minutes unless configured. A tool can declare its own default in milliseconds with `timeout`, or `false` for no limit: + +```ts title=".opencode/tools/deploy.ts" {5} +import { tool } from "@opencode-ai/plugin" + +export default tool({ + description: "Deploy and wait for the rollout", + timeout: 2 * 60 * 60 * 1000, + args: {}, + async execute(args, context) { + return "deployed" + }, +}) +``` + +The declared default replaces `tool_timeout.default`, whether higher or lower. A user's `tool_timeout` entry naming the tool replaces both. + +--- + ## Examples ### Write a tool in Python diff --git a/packages/web/src/content/docs/mcp-servers.mdx b/packages/web/src/content/docs/mcp-servers.mdx index 215938ec3b11..f4e55f4c6126 100644 --- a/packages/web/src/content/docs/mcp-servers.mdx +++ b/packages/web/src/content/docs/mcp-servers.mdx @@ -116,14 +116,15 @@ use the mcp_everything tool to add the number 3 and 4 Here are all the options for configuring a local MCP server. -| Option | Type | Required | Description | -| ------------- | ------- | -------- | ---------------------------------------------------------------------------------------- | -| `type` | String | Y | Type of MCP server connection, must be `"local"`. | -| `command` | Array | Y | Command and arguments to run the MCP server. | -| `cwd` | String | | Working directory for the MCP server process. Relative paths resolve from the workspace. | -| `environment` | Object | | Environment variables to set when running the server. | -| `enabled` | Boolean | | Enable or disable the MCP server on startup. | -| `timeout` | Number | | Timeout in ms for fetching tools from the MCP server. Defaults to 5000 (5 seconds). | +| Option | Type | Required | Description | +| -------------- | ----------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `type` | String | Y | Type of MCP server connection, must be `"local"`. | +| `command` | Array | Y | Command and arguments to run the MCP server. | +| `cwd` | String | | Working directory for the MCP server process. Relative paths resolve from the workspace. | +| `environment` | Object | | Environment variables to set when running the server. | +| `enabled` | Boolean | | Enable or disable the MCP server on startup. | +| `timeout` | Number | | Timeout in ms for fetching tools from the MCP server. Defaults to 5000 (5 seconds). | +| `tool_timeout` | Number or `false` | | Time limit in ms for each call to this server's tools, or `false` for none. Replaces [`tool_timeout.default`](/docs/config#tool-time-limits); a `tool_timeout` entry naming the tool replaces this. | --- @@ -153,14 +154,15 @@ The `url` is the URL of the remote MCP server and with the `headers` option you #### Options -| Option | Type | Required | Description | -| --------- | ------- | -------- | ----------------------------------------------------------------------------------- | -| `type` | String | Y | Type of MCP server connection, must be `"remote"`. | -| `url` | String | Y | URL of the remote MCP server. | -| `enabled` | Boolean | | Enable or disable the MCP server on startup. | -| `headers` | Object | | Headers to send with the request. | -| `oauth` | Object | | OAuth authentication configuration. See [OAuth](#oauth) section below. | -| `timeout` | Number | | Timeout in ms for fetching tools from the MCP server. Defaults to 5000 (5 seconds). | +| Option | Type | Required | Description | +| -------------- | ----------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `type` | String | Y | Type of MCP server connection, must be `"remote"`. | +| `url` | String | Y | URL of the remote MCP server. | +| `enabled` | Boolean | | Enable or disable the MCP server on startup. | +| `headers` | Object | | Headers to send with the request. | +| `oauth` | Object | | OAuth authentication configuration. See [OAuth](#oauth) section below. | +| `timeout` | Number | | Timeout in ms for fetching tools from the MCP server. Defaults to 5000 (5 seconds). | +| `tool_timeout` | Number or `false` | | Time limit in ms for each call to this server's tools, or `false` for none. Replaces [`tool_timeout.default`](/docs/config#tool-time-limits); a `tool_timeout` entry naming the tool replaces this. | --- diff --git a/packages/web/src/content/docs/plugins.mdx b/packages/web/src/content/docs/plugins.mdx index a8be798217a8..751e1a803cb1 100644 --- a/packages/web/src/content/docs/plugins.mdx +++ b/packages/web/src/content/docs/plugins.mdx @@ -305,6 +305,7 @@ The `tool` helper creates a custom tool that opencode can call. It takes a Zod s - `description`: What the tool does - `args`: Zod schema for the tool's arguments - `execute`: Function that runs when the tool is called +- `timeout`: Optional default time limit for a call, in milliseconds, or `false` for none. See [time limit](/docs/custom-tools#time-limit). Your custom tools will be available to opencode alongside built-in tools. diff --git a/packages/web/src/content/docs/tools.mdx b/packages/web/src/content/docs/tools.mdx index 0f1085b053f4..7bced36f7449 100644 --- a/packages/web/src/content/docs/tools.mdx +++ b/packages/web/src/content/docs/tools.mdx @@ -37,6 +37,8 @@ You can also use wildcards to control multiple tools at once. For example, to re [Learn more](/docs/permissions) about configuring permissions. +Every tool call also runs under a time limit, 30 minutes by default; `read`, `write`, `edit`, `grep` and `glob` have 10 minutes, and `question` and `task` have none. A call that runs past its limit ends with an error and the turn continues. Use [`tool_timeout`](/docs/config#tool-time-limits) to change it per tool. + --- ## Built-in