diff --git a/packages/opencode/src/effect/runtime-flags.ts b/packages/opencode/src/effect/runtime-flags.ts index 65e02f076360..fbada71b349f 100644 --- a/packages/opencode/src/effect/runtime-flags.ts +++ b/packages/opencode/src/effect/runtime-flags.ts @@ -51,6 +51,7 @@ export class Service extends ConfigService.Service()("@opencode/Runtime experimentalIconDiscovery: enabledByExperimental("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY"), outputTokenMax: positiveInteger("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"), bashDefaultTimeoutMs: positiveInteger("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"), + taskDefaultTimeoutMs: positiveInteger("OPENCODE_EXPERIMENTAL_TASK_DEFAULT_TIMEOUT_MS"), experimentalNativeLlm: bool("OPENCODE_EXPERIMENTAL_NATIVE_LLM"), experimentalWebSockets: bool("OPENCODE_EXPERIMENTAL_WEBSOCKETS"), client: Config.string("OPENCODE_CLIENT").pipe(Config.withDefault("cli")), diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 1384e5d19725..ac8e3cb72164 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -10,6 +10,7 @@ import { Agent } from "../agent/agent" import { deriveSubagentSessionPermission } from "../agent/subagent-permissions" import type { SessionPrompt } from "../session/prompt" import { Config } from "@/config/config" +import { PositiveInt } from "@opencode-ai/core/schema" import { Effect, Exit, Schema, Scope } from "effect" import { EffectBridge } from "@/effect/bridge" import { RuntimeFlags } from "@/effect/runtime-flags" @@ -49,6 +50,7 @@ const BaseParameterFields = { "This should only be set if you mean to resume a previous task (you can pass a prior task_id and the task will continue the same subagent session as before instead of creating a fresh one)", }), command: Schema.optional(Schema.String).annotate({ description: "The command that triggered this task" }), + timeout: Schema.optional(PositiveInt).annotate({ description: "Optional timeout in milliseconds" }), } const BaseParameters = Schema.Struct(BaseParameterFields) @@ -88,6 +90,7 @@ export const TaskTool = Tool.define( const scope = yield* Scope.Scope const flags = yield* RuntimeFlags.Service const database = yield* Database.Service + const defaultTimeoutMs = flags.taskDefaultTimeoutMs ?? 5 * 60 * 1000 const run = Effect.fn("TaskTool.execute")(function* ( params: Schema.Schema.Type, @@ -100,6 +103,12 @@ export const TaskTool = Tool.define( new Error("Background subagents require OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true"), ) } + if (params.timeout !== undefined && params.timeout < 0) { + return yield* Effect.fail( + new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`), + ) + } + const timeoutMs = params.timeout ?? defaultTimeoutMs const parent = yield* sessions.get(ctx.sessionID) let current = parent @@ -320,10 +329,23 @@ export const TaskTool = Tool.define( }), () => Effect.gen(function* () { - const result = yield* Effect.raceFirst( + const waited = Effect.raceFirst( background.wait({ id: nextSession.id }).pipe(Effect.map((waited) => waited.info)), background.waitForPromotion(nextSession.id), - ) + ).pipe(Effect.map((result) => ({ kind: "done" as const, result }))) + const timedOut = Effect.sleep(`${timeoutMs} millis`).pipe(Effect.map(() => ({ kind: "timeout" as const }))) + + const raced = yield* Effect.raceAll([waited, timedOut]) + if (raced.kind === "timeout") { + yield* Effect.all([cancel, background.cancel(nextSession.id)], { discard: true }) + return yield* Effect.fail( + new Error( + `task tool terminated subagent after exceeding timeout ${timeoutMs} ms. If this task is expected to take longer, retry with a larger timeout value in milliseconds.`, + ), + ) + } + + const result = raced.result if (result?.metadata?.background === true) return backgroundResult() if (result?.status === "error") return yield* Effect.fail(new Error(result.error ?? "Task failed")) if (result?.status === "cancelled") return yield* Effect.fail(new Error("Task cancelled")) diff --git a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap index 51ff867ea44d..1fe26abf91a6 100644 --- a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap +++ b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap @@ -325,6 +325,13 @@ exports[`tool parameters JSON Schema (wire shape) task 1`] = ` "description": "This should only be set if you mean to resume a previous task (you can pass a prior task_id and the task will continue the same subagent session as before instead of creating a fresh one)", "type": "string", }, + "timeout": { + "description": "Optional timeout in milliseconds", + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer", + }, }, "required": [ "description", diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index 2bcf05a2a49b..e65bccd65508 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -3,7 +3,7 @@ import { SessionV1 } from "@opencode-ai/core/v1/session" import { Database } from "@opencode-ai/core/database/database" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { SessionProjector } from "@opencode-ai/core/session/projector" -import { Deferred, Effect, Exit, Fiber, Layer } from "effect" +import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect" import { Agent } from "../../src/agent/agent" import { BackgroundJob } from "@/background/job" import { EventV2Bridge } from "@/event-v2-bridge" @@ -57,6 +57,7 @@ const layer = (flags: Partial = {}) => const it = testEffect(layer()) const background = testEffect(layer({ experimentalBackgroundSubagents: true })) +const shortTimeout = testEffect(layer({ taskDefaultTimeoutMs: 50 })) function defer() { let resolve!: (value: T | PromiseLike) => void @@ -351,6 +352,121 @@ describe("tool.task", () => { }), ) + it.instance("execute terminates the task and cancels the child session on timeout", () => + Effect.gen(function* () { + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + const cancelled = defer() + + const exit = yield* def + .execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + timeout: 50, + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { + promptOps: { + ...stubOps(), + cancel: (sessionID) => Effect.sync(() => cancelled.resolve(sessionID)), + prompt: () => Effect.never, + } satisfies TaskPromptOps, + }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + .pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + const err = Cause.squash(exit.cause) + expect(String(err)).toContain("task tool terminated subagent after exceeding timeout 50 ms") + expect(String(err)).toContain("retry with a larger timeout value in milliseconds") + } + + expect(yield* Effect.promise(() => cancelled.promise)).toBeTruthy() + }), + ) + + it.instance("rejects a negative timeout value", () => + Effect.gen(function* () { + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + + const exit = yield* def + .execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + timeout: -1, + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps: stubOps() }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + .pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + }), + ) + + shortTimeout.instance("uses RuntimeFlags taskDefaultTimeoutMs when timeout is omitted", () => + Effect.gen(function* () { + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + + const exit = yield* def + .execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { + promptOps: { + ...stubOps(), + prompt: () => Effect.never, + } satisfies TaskPromptOps, + }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + .pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + const err = Cause.squash(exit.cause) + expect(String(err)).toContain("exceeding timeout 50 ms") + } + }), + ) + it.instance("execute creates a child when task_id does not exist", () => Effect.gen(function* () { const sessions = yield* Session.Service