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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/core/src/v1/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/v1/config/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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<typeof Local>

Expand Down Expand Up @@ -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<typeof Remote>

Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/src/config/v2-compat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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([
Expand Down
15 changes: 14 additions & 1 deletion packages/opencode/src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
77 changes: 55 additions & 22 deletions packages/opencode/src/session/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<string, unknown>, 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 = <A, E>(
options: ToolExecutionOptions,
limit: { tool: string; declared?: ToolTimeout.Limit; call?: number },
execute: (call: ToolTimeout.Call) => Effect.Effect<A, E>,
) =>
ToolTimeout.run(ToolTimeout.resolve({ ...limit, config: limits }), options.abortSignal, (call) =>
run.fork(execute(call)),
)

const context = (
args: Record<string, unknown>,
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 },
Expand All @@ -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({
Expand All @@ -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 },
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -395,18 +421,25 @@ 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 },
{ args },
)
const result: Awaited<ReturnType<NonNullable<typeof execute>>> = 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: {
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/tool/glob.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/tool/grep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions packages/opencode/src/tool/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -333,6 +336,8 @@ const layer = Layer.effect(
jsonSchema,
execute: tool.execute,
formatValidationError: tool.formatValidationError,
timeout: tool.timeout,
callTimeout: tool.callTimeout,
}
}),
{ concurrency: "unbounded" },
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/tool/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading