Skip to content

Commit 5940d2e

Browse files
committed
Revert "refactor(tools): remove background-task tool"
This reverts commit 6dbc4c095badd400e024510554a42a0dc018ae42.
1 parent 99d45f2 commit 5940d2e

File tree

6 files changed

+415
-4
lines changed

6 files changed

+415
-4
lines changed

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
228228
const backgroundNotificationHook = isHookEnabled("background-notification")
229229
? createBackgroundNotificationHook(backgroundManager)
230230
: null;
231-
const backgroundTools = createBackgroundTools();
231+
const backgroundTools = createBackgroundTools(backgroundManager, ctx.client);
232232

233233
const callOmoAgent = createCallOmoAgent(ctx, backgroundManager);
234234
const lookAt = createLookAt(ctx);
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export const BACKGROUND_TASK_DESCRIPTION = `Run agent task in background. Returns task_id immediately; notifies on completion.
2+
3+
Use \`background_output\` to get results. Prompts MUST be in English.`
4+
5+
export const BACKGROUND_OUTPUT_DESCRIPTION = `Get output from background task. System notifies on completion, so block=true rarely needed.`
6+
7+
export const BACKGROUND_CANCEL_DESCRIPTION = `Cancel running background task(s). Use all=true to cancel ALL before final answer.`

src/tools/background-task/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export {
2+
createBackgroundOutput,
3+
createBackgroundCancel,
4+
} from "./tools"
5+
6+
export type * from "./types"
7+
export * from "./constants"

src/tools/background-task/tools.ts

