Skip to content

Commit 971c837

Browse files
committed
feat(task): add background subagent support
1 parent d89bfc3 commit 971c837

8 files changed

Lines changed: 775 additions & 55 deletions

File tree

packages/opencode/src/session/prompt.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,10 @@ export const layer = Layer.effect(
115115
cancel: (sessionID: SessionID) => run.fork(cancel(sessionID)),
116116
resolvePromptParts: (template: string) => resolvePromptParts(template),
117117
prompt: (input: PromptInput) => prompt(input),
118+
loop: (input: LoopInput) => loop(input),
119+
fork: (effect: Effect.Effect<void, never, never>) => {
120+
run.fork(effect)
121+
},
118122
} satisfies TaskPromptOps
119123
})
120124

packages/opencode/src/tool/registry.ts

Lines changed: 9 additions & 2 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"
@@ -47,6 +48,7 @@ import { Bus } from "../bus"
4748
import { Agent } from "../agent/agent"
4849
import { Skill } from "../skill"
4950
import { Permission } from "@/permission"
51+
import { SessionStatus } from "@/session/status"
5052

5153
const log = Log.create({ service: "tool.registry" })
5254

@@ -78,8 +80,9 @@ export const layer: Layer.Layer<
7880
| Todo.Service
7981
| Agent.Service
8082
| Skill.Service
81-
| Session.Service
82-
| Provider.Service
83+
| Session.Service
84+
| SessionStatus.Service
85+
| Provider.Service
8386
| LSP.Service
8487
| Instruction.Service
8588
| AppFileSystem.Service
@@ -115,6 +118,7 @@ export const layer: Layer.Layer<
115118
const greptool = yield* GrepTool
116119
const patchtool = yield* ApplyPatchTool
117120
const skilltool = yield* SkillTool
121+
const taskstatus = yield* TaskStatusTool
118122
const agent = yield* Agent.Service
119123

120124
const state = yield* InstanceState.make<State>(
@@ -195,6 +199,7 @@ export const layer: Layer.Layer<
195199
edit: Tool.init(edit),
196200
write: Tool.init(writetool),
197201
task: Tool.init(task),
202+
taskstatus: Tool.init(taskstatus),
198203
fetch: Tool.init(webfetch),
199204
todo: Tool.init(todo),
200205
search: Tool.init(websearch),
@@ -218,6 +223,7 @@ export const layer: Layer.Layer<
218223
tool.edit,
219224
tool.write,
220225
tool.task,
226+
tool.taskstatus,
221227
tool.fetch,
222228
tool.todo,
223229
tool.search,
@@ -335,6 +341,7 @@ export const defaultLayer = Layer.suspend(() =>
335341
Layer.provide(Skill.defaultLayer),
336342
Layer.provide(Agent.defaultLayer),
337343
Layer.provide(Session.defaultLayer),
344+
Layer.provide(SessionStatus.defaultLayer),
338345
Layer.provide(Provider.defaultLayer),
339346
Layer.provide(LSP.defaultLayer),
340347
Layer.provide(Instruction.defaultLayer),

packages/opencode/src/tool/task.ts

Lines changed: 146 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,22 @@
11
import * as Tool from "./tool"
22
import DESCRIPTION from "./task.txt"
3+
import { Bus } from "../bus"
34
import { Session } from "../session"
45
import { SessionID, MessageID } from "../session/schema"
56
import { MessageV2 } from "../session/message-v2"
67
import { Agent } from "../agent/agent"
78
import type { SessionPrompt } from "../session/prompt"
9+
import { SessionStatus } from "../session/status"
810
import { Config } from "../config"
9-
import { Effect, Schema } from "effect"
11+
import { TuiEvent } from "@/cli/cmd/tui/event"
12+
import { Cause, Effect, Option, Schema } from "effect"
1013

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

1722
const id = "task"
@@ -20,19 +25,61 @@ export const Parameters = Schema.Struct({
2025
description: Schema.String.annotate({ description: "A short (3-5 words) description of the task" }),
2126
prompt: Schema.String.annotate({ description: "The task for the agent to perform" }),
2227
subagent_type: Schema.String.annotate({ description: "The type of specialized agent to use for this task" }),
23-
task_id: Schema.optional(Schema.String).annotate({
28+
task_id: Schema.optional(SessionID).annotate({
2429
description:
2530
"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)",
2631
}),
2732
command: Schema.optional(Schema.String).annotate({ description: "The command that triggered this task" }),
33+
background: Schema.optional(Schema.Boolean).annotate({
34+
description: "When true, launch the subagent in the background and return immediately",
35+
}),
2836
})
2937

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

3784
const run = Effect.fn("TaskTool.execute")(function* (
3885
params: Schema.Schema.Type<typeof Parameters>,
@@ -62,7 +109,7 @@ export const TaskTool = Tool.define(
62109

63110
const taskID = params.task_id
64111
const session = taskID
65-
? yield* sessions.get(SessionID.make(taskID)).pipe(Effect.catchCause(() => Effect.succeed(undefined)))
112+
? yield* sessions.get(taskID).pipe(Effect.catchCause(() => Effect.succeed(undefined)))
66113
: undefined
67114
const nextSession =
68115
session ??
@@ -103,19 +150,107 @@ export const TaskTool = Tool.define(
103150
modelID: msg.info.modelID,
104151
providerID: msg.info.providerID,
105152
}
153+
const parentModel = {
154+
modelID: msg.info.modelID,
155+
providerID: msg.info.providerID,
156+
}
157+
const background = params.background === true
158+
159+
const metadata = {
160+
sessionId: nextSession.id,
161+
model,
162+
...(background ? { background: true } : {}),
163+
}
106164

107165
yield* ctx.metadata({
108166
title: params.description,
109-
metadata: {
110-
sessionId: nextSession.id,
111-
model,
112-
},
167+
metadata,
113168
})
114169

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

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

120255
function cancel() {
121256
ops.cancel(nextSession.id)
@@ -127,36 +262,11 @@ export const TaskTool = Tool.define(
127262
}),
128263
() =>
129264
Effect.gen(function* () {
130-
const parts = yield* ops.resolvePromptParts(params.prompt)
131-
const result = yield* ops.prompt({
132-
messageID,
133-
sessionID: nextSession.id,
134-
model: {
135-
modelID: model.modelID,
136-
providerID: model.providerID,
137-
},
138-
agent: next.name,
139-
tools: {
140-
...(canTodo ? {} : { todowrite: false }),
141-
...(canTask ? {} : { task: false }),
142-
...Object.fromEntries((cfg.experimental?.primary_tools ?? []).map((item) => [item, false])),
143-
},
144-
parts,
145-
})
146-
265+
const text = yield* runTask()
147266
return {
148267
title: params.description,
149-
metadata: {
150-
sessionId: nextSession.id,
151-
model,
152-
},
153-
output: [
154-
`task_id: ${nextSession.id} (for resuming to continue this task if needed)`,
155-
"",
156-
"<task_result>",
157-
result.parts.findLast((item) => item.type === "text")?.text ?? "",
158-
"</task_result>",
159-
].join("\n"),
268+
metadata,
269+
output: output(nextSession.id, text),
160270
}
161271
}),
162272
() =>

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)