import * as Tool from "./tool" import DESCRIPTION from "./task.txt" import { ToolJsonSchema } from "./json-schema" import { BackgroundJob } from "@/background/job" import { Bus } from "@/bus" import { Session } from "@/session/session" import { SessionID, MessageID } from "../session/schema" import { MessageV2 } from "../session/message-v2" import { Agent } from "../agent/agent" import { deriveSubagentSessionPermission } from "../agent/subagent-permissions" import type { SessionPrompt } from "../session/prompt" import { SessionStatus } from "@/session/status" import { Config } from "@/config/config" import { TuiEvent } from "@/cli/cmd/tui/event" import { Cause, Effect, Exit, Option, Schema, Scope } from "effect" import { EffectBridge } from "@/effect/bridge" import { RuntimeFlags } from "@/effect/runtime-flags" export interface TaskPromptOps { cancel(sessionID: SessionID): Effect.Effect resolvePromptParts(template: string): Effect.Effect prompt(input: SessionPrompt.PromptInput): Effect.Effect loop(input: SessionPrompt.LoopInput): Effect.Effect } const id = "task" const BACKGROUND_DESCRIPTION = [ "", "", [ "Background mode: background=true launches the subagent asynchronously.", "Use task_status(task_id=..., wait=false) to poll, or wait=true to block until done.", ].join(" "), ].join("\n") const BaseParameters = Schema.Struct({ description: Schema.String.annotate({ description: "A short (3-5 words) description of the task" }), prompt: Schema.String.annotate({ description: "The task for the agent to perform" }), subagent_type: Schema.String.annotate({ description: "The type of specialized agent to use for this task" }), task_id: Schema.optional(Schema.String).annotate({ 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)", }), command: Schema.optional(Schema.String).annotate({ description: "The command that triggered this task" }), }) export const Parameters = Schema.Struct({ description: Schema.String.annotate({ description: "A short (3-5 words) description of the task" }), prompt: Schema.String.annotate({ description: "The task for the agent to perform" }), subagent_type: Schema.String.annotate({ description: "The type of specialized agent to use for this task" }), task_id: Schema.optional(Schema.String).annotate({ 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)", }), command: Schema.optional(Schema.String).annotate({ description: "The command that triggered this task" }), background: Schema.optional(Schema.Boolean).annotate({ description: "When true, launch the subagent in the background and return immediately", }), }) function output(sessionID: SessionID, text: string) { return [ `task_id: ${sessionID} (for resuming to continue this task if needed)`, "", "", text, "", ].join("\n") } function backgroundOutput(sessionID: SessionID) { return [ `task_id: ${sessionID} (for polling this task with task_status)`, "state: running", "", "", "Background task started. Continue your current work and call task_status when you need the result.", "", ].join("\n") } function backgroundMessage(input: { sessionID: SessionID description: string state: "completed" | "error" text: string }) { const tag = input.state === "completed" ? "task_result" : "task_error" const title = input.state === "completed" ? `Background task completed: ${input.description}` : `Background task failed: ${input.description}` return [title, `task_id: ${input.sessionID}`, `state: ${input.state}`, "", `<${tag}>`, input.text, ``].join( "\n", ) } function errorText(error: unknown) { if (error instanceof Error) return error.message return String(error) } export const TaskTool = Tool.define( id, Effect.gen(function* () { const agent = yield* Agent.Service const background = yield* BackgroundJob.Service const bus = yield* Bus.Service const config = yield* Config.Service const sessions = yield* Session.Service const scope = yield* Scope.Scope const status = yield* SessionStatus.Service const flags = yield* RuntimeFlags.Service const run = Effect.fn("TaskTool.execute")(function* ( params: Schema.Schema.Type, ctx: Tool.Context, ) { const cfg = yield* config.get() const runInBackground = params.background === true if (runInBackground && !flags.experimentalBackgroundSubagents) { return yield* Effect.fail( new Error("Background subagents require OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true"), ) } if (!ctx.extra?.bypassAgentCheck) { yield* ctx.ask({ permission: id, patterns: [params.subagent_type], always: ["*"], metadata: { description: params.description, subagent_type: params.subagent_type, }, }) } const next = yield* agent.get(params.subagent_type) if (!next) { return yield* Effect.fail(new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`)) } const taskID = params.task_id const session = taskID ? yield* sessions.get(SessionID.make(taskID)).pipe(Effect.catchCause(() => Effect.succeed(undefined))) : undefined const parent = yield* sessions.get(ctx.sessionID) const parentAgent = parent.agent ? yield* agent.get(parent.agent).pipe(Effect.catchCause(() => Effect.succeed(undefined))) : undefined const nextSession = session ?? (yield* sessions.create({ parentID: ctx.sessionID, title: params.description + ` (@${next.name} subagent)`, permission: [ ...deriveSubagentSessionPermission({ parentSessionPermission: parent.permission ?? [], parentAgent, subagent: next, }), ...(cfg.experimental?.primary_tools?.map((item) => ({ pattern: "*", action: "allow" as const, permission: item, })) ?? []), ], })) const msg = yield* MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID }).pipe(Effect.orDie) if (msg.info.role !== "assistant") return yield* Effect.fail(new Error("Not an assistant message")) const model = next.model ?? { modelID: msg.info.modelID, providerID: msg.info.providerID, } const metadata = { parentSessionId: ctx.sessionID, sessionId: nextSession.id, model, ...(runInBackground ? { background: true } : {}), } yield* ctx.metadata({ title: params.description, metadata, }) const ops = ctx.extra?.promptOps as TaskPromptOps if (!ops) return yield* Effect.fail(new Error("TaskTool requires promptOps in ctx.extra")) const runCancel = yield* EffectBridge.make() const runTask = Effect.fn("TaskTool.runTask")(function* () { const parts = yield* ops.resolvePromptParts(params.prompt) const result = yield* ops.prompt({ messageID: MessageID.ascending(), sessionID: nextSession.id, model: { modelID: model.modelID, providerID: model.providerID, }, agent: next.name, tools: { ...(next.permission.some((rule) => rule.permission === "todowrite") ? {} : { todowrite: false }), ...(next.permission.some((rule) => rule.permission === id) ? {} : { task: false }), ...Object.fromEntries((cfg.experimental?.primary_tools ?? []).map((item) => [item, false])), }, parts, }) return result.parts.findLast((item) => item.type === "text")?.text ?? "" }) const resumeWhenIdle: (input: { userID: MessageID; state: "completed" | "error" }) => Effect.Effect = Effect.fn("TaskTool.resumeWhenIdle")(function* (input: { userID: MessageID; state: "completed" | "error" }) { const latest = yield* sessions .findMessage(ctx.sessionID, (item) => item.info.role === "user") .pipe(Effect.orDie) if (Option.isNone(latest)) return if (latest.value.info.id !== input.userID) return if ((yield* status.get(ctx.sessionID)).type !== "idle") { yield* Effect.sleep("300 millis") return yield* resumeWhenIdle(input) } yield* bus.publish(TuiEvent.ToastShow, { title: input.state === "completed" ? "Background task complete" : "Background task failed", message: input.state === "completed" ? `Background task "${params.description}" finished. Resuming the main thread.` : `Background task "${params.description}" failed. Resuming the main thread.`, variant: input.state === "completed" ? "success" : "error", duration: 5000, }) yield* ops .loop({ sessionID: ctx.sessionID }) .pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true })) }) const continueIfIdle = Effect.fn("TaskTool.continueIfIdle")(function* (input: { userID: MessageID state: "completed" | "error" }) { yield* resumeWhenIdle(input).pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true })) }) const inject = Effect.fn("TaskTool.injectBackgroundResult")(function* ( state: "completed" | "error", text: string, ) { const currentParent = yield* sessions.get(ctx.sessionID) const message = yield* ops.prompt({ sessionID: ctx.sessionID, noReply: true, agent: currentParent.agent ?? ctx.agent, parts: [ { type: "text", synthetic: true, text: backgroundMessage({ sessionID: nextSession.id, description: params.description, state, text, }), }, ], }) yield* continueIfIdle({ userID: message.info.id, state }) }) const existing = yield* background.get(nextSession.id) if (existing?.status === "running") { return yield* Effect.fail( new Error(`Task ${nextSession.id} is already running. Use task_status to check progress.`), ) } if (runInBackground) { const info = yield* background.start({ id: nextSession.id, type: id, title: params.description, metadata, run: runTask().pipe( Effect.tap((text) => inject("completed", text).pipe(Effect.ignore)), Effect.catchCause((cause) => (Cause.hasInterruptsOnly(cause) ? Effect.void : inject("error", errorText(Cause.squash(cause))).pipe(Effect.ignore) ).pipe(Effect.andThen(Effect.failCause(cause))), ), ), }) return { title: params.description, metadata: { ...metadata, jobId: info.id, }, output: backgroundOutput(nextSession.id), } } const cancel = ops.cancel(nextSession.id) function onAbort() { runCancel.fork(cancel) } return yield* Effect.acquireUseRelease( Effect.sync(() => { ctx.abort.addEventListener("abort", onAbort) }), () => Effect.gen(function* () { const text = yield* runTask() return { title: params.description, metadata, output: output(nextSession.id, text), } }), (_, exit) => Effect.gen(function* () { if (Exit.hasInterrupts(exit)) yield* cancel }).pipe( Effect.ensuring( Effect.sync(() => { ctx.abort.removeEventListener("abort", onAbort) }), ), ), ) }) return { description: flags.experimentalBackgroundSubagents ? DESCRIPTION + BACKGROUND_DESCRIPTION : DESCRIPTION, parameters: Parameters, jsonSchema: flags.experimentalBackgroundSubagents ? undefined : ToolJsonSchema.fromSchema(BaseParameters), execute: (params: Schema.Schema.Type, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie), } }), )