Skip to content

Commit dabf2dc

Browse files
authored
remove the need for polling from experimental background agents (#29179)
1 parent 7753211 commit dabf2dc

11 files changed

Lines changed: 63 additions & 431 deletions

File tree

packages/opencode/src/session/prompt.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,6 @@ export const layer = Layer.effect(
134134
cancel: (sessionID: SessionID) => cancel(sessionID),
135135
resolvePromptParts: (template: string) => resolvePromptParts(template),
136136
prompt: (input: PromptInput) => prompt(input).pipe(Effect.catch(Effect.die)),
137-
loop: (input: LoopInput) => loop(input),
138137
} satisfies TaskPromptOps
139138
})
140139

packages/opencode/src/tool/registry.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ 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"
1110
import { TodoWriteTool } from "./todo"
1211
import { WebFetchTool } from "./webfetch"
1312
import { WriteTool } from "./write"
@@ -53,7 +52,6 @@ import { Skill } from "../skill"
5352
import { Permission } from "@/permission"
5453
import { Reference } from "@/reference/reference"
5554
import { BackgroundJob } from "@/background/job"
56-
import { SessionStatus } from "@/session/status"
5755
import { RuntimeFlags } from "@/effect/runtime-flags"
5856

5957
const log = Log.create({ service: "tool.registry" })
@@ -91,7 +89,6 @@ export const layer: Layer.Layer<
9189
| Agent.Service
9290
| Skill.Service
9391
| Session.Service
94-
| SessionStatus.Service
9592
| BackgroundJob.Service
9693
| Provider.Service
9794
| Git.Service
@@ -119,7 +116,6 @@ export const layer: Layer.Layer<
119116

