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
7 changes: 7 additions & 0 deletions packages/opencode/src/effect/runtime-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -51,6 +57,7 @@ export class Service extends ConfigService.Service<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")),
Expand Down
65 changes: 54 additions & 11 deletions packages/opencode/src/tool/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <task_error> 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: {
Expand All @@ -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* () {
Expand Down Expand Up @@ -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)

Expand All @@ -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)`,
"",
"<task_error>",
`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.`,
"</task_error>",
].join("\n"),
}
}
return outcome.output
}),
(_, exit) =>
Effect.gen(function* () {
Expand Down
109 changes: 109 additions & 0 deletions packages/opencode/test/tool/task.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SessionID>()
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("<task_error>")
expect(result.output).toContain("did not complete within 50ms")
expect(result.output).toContain(cancelledId)
expect(result.output).not.toContain("<task_result>")
}),
)

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("<task_error>")
expect(result.output).toContain("<task_result>")
}),
)
})
Loading