Lines changed: 370 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,370 @@
1+
import { tool, type PluginInput, type ToolDefinition } from "@opencode-ai/plugin"
2+
import { existsSync, readdirSync } from "node:fs"
3+
import { join } from "node:path"
4+
import type { BackgroundManager, BackgroundTask } from "../../features/background-agent"
5+
import type { BackgroundTaskArgs, BackgroundOutputArgs, BackgroundCancelArgs } from "./types"
6+
import { BACKGROUND_TASK_DESCRIPTION, BACKGROUND_OUTPUT_DESCRIPTION, BACKGROUND_CANCEL_DESCRIPTION } from "./constants"
7+
import { findNearestMessageWithFields, MESSAGE_STORAGE } from "../../features/hook-message-injector"
8+
9+
type OpencodeClient = PluginInput["client"]
10+
11+
function getMessageDir(sessionID: string): string | null {
12+
if (!existsSync(MESSAGE_STORAGE)) return null
13+
14+
const directPath = join(MESSAGE_STORAGE, sessionID)
15+
if (existsSync(directPath)) return directPath
16+
17+
for (const dir of readdirSync(MESSAGE_STORAGE)) {
18+
const sessionPath = join(MESSAGE_STORAGE, dir, sessionID)
19+
if (existsSync(sessionPath)) return sessionPath
20+
}
21+
22+
return null
23+
}
24+
25+
function formatDuration(start: Date, end?: Date): string {
26+
const duration = (end ?? new Date()).getTime() - start.getTime()
27+
const seconds = Math.floor(duration / 1000)
28+
const minutes = Math.floor(seconds / 60)
29+
const hours = Math.floor(minutes / 60)
30+
31+
if (hours > 0) {
32+
return `${hours}h ${minutes % 60}m ${seconds % 60}s`
33+
} else if (minutes > 0) {
34+
return `${minutes}m ${seconds % 60}s`
35+
} else {
36+
return `${seconds}s`
37+
}
38+
}
39+
40+
type ToolContextWithMetadata = {
41+
sessionID: string
42+
messageID: string
43+
agent: string
44+
abort: AbortSignal
45+
metadata?: (input: { title?: string; metadata?: Record<string, unknown> }) => void
46+
}
47+
48+
export function createBackgroundTask(manager: BackgroundManager): ToolDefinition {
49+
return tool({
50+
description: BACKGROUND_TASK_DESCRIPTION,
51+
args: {
52+
description: tool.schema.string().describe("Short task description (shown in status)"),
53+
prompt: tool.schema.string().describe("Full detailed prompt for the agent"),
54+
agent: tool.schema.string().describe("Agent type to use (any registered agent)"),
55+
},
56+
async execute(args: BackgroundTaskArgs, toolContext) {
57+
const ctx = toolContext as ToolContextWithMetadata
58+
59+
if (!args.agent || args.agent.trim() === "") {
60+
return `❌ Agent parameter is required. Please specify which agent to use (e.g., "explore", "librarian", "build", etc.)`
61+
}
62+
63+
try {
64+
const messageDir = getMessageDir(ctx.sessionID)
65+
const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
66+
const parentModel = prevMessage?.model?.providerID && prevMessage?.model?.modelID
67+
? { providerID: prevMessage.model.providerID, modelID: prevMessage.model.modelID }
68+
: undefined
69+
70+
const task = await manager.launch({
71+
description: args.description,
72+
prompt: args.prompt,
73+
agent: args.agent.trim(),
74+
parentSessionID: ctx.sessionID,
75+
parentMessageID: ctx.messageID,
76+
parentModel,
77+
})
78+
79+
ctx.metadata?.({
80+
title: args.description,
81+
metadata: { sessionId: task.sessionID },
82+
})
83+
84+
return `Background task launched successfully.
85+
86+
Task ID: ${task.id}
87+
Session ID: ${task.sessionID}
88+
Description: ${task.description}
89+
Agent: ${task.agent}
90+
Status: ${task.status}
91+
92+
The system will notify you when the task completes.
93+
Use \`background_output\` tool with task_id="${task.id}" to check progress:
94+
- block=false (default): Check status immediately - returns full status info
95+
- block=true: Wait for completion (rarely needed since system notifies)`
96+
} catch (error) {
97+
const message = error instanceof Error ? error.message : String(error)
98+
return `❌ Failed to launch background task: ${message}`
99+
}
100+
},
101+
})
102+
}
103+
104+
function delay(ms: number): Promise<void> {
105+
return new Promise(resolve => setTimeout(resolve, ms))
106+
}
107+
108+
function truncateText(text: string, maxLength: number): string {
109+
if (text.length <= maxLength) return text
110+
return text.slice(0, maxLength) + "..."
111+
}
112+
113+
function formatTaskStatus(task: BackgroundTask): string {
114+
const duration = formatDuration(task.startedAt, task.completedAt)
115+
const promptPreview = truncateText(task.prompt, 500)
116+
117+
let progressSection = ""
118+
if (task.progress?.lastTool) {
119+
progressSection = `\n| Last tool | ${task.progress.lastTool} |`
120+
}
121+
122+
let lastMessageSection = ""
123+
if (task.progress?.lastMessage) {
124+
const truncated = truncateText(task.progress.lastMessage, 500)
125+
const messageTime = task.progress.lastMessageAt
126+
? task.progress.lastMessageAt.toISOString()
127+
: "N/A"
128+
lastMessageSection = `
129+
130+
## Last Message (${messageTime})
131+
132+
\`\`\`
133+
${truncated}
134+
\`\`\``
135+
}
136+
137+
let statusNote = ""
138+
if (task.status === "running") {
139+
statusNote = `
140+
141+
> **Note**: No need to wait explicitly - the system will notify you when this task completes.`
142+
} else if (task.status === "error") {
143+
statusNote = `
144+
145+
> **Failed**: The task encountered an error. Check the last message for details.`
146+
}
147+
148+
return `# Task Status
149+
150+
| Field | Value |
151+
|-------|-------|
152+
| Task ID | \`${task.id}\` |
153+
| Description | ${task.description} |
154+
| Agent | ${task.agent} |
155+
| Status | **${task.status}** |
156+
| Duration | ${duration} |
157+
| Session ID | \`${task.sessionID}\` |${progressSection}
158+
${statusNote}
159+
## Original Prompt
160+
161+
\`\`\`
162+
${promptPreview}
163+
\`\`\`${lastMessageSection}`
164+
}
165+
166+
async function formatTaskResult(task: BackgroundTask, client: OpencodeClient): Promise<string> {
167+
const messagesResult = await client.session.messages({
168+
path: { id: task.sessionID },
169+
})
170+
171+
if (messagesResult.error) {
172+
return `Error fetching messages: ${messagesResult.error}`
173+
}
174+
175+
// Handle both SDK response structures: direct array or wrapped in .data
176+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
177+
const messages = ((messagesResult as any).data ?? messagesResult) as Array<{
178+
info?: { role?: string }
179+
parts?: Array<{ type?: string; text?: string }>
180+
}>
181+
182+
if (!Array.isArray(messages) || messages.length === 0) {
183+
return `Task Result
184+
185+
Task ID: ${task.id}
186+
Description: ${task.description}
187+
Duration: ${formatDuration(task.startedAt, task.completedAt)}
188+
Session ID: ${task.sessionID}
189+
190+
---
191+
192+
(No messages found)`
193+
}
194+
195+
const assistantMessages = messages.filter(
196+
(m) => m.info?.role === "assistant"
197+
)
198+
199+
if (assistantMessages.length === 0) {
200+
return `Task Result
201+
202+
Task ID: ${task.id}
203+
Description: ${task.description}
204+
Duration: ${formatDuration(task.startedAt, task.completedAt)}
205+
Session ID: ${task.sessionID}
206+
207+
---
208+
209+
(No assistant response found)`
210+
}
211+
212+
const lastMessage = assistantMessages[assistantMessages.length - 1]
213+
const textParts = lastMessage?.parts?.filter(
214+
(p) => p.type === "text"
215+
) ?? []
216+
const textContent = textParts
217+
.map((p) => p.text ?? "")
218+
.filter((text) => text.length > 0)
219+
.join("\n")
220+
221+
const duration = formatDuration(task.startedAt, task.completedAt)
222+
223+
return `Task Result
224+
225+
Task ID: ${task.id}
226+
Description: ${task.description}
227+
Duration: ${duration}
228+
Session ID: ${task.sessionID}
229+
230+
---
231+
232+
${textContent || "(No text output)"}`
233+
}
234+
235+
export function createBackgroundOutput(manager: BackgroundManager, client: OpencodeClient): ToolDefinition {
236+
return tool({
237+
description: BACKGROUND_OUTPUT_DESCRIPTION,
238+
args: {
239+
task_id: tool.schema.string().describe("Task ID to get output from"),
240+
block: tool.schema.boolean().optional().describe("Wait for completion (default: false). System notifies when done, so blocking is rarely needed."),
241+
timeout: tool.schema.number().optional().describe("Max wait time in ms (default: 60000, max: 600000)"),
242+
},
243+
async execute(args: BackgroundOutputArgs) {
244+
try {
245+
const task = manager.getTask(args.task_id)
246+
if (!task) {
247+
return `Task not found: ${args.task_id}`
248+
}
249+
250+
const shouldBlock = args.block === true
251+
const timeoutMs = Math.min(args.timeout ?? 60000, 600000)
252+
253+
// Already completed: return result immediately (regardless of block flag)
254+
if (task.status === "completed") {
255+
return await formatTaskResult(task, client)
256+
}
257+
258+
// Error or cancelled: return status immediately
259+
if (task.status === "error" || task.status === "cancelled") {
260+
return formatTaskStatus(task)
261+
}
262+
263+
// Non-blocking and still running: return status
264+
if (!shouldBlock) {
265+
return formatTaskStatus(task)
266+
}
267+
268+
// Blocking: poll until completion or timeout
269+
const startTime = Date.now()
270+
271+
while (Date.now() - startTime < timeoutMs) {
272+
await delay(1000)
273+
274+
const currentTask = manager.getTask(args.task_id)
275+
if (!currentTask) {
276+
return `Task was deleted: ${args.task_id}`
277+
}
278+
279+
if (currentTask.status === "completed") {
280+
return await formatTaskResult(currentTask, client)
281+
}
282+
283+
if (currentTask.status === "error" || currentTask.status === "cancelled") {
284+
return formatTaskStatus(currentTask)
285+
}
286+
}
287+
288+
// Timeout exceeded: return current status
289+
const finalTask = manager.getTask(args.task_id)
290+
if (!finalTask) {
291+
return `Task was deleted: ${args.task_id}`
292+
}
293+
return `Timeout exceeded (${timeoutMs}ms). Task still ${finalTask.status}.\n\n${formatTaskStatus(finalTask)}`
294+
} catch (error) {
295+
return `Error getting output: ${error instanceof Error ? error.message : String(error)}`
296+
}
297+
},
298+
})
299+
}
300+
301+
export function createBackgroundCancel(manager: BackgroundManager, client: OpencodeClient): ToolDefinition {
302+
return tool({
303+
description: BACKGROUND_CANCEL_DESCRIPTION,
304+
args: {
305+
taskId: tool.schema.string().optional().describe("Task ID to cancel (required if all=false)"),
306+
all: tool.schema.boolean().optional().describe("Cancel all running background tasks (default: false)"),
307+
},
308+
async execute(args: BackgroundCancelArgs, toolContext) {
309+
try {
310+
const cancelAll = args.all === true
311+
312+
if (!cancelAll && !args.taskId) {
313+
return `❌ Invalid arguments: Either provide a taskId or set all=true to cancel all running tasks.`
314+
}
315+
316+
if (cancelAll) {
317+
const tasks = manager.getAllDescendantTasks(toolContext.sessionID)
318+
const runningTasks = tasks.filter(t => t.status === "running")
319+
320+
if (runningTasks.length === 0) {
321+
return `✅ No running background tasks to cancel.`
322+
}
323+
324+
const results: string[] = []
325+
for (const task of runningTasks) {
326+
client.session.abort({
327+
path: { id: task.sessionID },
328+
}).catch(() => {})
329+
330+
task.status = "cancelled"
331+
task.completedAt = new Date()
332+
results.push(`- ${task.id}: ${task.description}`)
333+
}
334+
335+
return `✅ Cancelled ${runningTasks.length} background task(s):
336+
337+
${results.join("\n")}`
338+
}
339+
340+
const task = manager.getTask(args.taskId!)
341+
if (!task) {
342+
return `❌ Task not found: ${args.taskId}`
343+
}
344+
345+
if (task.status !== "running") {
346+
return `❌ Cannot cancel task: current status is "${task.status}".
347+
Only running tasks can be cancelled.`
348+
}
349+
350+
// Fire-and-forget: abort 요청을 보내고 await 하지 않음
351+
// await 하면 메인 세션까지 abort 되는 문제 발생
352+
client.session.abort({
353+
path: { id: task.sessionID },
354+
}).catch(() => {})
355+
356+
task.status = "cancelled"
357+
task.completedAt = new Date()
358+
359+
return `✅ Task cancelled successfully
360+
361+
Task ID: ${task.id}
362+
Description: ${task.description}
363+
Session ID: ${task.sessionID}
364+
Status: ${task.status}`
365+
} catch (error) {
366+
return `❌ Error cancelling task: ${error instanceof Error ? error.message : String(error)}`
367+
}
368+
},
369+
})
370+
}

0 commit comments

Comments
 (0)