120117
const invalid = yield* InvalidTool
121118
const task = yield* TaskTool
122-
const taskStatus = yield* TaskStatusTool
123119
const read = yield* ReadTool
124120
const question = yield* QuestionTool
125121
const todo = yield* TodoWriteTool
@@ -235,7 +231,6 @@ export const layer: Layer.Layer<
235231
edit: Tool.init(edit),
236232
write: Tool.init(writetool),
237233
task: Tool.init(task),
238-
task_status: Tool.init(taskStatus),
239234
fetch: Tool.init(webfetch),
240235
todo: Tool.init(todo),
241236
search: Tool.init(websearch),
@@ -260,7 +255,6 @@ export const layer: Layer.Layer<
260255
tool.edit,
261256
tool.write,
262257
tool.task,
263-
...(flags.experimentalBackgroundSubagents ? [tool.task_status] : []),
264258
tool.fetch,
265259
tool.todo,
266260
tool.search,
@@ -385,7 +379,7 @@ export const defaultLayer = Layer.suspend(() =>
385379
Layer.provide(Skill.defaultLayer),
386380
Layer.provide(Agent.defaultLayer),
387381
Layer.provide(Session.defaultLayer),
388-
Layer.provide(Layer.mergeAll(SessionStatus.defaultLayer, BackgroundJob.defaultLayer)),
382+
Layer.provide(BackgroundJob.defaultLayer),
389383
Layer.provide(Provider.defaultLayer),
390384
Layer.provide(Layer.mergeAll(Git.defaultLayer, RepositoryCache.defaultLayer)),
391385
Layer.provide(Reference.defaultLayer),

packages/opencode/src/tool/task.ts

Lines changed: 49 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -2,38 +2,36 @@ import * as Tool from "./tool"
22
import DESCRIPTION from "./task.txt"
33
import { ToolJsonSchema } from "./json-schema"
44
import { BackgroundJob } from "@/background/job"
5-
import { Bus } from "@/bus"
65
import { Session } from "@/session/session"
76
import { SessionID, MessageID } from "../session/schema"
87
import { MessageV2 } from "../session/message-v2"
98
import { Agent } from "../agent/agent"
109
import { deriveSubagentSessionPermission } from "../agent/subagent-permissions"
1110
import type { SessionPrompt } from "../session/prompt"
12-
import { SessionStatus } from "@/session/status"
1311
import { Config } from "@/config/config"
14-
import { TuiEvent } from "@/cli/cmd/tui/event"
15-
import { Cause, Effect, Exit, Option, Schema, Scope } from "effect"
12+
import { Cause, Effect, Exit, Schema, Scope } from "effect"
1613
import { EffectBridge } from "@/effect/bridge"
1714
import { RuntimeFlags } from "@/effect/runtime-flags"
1815

1916
export interface TaskPromptOps {
2017
cancel(sessionID: SessionID): Effect.Effect<void>
2118
resolvePromptParts(template: string): Effect.Effect<SessionPrompt.PromptInput["parts"]>
2219
prompt(input: SessionPrompt.PromptInput): Effect.Effect<MessageV2.WithParts>
23-
loop(input: SessionPrompt.LoopInput): Effect.Effect<MessageV2.WithParts>
2420
}
2521

2622
const id = "task"
2723
const BACKGROUND_DESCRIPTION = [
2824
"",
2925
"",
3026
[
31-
"Background mode: background=true launches the subagent asynchronously.",
32-
"Use task_status(task_id=..., wait=false) to poll, or wait=true to block until done.",
27+
"Background mode: background=true launches the subagent asynchronously and returns immediately.",
28+
"Foreground is the default; use it when you need the result before continuing.",
29+
"Use background only for independent work that can run while you continue elsewhere.",
30+
"You will be notified automatically when it finishes.",
3331
].join(" "),
3432
].join("\n")
3533

36-
const BaseParameters = Schema.Struct({
34+
const BaseParameterFields = {
3735
description: Schema.String.annotate({ description: "A short (3-5 words) description of the task" }),
3836
prompt: Schema.String.annotate({ description: "The task for the agent to perform" }),
3937
subagent_type: Schema.String.annotate({ description: "The type of specialized agent to use for this task" }),
@@ -42,40 +40,32 @@ const BaseParameters = Schema.Struct({
4240
"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)",
4341
}),
4442
command: Schema.optional(Schema.String).annotate({ description: "The command that triggered this task" }),
45-
})
43+
}
44+
45+
const BaseParameters = Schema.Struct(BaseParameterFields)
4646

4747
export const Parameters = Schema.Struct({
48-
description: Schema.String.annotate({ description: "A short (3-5 words) description of the task" }),
49-
prompt: Schema.String.annotate({ description: "The task for the agent to perform" }),
50-
subagent_type: Schema.String.annotate({ description: "The type of specialized agent to use for this task" }),
51-
task_id: Schema.optional(Schema.String).annotate({
52-
description:
53-
"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)",
54-
}),
55-
command: Schema.optional(Schema.String).annotate({ description: "The command that triggered this task" }),
48+
...BaseParameterFields,
5649
background: Schema.optional(Schema.Boolean).annotate({
57-
description: "When true, launch the subagent in the background and return immediately",
50+
description: "Run the agent in the background. You will be notified when it completes.",
5851
}),
5952
})
6053

6154
function output(sessionID: SessionID, text: string) {
62-
return [
63-
`task_id: ${sessionID} (for resuming to continue this task if needed)`,
64-
"",
65-
"<task_result>",
66-
text,
67-
"</task_result>",
68-
].join("\n")
55+
return [`<task id="${sessionID}" state="completed">`, "<task_result>", text, "</task_result>", "</task>"].join(
56+
"\n",
57+
)
6958
}
7059

7160
function backgroundOutput(sessionID: SessionID) {
7261
return [
73-
`task_id: ${sessionID} (for polling this task with task_status)`,
74-
"state: running",
75-
"",
62+
`<task id="${sessionID}" state="running">`,
63+
"<summary>Background task started</summary>",
7664
"<task_result>",
77-
"Background task started. Continue your current work and call task_status when you need the result.",
65+
"Background task started. You will be notified automatically when it finishes; do not poll for progress.",
66+
"Do not duplicate its work. Continue only with non-overlapping work, or stop if there is nothing else useful to do.",
7867
"</task_result>",
68+
"</task>",
7969
].join("\n")
8070
}
8171

@@ -90,9 +80,14 @@ function backgroundMessage(input: {
9080
input.state === "completed"
9181
? `Background task completed: ${input.description}`
9282
: `Background task failed: ${input.description}`
93-
return [title, `task_id: ${input.sessionID}`, `state: ${input.state}`, "", `<${tag}>`, input.text, `</${tag}>`].join(
94-
"\n",
95-
)
83+
return [
84+
`<task id="${input.sessionID}" state="${input.state}">`,
85+
`<summary>${title}</summary>`,
86+
`<${tag}>`,
87+
input.text,
88+
`</${tag}>`,
89+
"</task>",
90+
].join("\n")
9691
}
9792

9893
function errorText(error: unknown) {
@@ -105,11 +100,9 @@ export const TaskTool = Tool.define(
105100
Effect.gen(function* () {
106101
const agent = yield* Agent.Service
107102
const background = yield* BackgroundJob.Service
108-
const bus = yield* Bus.Service
109103
const config = yield* Config.Service
110104
const sessions = yield* Session.Service
111105
const scope = yield* Scope.Scope
112-
const status = yield* SessionStatus.Service
113106
const flags = yield* RuntimeFlags.Service
114107

115108
const run = Effect.fn("TaskTool.execute")(function* (
@@ -141,9 +134,8 @@ export const TaskTool = Tool.define(
141134
return yield* Effect.fail(new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`))
142135
}
143136

144-
const taskID = params.task_id
145-
const session = taskID
146-
? yield* sessions.get(SessionID.make(taskID)).pipe(Effect.catchCause(() => Effect.succeed(undefined)))
137+
const session = params.task_id
138+
? yield* sessions.get(SessionID.make(params.task_id)).pipe(Effect.catchCause(() => Effect.succeed(undefined)))
147139
: undefined
148140
const parent = yield* sessions.get(ctx.sessionID)
149141
const parentAgent = parent.agent
@@ -189,7 +181,6 @@ export const TaskTool = Tool.define(
189181

190182
const ops = ctx.extra?.promptOps as TaskPromptOps
191183
if (!ops) return yield* Effect.fail(new Error("TaskTool requires promptOps in ctx.extra"))
192-
const runCancel = yield* EffectBridge.make()
193184

194185
const runTask = Effect.fn("TaskTool.runTask")(function* () {
195186
const parts = yield* ops.resolvePromptParts(params.prompt)
@@ -211,68 +202,34 @@ export const TaskTool = Tool.define(
211202
return result.parts.findLast((item) => item.type === "text")?.text ?? ""
212203
})
213204

214-
const resumeWhenIdle: (input: { userID: MessageID; state: "completed" | "error" }) => Effect.Effect<void> =
215-
Effect.fn("TaskTool.resumeWhenIdle")(function* (input: { userID: MessageID; state: "completed" | "error" }) {
216-
const latest = yield* sessions
217-
.findMessage(ctx.sessionID, (item) => item.info.role === "user")
218-
.pipe(Effect.orDie)
219-
if (Option.isNone(latest)) return
220-
if (latest.value.info.id !== input.userID) return
221-
if ((yield* status.get(ctx.sessionID)).type !== "idle") {
222-
yield* Effect.sleep("300 millis")
223-
return yield* resumeWhenIdle(input)
224-
}
225-
yield* bus.publish(TuiEvent.ToastShow, {
226-
title: input.state === "completed" ? "Background task complete" : "Background task failed",
227-
message:
228-
input.state === "completed"
229-
? `Background task "${params.description}" finished. Resuming the main thread.`
230-
: `Background task "${params.description}" failed. Resuming the main thread.`,
231-
variant: input.state === "completed" ? "success" : "error",
232-
duration: 5000,
233-
})
234-
yield* ops
235-
.loop({ sessionID: ctx.sessionID })
236-
.pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }))
237-
})
238-
239-
const continueIfIdle = Effect.fn("TaskTool.continueIfIdle")(function* (input: {
240-
userID: MessageID
241-
state: "completed" | "error"
242-
}) {
243-
yield* resumeWhenIdle(input).pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }))
244-
})
245-
246205
const inject = Effect.fn("TaskTool.injectBackgroundResult")(function* (
247206
state: "completed" | "error",
248207
text: string,
249208
) {
250209
const currentParent = yield* sessions.get(ctx.sessionID)
251-
const message = yield* ops.prompt({
252-
sessionID: ctx.sessionID,
253-
noReply: true,
254-
agent: currentParent.agent ?? ctx.agent,
255-
parts: [
256-
{
257-
type: "text",
258-
synthetic: true,
259-
text: backgroundMessage({
260-
sessionID: nextSession.id,
261-
description: params.description,
262-
state,
263-
text,
264-
}),
265-
},
266-
],
267-
})
268-
yield* continueIfIdle({ userID: message.info.id, state })
210+
yield* ops
211+
.prompt({
212+
sessionID: ctx.sessionID,
213+
agent: currentParent.agent ?? ctx.agent,
214+
parts: [
215+
{
216+
type: "text",
217+
synthetic: true,
218+
text: backgroundMessage({
219+
sessionID: nextSession.id,
220+
description: params.description,
221+
state,
222+
text,
223+
}),
224+
},
225+
],
226+
})
227+
.pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }))
269228
})
270229

271230
const existing = yield* background.get(nextSession.id)
272231
if (existing?.status === "running") {
273-
return yield* Effect.fail(
274-
new Error(`Task ${nextSession.id} is already running. Use task_status to check progress.`),
275-
)
232+
return yield* Effect.fail(new Error(`Task ${nextSession.id} is already running.`))
276233
}
277234

278235
if (runInBackground) {
@@ -302,6 +259,7 @@ export const TaskTool = Tool.define(
302259
}
303260
}
304261

262+
const runCancel = yield* EffectBridge.make()
305263
const cancel = ops.cancel(nextSession.id)
306264

307265
function onAbort() {

0 commit comments

Comments
 (0)