From 5d278d840409f5b039e77f7b54c94bde312bb266 Mon Sep 17 00:00:00 2001 From: OpenCode Agent Date: Wed, 7 Jan 2026 13:56:42 +0000 Subject: [PATCH] feat(tui): fire-and-forget async subagent tasks --- packages/opencode/src/agent/agent.ts | 4 +- packages/opencode/src/cli/cmd/tui/app.tsx | 21 +- .../opencode/src/cli/cmd/tui/context/sync.tsx | 13 +- .../src/cli/cmd/tui/routes/session/index.tsx | 268 +++++++++++++++-- .../cli/cmd/tui/routes/session/sidebar.tsx | 54 ++++ packages/opencode/src/config/config.ts | 8 + packages/opencode/src/session/compaction.ts | 14 +- packages/opencode/src/session/index.ts | 9 + packages/opencode/src/session/message-v2.ts | 58 ++++ packages/opencode/src/session/prompt.ts | 110 ++++++- packages/opencode/src/tool/task.ts | 273 +++++++++++++++--- packages/opencode/src/tool/task.txt | 17 +- packages/sdk/js/src/v2/gen/types.gen.ts | 12 + 13 files changed, 793 insertions(+), 68 deletions(-) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index cc8942c2aef..b0e1b950218 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -83,7 +83,7 @@ export namespace Agent { }, general: { name: "general", - description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`, + description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel. Ideal for async execution when work can proceed concurrently.`, permission: PermissionNext.merge( defaults, PermissionNext.fromConfig({ @@ -113,7 +113,7 @@ export namespace Agent { }), user, ), - description: `Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.`, + description: `Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions. Ideal for async execution when running multiple independent explorations.`, prompt: PROMPT_EXPLORE, options: {}, mode: "subagent", diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 2af5b21152c..36eb59c9f36 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -290,6 +290,21 @@ function App() { keybind: "session_new", category: "Session", onSelect: () => { + if (route.data.type === "session") { + const currentSessionID = route.data.sessionID + const children = sync.data.session.filter((s) => s.parentID === currentSessionID) + for (const child of children) { + const status = sync.data.session_status[child.id] + if (status?.type === "busy" || status?.type === "retry") { + toast.show({ + variant: "warning", + message: `Cannot start new session: background task "${child.title}" is still running`, + }) + return + } + } + } + const current = promptRef.current // Don't require focus - if there's any text, preserve it const currentPrompt = current?.current?.input ? current.current : undefined @@ -562,7 +577,11 @@ function App() { sdk.event.on(SessionApi.Event.Deleted.type, (evt) => { if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) { - route.navigate({ type: "home" }) + if (evt.properties.info.parentID) { + route.navigate({ type: "session", sessionID: evt.properties.info.parentID }) + } else { + route.navigate({ type: "home" }) + } toast.show({ variant: "info", message: "The current session was deleted", diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 8a14d8b2e77..f1cf5cd2da2 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -218,21 +218,22 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ break } case "message.part.updated": { - const parts = store.part[event.properties.part.messageID] + const part = event.properties.part + const parts = store.part[part.messageID] if (!parts) { - setStore("part", event.properties.part.messageID, [event.properties.part]) + setStore("part", part.messageID, [part]) break } - const result = Binary.search(parts, event.properties.part.id, (p) => p.id) + const result = Binary.search(parts, part.id, (p) => p.id) if (result.found) { - setStore("part", event.properties.part.messageID, result.index, reconcile(event.properties.part)) + setStore("part", part.messageID, result.index, reconcile(part)) break } setStore( "part", - event.properties.part.messageID, + part.messageID, produce((draft) => { - draft.splice(result.index, 0, event.properties.part) + draft.splice(result.index, 0, part) }), ) break diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index aa331ca0f0d..95dee4f5145 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -71,6 +71,7 @@ import { Filesystem } from "@/util/filesystem" import { PermissionPrompt } from "./permission" import { DialogExportOptions } from "../../ui/dialog-export-options" import { formatTranscript } from "../../util/transcript" +import { Identifier } from "@/id/id" addDefaultParsers(parsers.parsers) @@ -182,6 +183,27 @@ export function Session() { const toast = useToast() const sdk = useSDK() + const childSessions = createMemo(() => sync.data.session.filter((s) => s.parentID === route.sessionID)) + const prevChildStatuses = new Map() + + createEffect(() => { + for (const child of childSessions()) { + const status = sync.data.session_status[child.id] + const currentType = status?.type ?? "idle" + const prevType = prevChildStatuses.get(child.id) + + if (prevType && (prevType === "busy" || prevType === "retry") && currentType === "idle") { + toast.show({ + message: `${child.title} completed`, + variant: "success", + duration: 5000, + }) + } + + prevChildStatuses.set(child.id, currentType) + } + }) + // Handle initial prompt from fork createEffect(() => { if (route.initialPrompt && prompt) { @@ -1004,6 +1026,12 @@ export function Session() { = revert()!.messageID}> <> + + + = { "application/x-directory": "dir", } +type TaskSummaryItem = { + id: string + tool: string + state: { + status: string + title?: string + } +} + +type SubtaskPart = Extract & { + status?: "completed" | "error" + subagentSessionID?: string +} + +function isSubtaskReminder(parts: Part[]): boolean { + if (parts.length === 0) return false + return parts.every((part) => { + if (part.type !== "subtask") return false + const subtask = part as SubtaskPart + return subtask.status === "completed" || subtask.status === "error" + }) +} + +function isTaskSummaryItem(value: unknown): value is TaskSummaryItem { + if (!value || typeof value !== "object") return false + const record = value as Record + if (typeof record["id"] !== "string") return false + if (typeof record["tool"] !== "string") return false + const state = record["state"] + if (!state || typeof state !== "object") return false + const stateRecord = state as Record + if (typeof stateRecord["status"] !== "string") return false + const title = stateRecord["title"] + if (title !== undefined && typeof title !== "string") return false + return true +} + +function readTaskSummary(meta: unknown): TaskSummaryItem[] { + if (!meta || typeof meta !== "object") return [] + const record = meta as Record + const summary = record["summary"] + if (!Array.isArray(summary)) return [] + return summary.filter(isTaskSummaryItem) +} + +function readTaskInput(input: unknown): { description?: string; subagentType?: string } { + if (!input || typeof input !== "object") return {} + const record = input as Record + const description = record["description"] + const subagentType = record["subagent_type"] + return { + description: typeof description === "string" ? description : undefined, + subagentType: typeof subagentType === "string" ? subagentType : undefined, + } +} + +function readToolMetadata(state: ToolPart["state"]): unknown { + if (!("metadata" in state)) return + return state.metadata +} + +function readTaskSessionId(meta: unknown): string | undefined { + if (!meta || typeof meta !== "object") return + const record = meta as Record + const sessionId = record["sessionId"] + if (typeof sessionId !== "string") return + return sessionId +} + +function findTaskPart( + sync: ReturnType, + sessionID: string, + subagentSessionID?: string, +): ToolPart | undefined { + if (!subagentSessionID) return + const messages = sync.data.message[sessionID] ?? [] + for (const message of messages) { + const parts = sync.data.part[message.id] ?? [] + const match = parts.find( + (part): part is ToolPart => + part.type === "tool" && + part.tool === "task" && + readTaskSessionId(readToolMetadata(part.state)) === subagentSessionID, + ) + if (match) return match + } +} + +function useSubagentNavigation(subagentSessionID?: string) { + const { navigate } = useRoute() + const sync = useSync() + const toast = useToast() + + return () => { + if (!subagentSessionID) return + + const exists = sync.session.get(subagentSessionID) + if (!exists) { + toast.show({ + variant: "info", + message: "Session was wiped", + duration: 3000, + }) + return + } + + navigate({ type: "session", sessionID: subagentSessionID }) + } +} + function UserMessage(props: { message: UserMessage parts: Part[] @@ -1093,9 +1231,15 @@ function UserMessage(props: { const compaction = createMemo(() => props.parts.find((x) => x.type === "compaction")) + const hasContent = createMemo(() => props.parts.some((p) => p.type !== "compaction")) + const renderSubtask = (part: Part) => { + if (part.type !== "subtask") return + return + } + return ( <> - + - {text()?.text} + + {text()?.text} + + {renderSubtask} @@ -1170,6 +1317,58 @@ function UserMessage(props: { ) } +function SubtaskReminderMessage(props: { message: UserMessage; parts: Part[] }) { + const subtasks = createMemo(() => + props.parts.flatMap((part) => (part.type === "subtask" ? [part as SubtaskPart] : [])), + ) + return {(part) => } +} + +function SubtaskReminderPart(props: { part: SubtaskPart }) { + const ctx = use() + const { theme } = useTheme() + const keybind = useKeybind() + const sync = useSync() + const navigateToSubagent = useSubagentNavigation(props.part.subagentSessionID) + + const taskPart = createMemo(() => findTaskPart(sync, ctx.sessionID, props.part.subagentSessionID)) + const taskInput = createMemo(() => readTaskInput(taskPart()?.state.input)) + const taskMeta = createMemo(() => { + const part = taskPart() + if (!part) return + return readToolMetadata(part.state) + }) + const summary = createMemo(() => readTaskSummary(taskMeta())) + + const status = createMemo(() => (props.part.status === "error" ? "Failed" : "Completed")) + const statusColor = createMemo(() => (props.part.status === "error" ? theme.error : theme.textMuted)) + const description = createMemo(() => taskInput().description ?? props.part.description) + const title = createMemo(() => Locale.titlecase(taskInput().subagentType ?? props.part.agent ?? "unknown")) + + return ( + + + + {status()}: {description()} ({summary().length} toolcalls) + + + {(item) => ( + + └ {Locale.titlecase(item.tool)} {item.state.status === "completed" ? item.state.title : ""} + + )} + + + + + {keybind.print("session_child_cycle")} + view subagents + + + + ) +} + function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; last: boolean }) { const local = useLocal() const { theme } = useTheme() @@ -1199,7 +1398,8 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las last={index() === props.parts.length - 1} component={component()} part={part as any} - message={props.message} + message={component() === SubtaskPartComp ? undefined : props.message} + indented={component() === SubtaskPartComp ? true : undefined} /> ) @@ -1253,6 +1453,33 @@ const PART_MAPPING = { text: TextPart, tool: ToolPart, reasoning: ReasoningPart, + subtask: SubtaskPartComp, +} + +function SubtaskPartComp(props: { last: boolean; part: SubtaskPart; indented?: boolean }) { + const { theme } = useTheme() + const navigateToSubagent = useSubagentNavigation(props.part.subagentSessionID) + + const complete = createMemo(() => { + if (props.part.status === "completed") return true + if (props.part.status === "error") return "Failed" + return false + }) + + return ( + props.part.subagentSessionID && navigateToSubagent()} + > + + + {props.part.status === "error" ? "Failed: " : "Completed: "} + {props.part.description} + + + + ) } function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: AssistantMessage }) { @@ -1417,7 +1644,7 @@ function InlineTool(props: { complete: any pending: string children: JSX.Element - part: ToolPart + part: ToolPart | SubtaskPart }) { const [margin, setMargin] = createSignal(0) const { theme } = useTheme() @@ -1425,6 +1652,7 @@ function InlineTool(props: { const sync = useSync() const permission = createMemo(() => { + if (!("callID" in props.part)) return false const callID = sync.data.permission[ctx.sessionID]?.at(0)?.tool?.callID if (!callID) return false return callID === props.part.callID @@ -1436,7 +1664,12 @@ function InlineTool(props: { return theme.text }) - const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error : undefined)) + const error = createMemo(() => { + if ("state" in props.part) { + return props.part.state.status === "error" ? props.part.state.error : undefined + } + return undefined + }) const denied = createMemo(() => error()?.includes("rejected permission") || error()?.includes("specified a rule")) @@ -1651,34 +1884,31 @@ function WebSearch(props: ToolProps) { function Task(props: ToolProps) { const { theme } = useTheme() const keybind = useKeybind() - const { navigate } = useRoute() const local = useLocal() + const navigateToSubagent = useSubagentNavigation(props.metadata.sessionId) const current = createMemo(() => props.metadata.summary?.findLast((x) => x.state.status !== "pending")) const color = createMemo(() => local.agent.color(props.input.subagent_type ?? "unknown")) return ( - + navigate({ type: "session", sessionID: props.metadata.sessionId! }) - : undefined - } + onClick={props.metadata.sessionId ? navigateToSubagent : undefined} part={props.part} > - {props.input.description} ({props.metadata.summary?.length} toolcalls) + {props.input.description} ({props.metadata.summary?.length ?? 0} toolcalls) - - - └ {Locale.titlecase(current()!.tool)}{" "} - {current()!.state.status === "completed" ? current()!.state.title : ""} - - + + {(item) => ( + + └ {Locale.titlecase(item.tool)} {item.state.status === "completed" ? item.state.title : ""} + + )} + {keybind.print("session_child_cycle")} diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index 98b8cd6d349..851832795fa 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -25,6 +25,13 @@ export function Sidebar(props: { sessionID: string }) { diff: true, todo: true, lsp: true, + tasks: true, + }) + + const backgroundTasks = createMemo(() => { + return sync.data.session + .filter((s) => s.parentID === props.sessionID) + .toSorted((a, b) => a.time.created - b.time.created) }) // Sort MCP servers alphabetically for consistent display order @@ -219,6 +226,53 @@ export function Sidebar(props: { sessionID: string }) { + 0}> + + backgroundTasks().length > 2 && setExpanded("tasks", !expanded.tasks)} + > + 2}> + {expanded.tasks ? "▼" : "▶"} + + + Background Tasks + + ({backgroundTasks().length}) + + + + + + {(task) => { + const status = createMemo(() => sync.data.session_status[task.id]) + const statusType = createMemo(() => status()?.type ?? "idle") + const statusColor = createMemo(() => { + if (statusType() === "busy") return theme.warning + if (statusType() === "retry") return theme.error + return theme.success + }) + const statusLabel = createMemo(() => { + if (statusType() === "busy") return "running" + if (statusType() === "retry") return "retrying" + return "completed" + }) + return ( + + + • + + + {task.title} {statusLabel()} + + + ) + }} + + + + 0}> 0) { + runningTasksInfo = "\n\nIMPORTANT: The following background tasks are still running:\n" + for (const child of running) { + runningTasksInfo += `- Task: "${child.title}" (Session: ${child.id})\n` + } + runningTasksInfo += + "\nInclude these session IDs in your summary so the conversation can track their completion.\n" + } + const defaultPrompt = "Provide a detailed prompt for continuing our conversation above. Focus on information that would be helpful for continuing the conversation, including what we did, what we're doing, which files we're working on, and what we're going to do next considering new session will not have access to our conversation." - const promptText = compacting.prompt ?? [defaultPrompt, ...compacting.context].join("\n\n") + const promptText = (compacting.prompt ?? [defaultPrompt, ...compacting.context].join("\n\n")) + runningTasksInfo const result = await processor.process({ user: userMessage, agent, diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 0776590d6a9..1ff710b2de9 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -304,6 +304,15 @@ export namespace Session { return result }) + export const runningChildren = fn(Identifier.schema("session"), async (parentID) => { + const kids = await Session.children(parentID) + const { SessionStatus } = await import("./status") + return kids.filter((child) => { + const status = SessionStatus.get(child.id) + return status.type === "busy" || status.type === "retry" + }) + }) + export const remove = fn(Identifier.schema("session"), async (sessionID) => { const project = Instance.project try { diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 2dff17a5efa..f073021ff6e 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -169,6 +169,10 @@ export namespace MessageV2 { description: z.string(), agent: z.string(), command: z.string().optional(), + subagentSessionID: z.string().optional(), + status: z.enum(["completed", "error"]).optional(), + error: z.string().optional(), + summary: z.string().optional(), }) export type SubtaskPart = z.infer @@ -426,12 +430,66 @@ export namespace MessageV2 { }) export type WithParts = z.infer + function buildSubtaskSystemText(part: MessageV2.SubtaskPart): string | undefined { + const description = part.description + const sessionId = part.subagentSessionID ?? "" + + if (part.status === "completed") { + return [ + `Background task completed: ${description}`, + "", + "Summary:", + part.summary ?? "No summary provided.", + "", + "Instruction:", + "- MUST treat the summary as authoritative and complete.", + "- MUST NOT check status or call tools to verify completion.", + "- MUST briefly acknowledge completion to the user.", + "- MUST incorporate the summary into your next actions when relevant.", + ].join("\n") + } + + if (part.status === "error") { + return [ + `Background task failed: ${description}`, + "", + "Error:", + part.error ?? "Unknown error", + "", + "Instruction:", + "- MUST briefly acknowledge the failure to the user.", + "- MUST attempt recovery by calling the task tool with the same session_id and prompt 'continue' (async=true).", + sessionId ? `session_id: ${sessionId}` : "", + ] + .filter((line) => line.length > 0) + .join("\n") + } + + return + } + export function toModelMessage(input: WithParts[]): ModelMessage[] { const result: UIMessage[] = [] for (const msg of input) { if (msg.parts.length === 0) continue + const completionPart = msg.parts.find( + (p): p is MessageV2.SubtaskPart => p.type === "subtask" && (p.status === "completed" || p.status === "error"), + ) + + if (completionPart) { + const text = buildSubtaskSystemText(completionPart) + if (text) { + result.push({ + id: msg.info.id, + role: "assistant", + parts: [{ type: "text", text }], + }) + continue + } + } + if (msg.info.role === "user") { const userMessage: UIMessage = { id: msg.info.id, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 09155c86e7d..996a36239fd 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -314,7 +314,7 @@ export namespace SessionPrompt { // pending subtask // TODO: centralize "invoke tool" logic - if (task?.type === "subtask") { + if (task?.type === "subtask" && task.status !== "completed" && task.status !== "error") { const taskTool = await TaskTool.init() const assistantMessage = (await Session.updateMessage({ id: Identifier.ascending("message"), @@ -590,12 +590,19 @@ export namespace SessionPrompt { await Plugin.trigger("experimental.chat.messages.transform", {}, { messages: sessionMessages }) + const taskAgents = taskToolAgentsSystem(sessionMessages) + const system = [ + ...(await SystemPrompt.environment()), + ...(await SystemPrompt.custom()), + ...(taskAgents ? [taskAgents] : []), + ] + const result = await processor.process({ user: lastUser, agent, abort, sessionID, - system: [...(await SystemPrompt.environment()), ...(await SystemPrompt.custom())], + system, messages: [ ...MessageV2.toModelMessage(sessionMessages), ...(isLastStep @@ -1194,6 +1201,105 @@ export namespace SessionPrompt { } } + type TaskToolAgentStatus = "running" | "completed" | "error" + + type TaskToolAgentInfo = { + description?: string + agent?: string + } + + function taskToolAgentsSystem(messages: MessageV2.WithParts[]) { + const asyncSessions = new Set() + const done = new Map() + const details = new Map() + + for (const msg of messages) { + for (const part of msg.parts) { + if (part.type === "subtask") { + const sessionId = part.subagentSessionID + if (!sessionId) continue + asyncSessions.add(sessionId) + details.set(sessionId, { + description: part.description, + agent: part.agent, + }) + if (part.status === "completed" || part.status === "error") { + done.set(sessionId, part.status) + } + continue + } + if (part.type !== "tool") continue + if (part.tool !== "task") continue + const metadata = "metadata" in part.state ? part.state.metadata : undefined + const sessionId = asyncSessionIdFromMetadata(metadata) + if (!sessionId) continue + asyncSessions.add(sessionId) + const info = taskToolAgentInfoFromInput(part.state.input) + if (info) { + details.set(sessionId, info) + } + } + } + + if (asyncSessions.size === 0) return + + const rows = Array.from(asyncSessions) + .map((sessionId) => { + const status = done.get(sessionId) + if (status) return { sessionId, status, info: details.get(sessionId) } + const current = SessionStatus.get(sessionId) + const fallback: TaskToolAgentStatus = + current.type === "busy" || current.type === "retry" ? "running" : "completed" + return { sessionId, status: fallback, info: details.get(sessionId) } + }) + .sort((a, b) => a.sessionId.localeCompare(b.sessionId)) + + const lines = rows.map((entry) => formatTaskToolAgentEntry(entry)) + + return [ + "", + "These async subagent sessions run in the background; expect completion messages for running sessions.", + "Completed or error sessions can be continued with the task tool using session_id and prompt 'continue'.", + ...lines, + "", + ].join("\n") + } + + function formatTaskToolAgentEntry(input: { + sessionId: string + status: TaskToolAgentStatus + info: TaskToolAgentInfo | undefined + }) { + const fields = [`session_id: ${input.sessionId}`, `status: ${input.status}`] + if (input.info?.agent) fields.push(`agent: ${input.info.agent}`) + if (input.info?.description) fields.push(`description: ${input.info.description}`) + return `- ${fields.join(" ")}` + } + + function taskToolAgentInfoFromInput(input: unknown): TaskToolAgentInfo | undefined { + if (!isRecord(input)) return + const description = input.description + const agent = input.subagent_type + const info: TaskToolAgentInfo = {} + if (typeof description === "string" && description.length > 0) info.description = description + if (typeof agent === "string" && agent.length > 0) info.agent = agent + if (!info.agent && !info.description) return + return info + } + + function asyncSessionIdFromMetadata(input: unknown) { + if (!isRecord(input)) return + if (input.async !== true) return + const sessionId = input.sessionId + if (typeof sessionId !== "string") return + if (sessionId.length === 0) return + return sessionId + } + + function isRecord(input: unknown): input is Record { + return typeof input === "object" && input !== null + } + function insertReminders(input: { messages: MessageV2.WithParts[]; agent: Agent.Info }) { const userMessage = input.messages.findLast((msg) => msg.info.role === "user") if (!userMessage) return input.messages diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index a30a5a67502..8bf53cce55c 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -18,7 +18,24 @@ export function filterSubagents(agents: Agent.Info[], ruleset: PermissionNext.Ru return agents.filter((a) => PermissionNext.evaluate("task", a.name, ruleset).action !== "deny") } -export const TaskTool = Tool.define("task", async () => { +type TaskMetadata = { + sessionId: string + async?: boolean + summary?: { id: string; tool: string; state: { status: string; title?: string } }[] +} + +export const TaskTool = Tool.define< + z.ZodObject<{ + description: z.ZodString + prompt: z.ZodString + subagent_type: z.ZodString + name: z.ZodOptional + async: z.ZodOptional + session_id: z.ZodOptional + command: z.ZodOptional + }>, + TaskMetadata +>("task", async () => { const agents = await Agent.list().then((x) => x.filter((a) => a.mode !== "primary")) const description = DESCRIPTION.replace( "{agents}", @@ -32,6 +49,13 @@ export const TaskTool = Tool.define("task", async () => { description: z.string().describe("A short (3-5 words) description of the task"), prompt: z.string().describe("The task for the agent to perform"), subagent_type: z.string().describe("The type of specialized agent to use for this task"), + name: z.string().describe("Human-readable task name for sidebar display").optional(), + async: z + .boolean() + .describe( + "async/background/parallel mode. MUST use when the subagent can run concurrently while you continue; MUST use for non-conflicting parallel work (e.g., multiple independent research tasks, testing different components); SHOULD use for research or long-running tasks; MUST NOT use when the result is required before the next step. Omit for blocking execution. You will be automatically notified when async tasks complete.", + ) + .optional(), session_id: z.string().describe("Existing Task session to continue").optional(), command: z.string().describe("The command that triggered this task").optional(), }), @@ -39,7 +63,6 @@ export const TaskTool = Tool.define("task", async () => { const config = await Config.get() const userInvokedAgents = (ctx.extra?.userInvokedAgents ?? []) as string[] - // Skip permission check when invoked from a command subtask (user already approved by invoking the command) if (!ctx.extra?.bypassAgentCheck && !userInvokedAgents.includes(params.subagent_type)) { await ctx.ask({ permission: "task", @@ -50,10 +73,59 @@ export const TaskTool = Tool.define("task", async () => { subagent_type: params.subagent_type, }, }) + if (params.async) { + await ctx.ask({ + permission: "task_async", + patterns: [params.subagent_type], + always: ["*"], + metadata: { + description: params.description, + subagent_type: params.subagent_type, + async: true, + }, + }) + } } const agent = await Agent.get(params.subagent_type) if (!agent) throw new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`) + + if (params.async) { + const limit = config.experimental?.async_task_limit ?? 3 + const running = await Session.runningChildren(ctx.sessionID) + if (running.length >= limit) { + throw new Error(`Maximum ${limit} concurrent async tasks reached. Wait for a task to complete.`) + } + } + + const taskName = params.name ?? params.description + ` (@${agent.name} subagent)` + const basePermissions: PermissionNext.Ruleset = [ + { + permission: "todowrite", + pattern: "*", + action: "deny", + }, + { + permission: "todoread", + pattern: "*", + action: "deny", + }, + { + permission: "task", + pattern: "*", + action: "deny", + }, + ] + const primaryPermissions = + config.experimental?.primary_tools?.map((t) => ({ + pattern: "*", + action: "allow" as const, + permission: t, + })) ?? [] + const asyncConfig: Config.Permission = config.experimental?.async_task_permissions ?? {} + const asyncPermissions = params.async + ? PermissionNext.merge(basePermissions, PermissionNext.fromConfig(asyncConfig), primaryPermissions) + : PermissionNext.merge(basePermissions, primaryPermissions) const session = await iife(async () => { if (params.session_id) { const found = await Session.get(params.session_id).catch(() => {}) @@ -62,29 +134,8 @@ export const TaskTool = Tool.define("task", async () => { return await Session.create({ parentID: ctx.sessionID, - title: params.description + ` (@${agent.name} subagent)`, - permission: [ - { - permission: "todowrite", - pattern: "*", - action: "deny", - }, - { - permission: "todoread", - pattern: "*", - action: "deny", - }, - { - permission: "task", - pattern: "*", - action: "deny", - }, - ...(config.experimental?.primary_tools?.map((t) => ({ - pattern: "*", - action: "allow" as const, - permission: t, - })) ?? []), - ], + title: taskName, + permission: asyncPermissions, }) }) const msg = await MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID }) @@ -99,11 +150,9 @@ export const TaskTool = Tool.define("task", async () => { const messageID = Identifier.ascending("message") const parts: Record = {} - const unsub = Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => { - if (evt.properties.part.sessionID !== session.id) return - if (evt.properties.part.messageID === messageID) return - if (evt.properties.part.type !== "tool") return - const part = evt.properties.part + + const updateSummary = (part: MessageV2.Part) => { + if (part.type !== "tool") return parts[part.id] = { id: part.id, tool: part.tool, @@ -112,6 +161,13 @@ export const TaskTool = Tool.define("task", async () => { title: part.state.status === "completed" ? part.state.title : undefined, }, } + } + + const unsub = Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => { + if (evt.properties.part.sessionID !== session.id) return + if (evt.properties.part.messageID === messageID) return + updateSummary(evt.properties.part) + ctx.metadata({ title: params.description, metadata: { @@ -129,11 +185,13 @@ export const TaskTool = Tool.define("task", async () => { function cancel() { SessionPrompt.cancel(session.id) } - ctx.abort.addEventListener("abort", cancel) - using _ = defer(() => ctx.abort.removeEventListener("abort", cancel)) + if (!params.async) { + 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 promptConfig = { messageID, sessionID: session.id, model: { @@ -148,12 +206,156 @@ export const TaskTool = Tool.define("task", async () => { ...Object.fromEntries((config.experimental?.primary_tools ?? []).map((t) => [t, false])), }, parts: promptParts, - }) + } + + if (params.async) { + unsub() + + const parentSessionID = ctx.sessionID + const parentMessageID = ctx.messageID + const parentCallID = ctx.callID + const parentAgent = msg.info.agent + const parentModel = { providerID: msg.info.providerID, modelID: msg.info.modelID } + + const canNotifyParent = async () => { + if (!parentCallID) return false + const parent = await Session.get(parentSessionID).catch(() => null) + if (!parent) return false + const revertId = parent.revert?.messageID + if (revertId && parentMessageID.localeCompare(revertId) >= 0) return false + const parentMessage = await MessageV2.get({ + sessionID: parentSessionID, + messageID: parentMessageID, + }).catch(() => null) + if (!parentMessage) return false + return parentMessage.parts.some((part) => part.type === "tool" && part.callID === parentCallID) + } + + async function updateParentPart(summary: typeof parts) { + if (!parentCallID) return + const parentParts = await MessageV2.parts(parentMessageID) + const parentPart = parentParts.find( + (p): p is MessageV2.ToolPart => p.type === "tool" && p.callID === parentCallID, + ) + if (!parentPart) return + + const summaryArray = Object.values(summary).sort((a, b) => a.id.localeCompare(b.id)) + const state = parentPart.state + + if (state.status === "pending") return + + await Session.updatePart({ + ...parentPart, + state: { + ...state, + metadata: { + ...(state.metadata ?? {}), + summary: summaryArray, + sessionId: session.id, + async: true, + }, + }, + }) + } + + const asyncUnsub = Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => { + if (evt.properties.part.sessionID !== session.id) return + if (evt.properties.part.messageID === messageID) return + + const part = evt.properties.part + if (part.type !== "tool") return + + parts[part.id] = { + id: part.id, + tool: part.tool, + state: { + status: part.state.status, + title: part.state.status === "completed" ? part.state.title : undefined, + }, + } + await updateParentPart(parts) + }) + + const notifyParentOfError = async (error: unknown) => { + asyncUnsub() + if (ctx.abort.aborted) return + const shouldNotify = await canNotifyParent() + if (!shouldNotify) return + + const errMessage = error instanceof Error ? error.message : String(error) + + await SessionPrompt.prompt({ + sessionID: parentSessionID, + agent: parentAgent, + model: parentModel, + noReply: false, + parts: [ + { + type: "subtask", + subagentSessionID: session.id, + description: taskName, + status: "error", + error: errMessage, + prompt: params.prompt, + agent: agent.name, + }, + ], + }) + } + + SessionPrompt.prompt(promptConfig) + .then(async (result) => { + asyncUnsub() + + const shouldNotify = await canNotifyParent() + if (!shouldNotify) return + + await updateParentPart(parts) + + const lastText = + ("parts" in result ? result.parts.findLast((x) => x.type === "text")?.text : undefined) ?? + "Task completed." + + const summary = lastText + + await SessionPrompt.prompt({ + sessionID: parentSessionID, + agent: parentAgent, + model: parentModel, + noReply: false, + parts: [ + { + type: "subtask", + subagentSessionID: session.id, + description: taskName, + status: "completed", + prompt: params.prompt, + agent: agent.name, + summary, + }, + ], + }) + }) + .catch(notifyParentOfError) + + return { + title: params.description, + metadata: { + sessionId: session.id, + async: true, + summary: [], + }, + output: `Task started in background: "${taskName}". Session: ${session.id}. You will be notified when it completes.`, + } + } + + const result = await SessionPrompt.prompt(promptConfig) unsub() + const messages = await Session.messages({ sessionID: session.id }) const summary = messages .filter((x) => x.info.role === "assistant") - .flatMap((msg) => msg.parts.filter((x: any) => x.type === "tool") as MessageV2.ToolPart[]) + .flatMap((m) => m.parts.filter((x): x is MessageV2.ToolPart => x.type === "tool")) .map((part) => ({ id: part.id, tool: part.tool, @@ -171,6 +373,7 @@ export const TaskTool = Tool.define("task", async () => { metadata: { summary, sessionId: session.id, + async: false, }, output, } diff --git a/packages/opencode/src/tool/task.txt b/packages/opencode/src/tool/task.txt index 7af2a6f60dd..3ca61ee80f7 100644 --- a/packages/opencode/src/tool/task.txt +++ b/packages/opencode/src/tool/task.txt @@ -1,10 +1,22 @@ -Launch a new agent to handle complex, multistep tasks autonomously. +Launch a subagent (Task tool = subagent) to handle complex, multistep tasks autonomously. Supports two modes: blocking (default) and async/background/parallel (set async=true). Available agent types and the tools they have access to: {agents} When using the Task tool, you must specify a subagent_type parameter to select which agent type to use. +- MUST use the Task tool when work should be delegated to a subagent. +- SHOULD use it for research, exploration, or multi-step work in parallel. +- MUST NOT use it for simple file reads or quick lookups better served by Read/Glob/Grep. + +Async mode (async=true): +- MUST use async when the subagent work can run concurrently while you continue with other tasks +- MUST use async for non-conflicting parallel work (e.g., multiple independent research tasks, testing different components simultaneously) +- MUST use async for long-running tasks where you don't need to wait for the result +- SHOULD use async to maximize performance by launching multiple tasks in a single message +- MUST NOT use async when the result is required before the next step (omit for blocking execution) +- Background task completion will notify you automatically with the results + When to use the Task tool: - When you are instructed to execute custom slash commands. Use the Task tool with the slash command invocation as the entire prompt. The slash command can take arguments. For example: Task(description="Check the file", prompt="/check-file path/to/file.py") @@ -16,12 +28,13 @@ When NOT to use the Task tool: Usage notes: -1. Launch multiple agents concurrently whenever possible, to maximize performance; to do that, use a single message with multiple tool uses +1. Launch multiple agents concurrently whenever possible, to maximize performance; to do that, use a single message with multiple tool uses. For concurrent parallel work that doesn't block your progress, use async=true for each task. 2. When the agent is done, it will return a single message back to you. The result returned by the agent is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result. 3. Each agent invocation is stateless unless you provide a session_id. Your prompt should contain a highly detailed task description for the agent to perform autonomously and you should specify exactly what information the agent should return back to you in its final and only message to you. 4. The agent's outputs should generally be trusted 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 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. +7. Background task status: You will be automatically notified when async tasks complete. The system tracks all running background tasks and their completion status. 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 97a695162ed..244d027a767 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -430,6 +430,10 @@ export type Part = description: string agent: string command?: string + subagentSessionID?: string + status?: "completed" | "error" + error?: string + summary?: string } | ReasoningPart | FilePart @@ -1683,6 +1687,10 @@ export type Config = { * Timeout in milliseconds for model context protocol (MCP) requests */ mcp_timeout?: number + /** + * Maximum concurrent async background tasks per parent session (default: 3) + */ + async_task_limit?: number } } @@ -1761,6 +1769,10 @@ export type SubtaskPartInput = { description: string agent: string command?: string + subagentSessionID?: string + status?: "completed" | "error" + error?: string + summary?: string } export type Command = {