diff --git a/packages/opencode/src/effect/runtime-flags.ts b/packages/opencode/src/effect/runtime-flags.ts index 65e02f076360..e07c5b2155ad 100644 --- a/packages/opencode/src/effect/runtime-flags.ts +++ b/packages/opencode/src/effect/runtime-flags.ts @@ -7,6 +7,12 @@ const positiveInteger = (name: string) => Config.map((value) => (Number.isInteger(value) && value > 0 ? value : undefined)), Config.orElse(() => Config.succeed(undefined)), ) +// 0 is meaningful for timeouts (wait indefinitely), so they can't use positiveInteger. +const nonNegativeInteger = (name: string) => + Config.number(name).pipe( + Config.map((value) => (Number.isInteger(value) && value >= 0 ? value : undefined)), + Config.orElse(() => Config.succeed(undefined)), + ) const experimental = bool("OPENCODE_EXPERIMENTAL") const enabledByExperimental = (name: string) => Config.all({ experimental, enabled: Config.boolean(name).pipe(Config.option) }).pipe( @@ -51,6 +57,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: nonNegativeInteger("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..3eb57dad32f3 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -59,6 +59,10 @@ export const Parameters = Schema.Struct({ description: "Run the agent in the background. You will be notified when it completes. DO NOT sleep, poll, or proactively check on its progress", }), + timeout: Schema.optional(Schema.Number).annotate({ + description: + "Optional timeout in milliseconds for the foreground subagent. If the subagent does not complete within this duration it is cancelled and the tool returns a block so the calling agent can retry with a larger timeout, narrow the scope, or fall back. Set to 0 to wait indefinitely. Defaults to the value of OPENCODE_EXPERIMENTAL_TASK_DEFAULT_TIMEOUT_MS, or 10 minutes when unset.", + }), }) function renderOutput(input: { @@ -78,6 +82,8 @@ function renderOutput(input: { ].join("\n") } +const TASK_DEFAULT_TIMEOUT_MS = 10 * 60 * 1000 + export const TaskTool = Tool.define( id, Effect.gen(function* () { @@ -307,6 +313,15 @@ export const TaskTool = Tool.define( return backgroundResult() } + if (params.timeout !== undefined && params.timeout < 0) { + return yield* Effect.fail( + new Error( + `Invalid timeout value: ${params.timeout}. Timeout must be a non-negative number of milliseconds (0 waits indefinitely).`, + ), + ) + } + const taskTimeoutMs = params.timeout ?? flags.taskDefaultTimeoutMs ?? TASK_DEFAULT_TIMEOUT_MS + const runCancel = yield* EffectBridge.make() const cancel = ops.cancel(nextSession.id) @@ -320,18 +335,46 @@ export const TaskTool = Tool.define( }), () => Effect.gen(function* () { - const result = yield* Effect.raceFirst( - background.wait({ id: nextSession.id }).pipe(Effect.map((waited) => waited.info)), - background.waitForPromotion(nextSession.id), - ) - 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")) - return { - title: params.description, - metadata, - output: renderOutput({ sessionID: nextSession.id, state: "completed", text: result?.output ?? "" }), + const inner = Effect.gen(function* () { + const result = yield* Effect.raceFirst( + background.wait({ id: nextSession.id }).pipe(Effect.map((waited) => waited.info)), + background.waitForPromotion(nextSession.id), + ) + 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")) + return { + title: params.description, + metadata, + output: renderOutput({ sessionID: nextSession.id, state: "completed", text: result?.output ?? "" }), + } + }) + const raced = inner.pipe(Effect.map((output) => ({ kind: "result" as const, output }))) + // 0 means wait indefinitely: no timeout branch enters the race. + const outcome = + taskTimeoutMs === 0 + ? yield* raced + : yield* Effect.raceFirst( + raced, + Effect.sleep(`${taskTimeoutMs} millis`).pipe(Effect.as({ kind: "timeout" as const })), + ) + if (outcome.kind === "timeout") { + yield* Effect.all([cancel, background.cancel(nextSession.id)], { discard: true }) + return { + title: params.description, + metadata, + output: [ + `task_id: ${nextSession.id} (for resuming if the timeout was premature)`, + "", + "", + `Subagent "${params.subagent_type}" did not complete within ${taskTimeoutMs}ms and was cancelled.`, + `If this task legitimately needs more time, retry with a larger 'timeout' value (in milliseconds), set 'timeout' to 0 to wait indefinitely, or narrow the task scope.`, + `If the subagent appears stalled (provider or network hang), do not retry indefinitely; report what you have to the user.`, + "", + ].join("\n"), + } } + return outcome.output }), (_, exit) => Effect.gen(function* () { diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index 2bcf05a2a49b..757e1de38fa5 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -982,4 +982,113 @@ describe("tool.task", () => { expect((yield* jobs.get(grandchild.id))?.status).toBe("cancelled") }), ) + + it.instance("execute returns task_error and cancels the subagent when the timeout fires", () => + Effect.gen(function* () { + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + const cancelled = defer() + const promptOps: TaskPromptOps = { + cancel: (sessionID) => + Effect.sync(() => { + cancelled.resolve(sessionID) + }), + resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]), + prompt: () => Effect.never, + } + + const result = yield* def.execute( + { + description: "stalls forever", + prompt: "hang please", + subagent_type: "general", + timeout: 50, + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + const cancelledId = yield* Effect.promise(() => cancelled.promise) + expect(result.output).toContain("") + expect(result.output).toContain("did not complete within 50ms") + expect(result.output).toContain(cancelledId) + expect(result.output).not.toContain("") + }), + ) + + it.instance("execute fails when given a negative timeout", () => + Effect.gen(function* () { + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + + const exit = yield* def + .execute( + { + description: "bad timeout", + prompt: "irrelevant", + 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) + }), + ) + + it.instance("timeout 0 waits indefinitely for completion", () => + Effect.gen(function* () { + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + const ops = stubOps() + const promptOps: TaskPromptOps = { + ...ops, + prompt: (input) => + Effect.sleep("100 millis").pipe(Effect.flatMap(() => Effect.sync(() => reply(input, "slow but done")))), + } + + const result = yield* def.execute( + { + description: "waits past any would-be timeout", + prompt: "finish normally", + subagent_type: "general", + timeout: 0, + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + expect(result.output).not.toContain("") + expect(result.output).toContain("") + }), + ) })