import * as Tool from "./tool" import DESCRIPTION from "./task.txt" import { ToolJsonSchema } from "./json-schema" import { SessionLegacy } from "@opencode-ai/core/session/legacy" import { BackgroundJob } from "@/background/job" 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 { Config } from "@/config/config" import { Cause, Effect, Exit, Schema, Scope } from "effect" import { EffectBridge } from "@/effect/bridge" import { RuntimeFlags } from "@/effect/runtime-flags" import { Database } from "@opencode-ai/core/database/database" export interface TaskPromptOps { cancel(sessionID: SessionID): Effect.Effect resolvePromptParts(template: string): Effect.Effect prompt(input: SessionPrompt.PromptInput): Effect.Effect } const id = "task" const BACKGROUND_DESCRIPTION = [ "", "", [ "Background mode: background=true launches the subagent asynchronously and returns immediately.", "Foreground is the default; use it when you need the result before continuing.", "Use background only for independent work that can run while you continue elsewhere.", "You will be notified automatically when it finishes.", ].join(" "), ].join("\n") const BaseParameterFields = { 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" }), } const BaseParameters = Schema.Struct(BaseParameterFields) export const Parameters = Schema.Struct({ ...BaseParameterFields, background: Schema.optional(Schema.Boolean).annotate({ description: "Run the agent in the background. You will be notified when it completes.", }), }) function output(sessionID: SessionID, text: string) { return [``, "", text, "", ""].join("\n") } function backgroundOutput(sessionID: SessionID) { return [ ``, "Background task started", "", "Background task started. You will be notified automatically when it finishes; do not poll for progress.", "Do not duplicate its work. Continue only with non-overlapping work, or stop if there is nothing else useful to do.", "", "", ].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}`, `<${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 config = yield* Config.Service const sessions = yield* Session.Service const scope = yield* Scope.Scope const flags = yield* RuntimeFlags.Service const database = yield* Database.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 session = params.task_id ? yield* sessions.get(SessionID.make(params.task_id)).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.provideService(Database.Service, database), 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 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 inject = Effect.fn("TaskTool.injectBackgroundResult")(function* ( state: "completed" | "error", text: string, ) { const currentParent = yield* sessions.get(ctx.sessionID) yield* ops .prompt({ sessionID: ctx.sessionID, agent: currentParent.agent ?? ctx.agent, parts: [ { type: "text", synthetic: true, text: backgroundMessage({ sessionID: nextSession.id, description: params.description, state, text, }), }, ], }) .pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true })) }) const existing = yield* background.get(nextSession.id) if (existing?.status === "running") { return yield* Effect.fail(new Error(`Task ${nextSession.id} is already running.`)) } 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 runCancel = yield* EffectBridge.make() 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), } }), )