Skip to content

Commit 058c428

Browse files
Apply PR #24174: feat(core): add background subagent support
2 parents 726c40e + 80aeb78 commit 058c428

12 files changed

Lines changed: 811 additions & 61 deletions

File tree

packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2001,7 +2001,9 @@ function Task(props: ToolProps<typeof TaskTool>) {
20012001

20022002
const content = createMemo(() => {
20032003
if (!props.input.description) return ""
2004-
let content = [`${Locale.titlecase(props.input.subagent_type ?? "General")} Task — ${props.input.description}`]
2004+
const description =
2005+
props.metadata.background === true ? `${props.input.description} (background)` : props.input.description
2006+
let content = [`${Locale.titlecase(props.input.subagent_type ?? "General")} Task — ${description}`]
20052007

20062008
if (isRunning() && tools().length > 0) {
20072009
// content[0] += ` · ${tools().length} toolcalls`

packages/opencode/src/session/prompt.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,10 @@ export const layer = Layer.effect(
118118
cancel: (sessionID: SessionID) => run.fork(cancel(sessionID)),
119119
resolvePromptParts: (template: string) => resolvePromptParts(template),
120120
prompt: (input: PromptInput) => prompt(input),
121+
loop: (input: LoopInput) => loop(input),
122+
fork: (effect: Effect.Effect<void, never, never>) => {
123+
run.fork(effect)
124+
},
121125
} satisfies TaskPromptOps
122126
})
123127

packages/opencode/src/tool/registry.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { GlobTool } from "./glob"
77
import { GrepTool } from "./grep"
88
import { ReadTool } from "./read"
99
import { TaskTool } from "./task"
10+
import { TaskStatusTool } from "./task_status"
1011
import { TodoWriteTool } from "./todo"
1112
import { WebFetchTool } from "./webfetch"
1213
import { WriteTool } from "./write"
@@ -50,6 +51,7 @@ import { Agent } from "../agent/agent"
5051
import { Git } from "@/git"
5152
import { Skill } from "../skill"
5253
import { Permission } from "@/permission"
54+
import { SessionStatus } from "@/session/status"
5355

5456
const log = Log.create({ service: "tool.registry" })
5557

@@ -82,6 +84,7 @@ export const layer: Layer.Layer<
8284
| Agent.Service
8385
| Skill.Service
8486
| Session.Service
87+
| SessionStatus.Service
8588
| Provider.Service
8689
| Git.Service
8790
| LSP.Service
@@ -121,6 +124,7 @@ export const layer: Layer.Layer<
121124
const greptool = yield* GrepTool
122125
const patchtool = yield* ApplyPatchTool
123126
const skilltool = yield* SkillTool
127+
const taskstatus = yield* TaskStatusTool
124128
const agent = yield* Agent.Service
125129

126130
const state = yield* InstanceState.make<State>(
@@ -201,6 +205,7 @@ export const layer: Layer.Layer<
201205
edit: Tool.init(edit),
202206
write: Tool.init(writetool),
203207
task: Tool.init(task),
208+
taskstatus: Tool.init(taskstatus),
204209
fetch: Tool.init(webfetch),
205210
todo: Tool.init(todo),
206211
search: Tool.init(websearch),
@@ -226,6 +231,7 @@ export const layer: Layer.Layer<
226231
tool.edit,
227232
tool.write,
228233
tool.task,
234+
tool.taskstatus,
229235
tool.fetch,
230236
tool.todo,
231237
tool.search,
@@ -345,6 +351,7 @@ export const defaultLayer = Layer.suspend(() =>
345351
Layer.provide(Skill.defaultLayer),
346352
Layer.provide(Agent.defaultLayer),
347353
Layer.provide(Session.defaultLayer),
354+
Layer.provide(SessionStatus.defaultLayer),
348355
Layer.provide(Provider.defaultLayer),
349356
Layer.provide(Git.defaultLayer),
350357
Layer.provide(LSP.defaultLayer),

packages/opencode/src/tool/task.ts

Lines changed: 151 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,22 @@ import * as Tool from "./tool"
22
import DESCRIPTION from "./task.txt"
33
import { Session } from "@/session/session"
44
import { ShellToolID } from "./shell/id"
5+
import { Bus } from "../bus"
56
import { SessionID, MessageID } from "../session/schema"
67
import { MessageV2 } from "../session/message-v2"
78
import { Agent } from "../agent/agent"
89
import type { SessionPrompt } from "../session/prompt"
910
import { Config } from "@/config/config"
10-
import { Effect, Schema } from "effect"
11+
import { SessionStatus } from "../session/status"
12+
import { TuiEvent } from "@/cli/cmd/tui/event"
13+
import { Cause, Effect, Option, Schema } from "effect"
1114

1215
export interface TaskPromptOps {
1316
cancel(sessionID: SessionID): void
1417
resolvePromptParts(template: string): Effect.Effect<SessionPrompt.PromptInput["parts"]>
1518
prompt(input: SessionPrompt.PromptInput): Effect.Effect<MessageV2.WithParts>
19+
loop(input: SessionPrompt.LoopInput): Effect.Effect<MessageV2.WithParts>
20+
fork(effect: Effect.Effect<void, never, never>): void
1621
}
1722

1823
const id = "task"
@@ -21,24 +26,65 @@ export const Parameters = Schema.Struct({
2126
description: Schema.String.annotate({ description: "A short (3-5 words) description of the task" }),
2227
prompt: Schema.String.annotate({ description: "The task for the agent to perform" }),
2328
subagent_type: Schema.String.annotate({ description: "The type of specialized agent to use for this task" }),
24-
task_id: Schema.optional(Schema.String).annotate({
29+
task_id: Schema.optional(SessionID).annotate({
2530
description:
2631
"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)",
2732
}),
2833
command: Schema.optional(Schema.String).annotate({ description: "The command that triggered this task" }),
34+
background: Schema.optional(Schema.Boolean).annotate({
35+
description: "When true, launch the subagent in the background and return immediately",
36+
}),
2937
})
3038

39+
function output(sessionID: SessionID, text: string) {
40+
return [
41+
`task_id: ${sessionID} (for resuming to continue this task if needed)`,
42+
"",
43+
"<task_result>",
44+
text,
45+
"</task_result>",
46+
].join("\n")
47+
}
48+
49+
function backgroundOutput(sessionID: SessionID) {
50+
return [
51+
`task_id: ${sessionID} (for polling this task with task_status)`,
52+
"state: running",
53+
"",
54+
"<task_result>",
55+
"Background task started. Continue your current work and call task_status when you need the result.",
56+
"</task_result>",
57+
].join("\n")
58+
}
59+
60+
function backgroundMessage(input: { sessionID: SessionID; description: string; state: "completed" | "error"; text: string }) {
61+
const tag = input.state === "completed" ? "task_result" : "task_error"
62+
const title =
63+
input.state === "completed"
64+
? `Background task completed: ${input.description}`
65+
: `Background task failed: ${input.description}`
66+
return [title, `task_id: ${input.sessionID}`, `state: ${input.state}`, `<${tag}>`, input.text, `</${tag}>`].join(
67+
"\n",
68+
)
69+
}
70+
71+
function errorText(error: unknown) {
72+
if (error instanceof Error) return error.message
73+
return String(error)
74+
}
75+
3176
export const TaskTool = Tool.define(
3277
id,
3378
Effect.gen(function* () {
3479
const agent = yield* Agent.Service
80+
const bus = yield* Bus.Service
3581
const config = yield* Config.Service
3682
const sessions = yield* Session.Service
83+
const status = yield* SessionStatus.Service
3784

38-
const run = Effect.fn("TaskTool.execute")(function* (
39-
params: Schema.Schema.Type<typeof Parameters>,
40-
ctx: Tool.Context,
41-
) {
85+
const run = Effect.fn(
86+
"TaskTool.execute",
87+
)(function* (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) {
4288
const cfg = yield* config.get()
4389
const primaryTools = (cfg.experimental?.primary_tools ?? []).map(ShellToolID.normalize)
4490

@@ -64,7 +110,7 @@ export const TaskTool = Tool.define(
64110

65111
const taskID = params.task_id
66112
const session = taskID
67-
? yield* sessions.get(SessionID.make(taskID)).pipe(Effect.catchCause(() => Effect.succeed(undefined)))
113+
? yield* sessions.get(taskID).pipe(Effect.catchCause(() => Effect.succeed(undefined)))
68114
: undefined
69115
const nextSession =
70116
session ??
@@ -105,19 +151,107 @@ export const TaskTool = Tool.define(
105151
modelID: msg.info.modelID,
106152
providerID: msg.info.providerID,
107153
}
154+
const parentModel = {
155+
modelID: msg.info.modelID,
156+
providerID: msg.info.providerID,
157+
}
158+
const background = params.background === true
159+
160+
const metadata = {
161+
sessionId: nextSession.id,
162+
model,
163+
...(background ? { background: true } : {}),
164+
}
108165

109166
yield* ctx.metadata({
110167
title: params.description,
111-
metadata: {
112-
sessionId: nextSession.id,
113-
model,
114-
},
168+
metadata,
115169
})
116170

117171
const ops = ctx.extra?.promptOps as TaskPromptOps
118172
if (!ops) return yield* Effect.fail(new Error("TaskTool requires promptOps in ctx.extra"))
119173

120-
const messageID = MessageID.ascending()
174+
const runTask = Effect.fn("TaskTool.runTask")(function* () {
175+
const parts = yield* ops.resolvePromptParts(params.prompt)
176+
const result = yield* ops.prompt({
177+
messageID: MessageID.ascending(),
178+
sessionID: nextSession.id,
179+
model: {
180+
modelID: model.modelID,
181+
providerID: model.providerID,
182+
},
183+
agent: next.name,
184+
tools: {
185+
...(canTodo ? {} : { todowrite: false }),
186+
...(canTask ? {} : { task: false }),
187+
...Object.fromEntries(primaryTools.map((item) => [item, false])),
188+
},
189+
parts,
190+
})
191+
return result.parts.findLast((item) => item.type === "text")?.text ?? ""
192+
})
193+
194+
const continueIfIdle = Effect.fn("TaskTool.continueIfIdle")(function* (input: {
195+
userID: MessageID
196+
state: "completed" | "error"
197+
}) {
198+
if ((yield* status.get(ctx.sessionID)).type !== "idle") return
199+
const latest = yield* sessions.findMessage(ctx.sessionID, (item) => item.info.role === "user")
200+
if (Option.isNone(latest)) return
201+
if (latest.value.info.id !== input.userID) return
202+
yield* bus.publish(TuiEvent.ToastShow, {
203+
title: input.state === "completed" ? "Background task complete" : "Background task failed",
204+
message:
205+
input.state === "completed"
206+
? `Background task \"${params.description}\" finished. Resuming the main thread.`
207+
: `Background task \"${params.description}\" failed. Resuming the main thread.`,
208+
variant: input.state === "completed" ? "success" : "error",
209+
duration: 5000,
210+
})
211+
yield* ops.loop({ sessionID: ctx.sessionID }).pipe(Effect.ignore)
212+
})
213+
214+
if (background) {
215+
const inject = Effect.fn("TaskTool.injectBackgroundResult")(function* (state: "completed" | "error", text: string) {
216+
const message = yield* ops.prompt({
217+
sessionID: ctx.sessionID,
218+
noReply: true,
219+
model: parentModel,
220+
agent: ctx.agent,
221+
parts: [
222+
{
223+
type: "text",
224+
synthetic: true,
225+
text: backgroundMessage({
226+
sessionID: nextSession.id,
227+
description: params.description,
228+
state,
229+
text,
230+
}),
231+
},
232+
],
233+
})
234+
yield* continueIfIdle({ userID: message.info.id, state })
235+
})
236+
237+
ops.fork(
238+
runTask().pipe(
239+
Effect.matchCauseEffect({
240+
onSuccess: (text) => inject("completed", text),
241+
onFailure: (cause) =>
242+
inject("error", errorText(Cause.squash(cause))).pipe(Effect.catchCause(() => Effect.void)),
243+
}),
244+
Effect.catchCause(() => Effect.void),
245+
Effect.asVoid,
246+
),
247+
)
248+
249+
return {
250+
title: params.description,
251+
metadata,
252+
output: backgroundOutput(nextSession.id),
253+
}
254+
}
121255

122256
function cancel() {
123257
ops.cancel(nextSession.id)
@@ -129,50 +263,24 @@ export const TaskTool = Tool.define(
129263
}),
130264
() =>
131265
Effect.gen(function* () {
132-
const parts = yield* ops.resolvePromptParts(params.prompt)
133-
const result = yield* ops.prompt({
134-
messageID,
135-
sessionID: nextSession.id,
136-
model: {
137-
modelID: model.modelID,
138-
providerID: model.providerID,
139-
},
140-
agent: next.name,
141-
tools: {
142-
...(canTodo ? {} : { todowrite: false }),
143-
...(canTask ? {} : { task: false }),
144-
...Object.fromEntries(primaryTools.map((item) => [item, false])),
145-
},
146-
parts,
147-
})
148-
266+
const text = yield* runTask()
149267
return {
150268
title: params.description,
151-
metadata: {
152-
sessionId: nextSession.id,
153-
model,
154-
},
155-
output: [
156-
`task_id: ${nextSession.id} (for resuming to continue this task if needed)`,
157-
"",
158-
"<task_result>",
159-
result.parts.findLast((item) => item.type === "text")?.text ?? "",
160-
"</task_result>",
161-
].join("\n"),
269+
metadata,
270+
output: output(nextSession.id, text),
162271
}
163272
}),
164273
() =>
165274
Effect.sync(() => {
166275
ctx.abort.removeEventListener("abort", cancel)
167276
}),
168277
)
169-
})
278+
}, Effect.orDie)
170279

171280
return {
172281
description: DESCRIPTION,
173282
parameters: Parameters,
174-
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>
175-
run(params, ctx).pipe(Effect.orDie),
283+
execute: run,
176284
}
177285
}),
178286
)

packages/opencode/src/tool/task.txt

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,13 @@ When NOT to use the Task tool:
1414

1515
Usage notes:
1616
1. Launch multiple agents concurrently whenever possible, to maximize performance; to do that, use a single message with multiple tool uses
17-
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. The output includes a task_id you can reuse later to continue the same subagent session.
18-
3. Each agent invocation starts with a fresh context unless you provide task_id to resume the same subagent session (which continues with its previous messages and tool outputs). When starting fresh, 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.
19-
4. The agent's outputs should generally be trusted
20-
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).
21-
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.
17+
2. By default, task waits for completion and returns the result immediately, along with a task_id you can reuse later to continue the same subagent session.
18+
3. Set background=true to launch asynchronously. In background mode, continue your current work without waiting.
19+
4. For background runs, use task_status(task_id=..., wait=false) to poll, or wait=true to block until done (optionally with timeout_ms).
20+
5. Each agent invocation starts with a fresh context unless you provide task_id to resume the same subagent session (which continues with its previous messages and tool outputs). When starting fresh, 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.
21+
6. The agent's outputs should generally be trusted
22+
7. 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).
23+
8. 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.
2224

2325
Example usage (NOTE: The agents below are fictional examples for illustration only - use the actual agents listed above):
2426

0 commit comments

Comments
 (0)