Skip to content
Closed
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
1 change: 1 addition & 0 deletions packages/opencode/src/effect/runtime-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,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: 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")),
Expand Down
26 changes: 24 additions & 2 deletions packages/opencode/src/tool/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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<typeof Parameters>,
Expand All @@ -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
Expand Down Expand Up @@ -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"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
118 changes: 117 additions & 1 deletion packages/opencode/test/tool/task.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -57,6 +57,7 @@ const layer = (flags: Partial<RuntimeFlags.Info> = {}) =>

const it = testEffect(layer())
const background = testEffect(layer({ experimentalBackgroundSubagents: true }))
const shortTimeout = testEffect(layer({ taskDefaultTimeoutMs: 50 }))

function defer<T>() {
let resolve!: (value: T | PromiseLike<T>) => void
Expand Down Expand Up @@ -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<SessionID>()

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
Expand Down
Loading