diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index a2be3733f85..d2ab56d00d3 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -12,6 +12,7 @@ import { Log } from "@/util/log" import { ShareNext } from "@/share/share-next" import { Snapshot } from "../snapshot" import { Truncate } from "../tool/truncation" +import { SessionBackground } from "../session/background" export async function InstanceBootstrap() { Log.Default.info("bootstrapping", { directory: Instance.directory }) @@ -24,6 +25,7 @@ export async function InstanceBootstrap() { Vcs.init() Snapshot.init() Truncate.init() + SessionBackground.init() Bus.subscribe(Command.Event.Executed, async (payload) => { if (payload.properties.name === Command.Default.INIT) { diff --git a/packages/opencode/src/session/background.ts b/packages/opencode/src/session/background.ts new file mode 100644 index 00000000000..201fa70bb5e --- /dev/null +++ b/packages/opencode/src/session/background.ts @@ -0,0 +1,99 @@ +import { Bus } from "@/bus" +import { Session } from "./index" +import { SessionStatus } from "./status" +import { Instance } from "@/project/instance" +import { Log } from "@/util/log" +import { Identifier } from "@/id/id" +import { SessionPrompt } from "./prompt" + +export namespace SessionBackground { + const log = Log.create({ service: "session.background" }) + + const state = Instance.state( + () => { + const wakeable = new Set() + const unsubscribes = [ + Bus.subscribe(Session.Event.BackgroundTaskCompleted, async (event) => { + const sessionID = event.properties.sessionID + const task = event.properties.task + + await updateTaskMessage(sessionID, task).then(() => { + state().wakeable.add(sessionID) + }).catch((err) => { + log.error("failed to update background task session message", { sessionID, error: err }) + }) + await wake(sessionID).catch((err) => { + log.error("failed to wake for finished tasks", { sessionID, error: err }) + }) + }), + Bus.subscribe(SessionStatus.Event.Status, async (event) => { + const sessionID = event.properties.sessionID + + await wake(sessionID).catch((err) => { + log.error("failed to wake for finished tasks", { sessionID, error: err }) + }) + }), + ] + return { wakeable, unsubscribes } + }, + async (current) => { + for (const unsubscribe of current.unsubscribes) { + unsubscribe() + } + }, + ) + + export function init() { + return state() + } + + async function wake(sessionID: string) { + const session = await Session.get(sessionID).catch(() => { + log.warn("session not found, skipping wake", { sessionID }) + return + }) + if (!session) { + return + } + + const s = state() + if (!s.wakeable.has(sessionID)) { + return + } + + const status = SessionStatus.get(sessionID) + if (status.type !== "idle") { + return + } + + s.wakeable.delete(sessionID) + SessionStatus.set(sessionID, { type: "busy" }) + await SessionPrompt.loop({ sessionID }) + } + + async function updateTaskMessage(sessionID: string, task: Session.BackgroundTask) { + const msgID = Identifier.ascending("message") + const output = [`Session ID: ${task.sessionID}`, "", "", task.result, ""].join("\n") + const text = + task.status === "success" + ? `Background task '${task.description}' completed.\n${output}` + : `Background task '${task.description}' failed: ${task.result}` + + await Session.updateMessage({ + id: msgID, + sessionID, + role: "user", + time: { created: Date.now() }, + agent: task.agent, + model: task.model, + }) + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: msgID, + sessionID, + type: "text", + synthetic: true, + text, + }) + } +} diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index b117632051f..6c6811893c8 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -178,6 +178,19 @@ export namespace Session { }) export type GlobalInfo = z.output + export const BackgroundTask = z.object({ + sessionID: z.string(), + result: z.string(), + status: z.enum(["success", "error"]), + description: z.string(), + agent: z.string(), + model: z.object({ + providerID: z.string(), + modelID: z.string(), + }), + }) + export type BackgroundTask = z.output + export const Event = { Created: BusEvent.define( "session.created", @@ -211,6 +224,13 @@ export namespace Session { error: MessageV2.Assistant.shape.error, }), ), + BackgroundTaskCompleted: BusEvent.define( + "session.background_task_completed", + z.object({ + sessionID: z.string(), + task: BackgroundTask, + }), + ), } export const create = fn( @@ -661,6 +681,7 @@ export namespace Session { for (const child of await children(sessionID)) { await remove(child.id) } + SessionPrompt.cancel(sessionID) await unshare(sessionID).catch(() => {}) // CASCADE delete handles messages and parts automatically Database.use((db) => { diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 5b4e7bdbc04..d4e41730808 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -218,6 +218,7 @@ export namespace MessageV2 { }) .optional(), command: z.string().optional(), + background: z.boolean().optional(), }).meta({ ref: "SubtaskPart", }) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 4f77920cc98..06b4e181ae1 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -402,6 +402,7 @@ export namespace SessionPrompt { description: task.description, subagent_type: task.agent, command: task.command, + background: task.background, } await Plugin.trigger( "tool.execute.before", diff --git a/packages/opencode/src/session/status.ts b/packages/opencode/src/session/status.ts index 1db03b5db0d..d5af2f8b18b 100644 --- a/packages/opencode/src/session/status.ts +++ b/packages/opencode/src/session/status.ts @@ -59,6 +59,7 @@ export namespace SessionStatus { } export function set(sessionID: string, status: Info) { + state()[sessionID] = status Bus.publish(Event.Status, { sessionID, status, @@ -69,8 +70,6 @@ export namespace SessionStatus { sessionID, }) delete state()[sessionID] - return } - state()[sessionID] = status } } diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 8c8cf827aba..616ed0b6b93 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -10,6 +10,7 @@ import { iife } from "@/util/iife" import { defer } from "@/util/defer" import { Config } from "../config/config" import { PermissionNext } from "@/permission/next" +import { Bus } from "@/bus" const parameters = z.object({ description: z.string().describe("A short (3-5 words) description of the task"), @@ -22,6 +23,13 @@ const parameters = z.object({ ) .optional(), command: z.string().describe("The command that triggered this task").optional(), + background: z + .boolean() + .default(false) + .describe( + "If true, the task runs in the background and the main agent can continue working. Results will be reported back when done.", + ) + .optional(), }) export const TaskTool = Tool.define("task", async (ctx) => { @@ -117,21 +125,11 @@ export const TaskTool = Tool.define("task", async (ctx) => { }) const messageID = Identifier.ascending("message") - - function cancel() { - SessionPrompt.cancel(session.id) - } - ctx.abort.addEventListener("abort", cancel) - using _ = defer(() => ctx.abort.removeEventListener("abort", cancel)) const promptParts = await SessionPrompt.resolvePromptParts(params.prompt) - - const result = await SessionPrompt.prompt({ + const promptInput = { messageID, sessionID: session.id, - model: { - modelID: model.modelID, - providerID: model.providerID, - }, + model, agent: agent.name, tools: { todowrite: false, @@ -140,7 +138,55 @@ export const TaskTool = Tool.define("task", async (ctx) => { ...Object.fromEntries((config.experimental?.primary_tools ?? []).map((t) => [t, false])), }, parts: promptParts, - }) + } + + if (params.background) { + iife(async () => { + const result = await SessionPrompt.prompt(promptInput) + const text = result.parts.findLast((x) => x.type === "text")?.text ?? "" + + Bus.publish(Session.Event.BackgroundTaskCompleted, { + sessionID: ctx.sessionID, + task: { + sessionID: session.id, + result: text, + status: "success", + description: params.description, + agent: agent.name, + model, + }, + }) + }).catch((err) => { + Bus.publish(Session.Event.BackgroundTaskCompleted, { + sessionID: ctx.sessionID, + task: { + sessionID: session.id, + result: String(err), + status: "error", + description: params.description, + agent: agent.name, + model, + }, + }) + }) + + return { + title: params.description, + metadata: { + sessionId: session.id, + model, + }, + output: `Background task started.\nSession ID: ${session.id}\n\nYou will be notified when it completes.`, + } + } + + function cancel() { + SessionPrompt.cancel(session.id) + } + ctx.abort.addEventListener("abort", cancel) + using _ = defer(() => ctx.abort.removeEventListener("abort", cancel)) + + const result = await SessionPrompt.prompt(promptInput) const text = result.parts.findLast((x) => x.type === "text")?.text ?? "" diff --git a/packages/opencode/src/tool/task.txt b/packages/opencode/src/tool/task.txt index 585cce8f9d0..73db6623514 100644 --- a/packages/opencode/src/tool/task.txt +++ b/packages/opencode/src/tool/task.txt @@ -23,6 +23,11 @@ Usage notes: 5. Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.), since it is not aware of the user's intent. Tell it how to verify its work if possible (e.g., relevant test commands). 6. If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement. +Background tasks: +- You can set background=true to run a task asynchronously if it will take a long time, or if you are requested to do so. +- Background tasks wake you upon completion, so you should wait passively and idly for output of background tasks. +- It is forbidden to track completion of tasks yourself via sleeping, or to delegate that tracking to other subagents. + Example usage (NOTE: The agents below are fictional examples for illustration only - use the actual agents listed above): diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 71e075b3916..5d58b377eb5 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -290,6 +290,7 @@ export type SubtaskPart = { modelID: string } command?: string + background?: boolean } export type ReasoningPart = { @@ -882,6 +883,24 @@ export type EventSessionError = { } } +export type EventSessionBackgroundTaskCompleted = { + type: "session.background_task_completed" + properties: { + sessionID: string + task: { + sessionID: string + result: string + status: "success" | "error" + description: string + agent: string + model: { + providerID: string + modelID: string + } + } + } +} + export type EventVcsBranchUpdated = { type: "vcs.branch.updated" properties: { @@ -994,6 +1013,7 @@ export type Event = | EventSessionDeleted | EventSessionDiff | EventSessionError + | EventSessionBackgroundTaskCompleted | EventVcsBranchUpdated | EventWorkspaceReady | EventWorkspaceFailed @@ -1757,6 +1777,7 @@ export type SubtaskPartInput = { modelID: string } command?: string + background?: boolean } export type ProviderAuthMethod = {