* fix: background task completion detection and silent notifications - Fix TS2742 by adding explicit ToolDefinition type annotations - Add stability detection (3 consecutive stable polls after 10s minimum) - Remove early continue when sessionStatus is undefined - Add silent notification system via tool.execute.after hook injection - Change task retention from 200ms to 5 minutes for background_output retrieval - Fix formatTaskResult to sort messages by time descending Fixes hanging background tasks that never complete due to missing sessionStatus. * fix: improve background task completion detection and message extraction - Add stability-based completion detection (10s min + 3 stable polls) - Fix message extraction to recognize 'reasoning' parts from thinking models - Switch from promptAsync() to prompt() for proper agent initialization - Remove model parameter from prompt body (use agent's configured model) - Add fire-and-forget prompt pattern for sisyphus_task sync mode - Add silent notification via tool.execute.after hook injection - Fix indentation issues in manager.ts and index.ts Incorporates fixes from: - PR #592: Stability detection mechanism - PR #610: Model parameter passing (partially) - PR #628: Completion detection improvements Known limitation: Thinking models (e.g. claude-*-thinking-*) cause JSON Parse errors in child sessions. Use non-thinking models for background agents until OpenCode core resolves this. * fix: add tool_result handling and pendingByParent tracking for resume/external tasks Addresses code review feedback from PR #638: P1: Add tool_result type to validateSessionHasOutput() to prevent false negatives for tool-only background tasks that would otherwise timeout after 30 minutes despite having valid results. P2: Add pendingByParent tracking to resume() and registerExternalTask() to prevent premature 'ALL COMPLETE' notifications when mixing launched and resumed tasks. * fix: address code review feedback - log messages, model passthrough, sorting, race condition - Fix misleading log messages: 'promptAsync' -> 'prompt (fire-and-forget)' - Restore model passthrough in launch() for Sisyphus category configs - Fix call-omo-agent sorting: use time.created number instead of String(time) - Fix race condition: check promptError inside polling loop, not just after 100ms
234 lines
8.6 KiB
TypeScript
234 lines
8.6 KiB
TypeScript
import { tool, type PluginInput, type ToolDefinition } from "@opencode-ai/plugin"
|
|
import { ALLOWED_AGENTS, CALL_OMO_AGENT_DESCRIPTION } from "./constants"
|
|
import type { CallOmoAgentArgs } from "./types"
|
|
import type { BackgroundManager } from "../../features/background-agent"
|
|
import { log } from "../../shared/logger"
|
|
|
|
type ToolContextWithMetadata = {
|
|
sessionID: string
|
|
messageID: string
|
|
agent: string
|
|
abort: AbortSignal
|
|
metadata?: (input: { title?: string; metadata?: Record<string, unknown> }) => void
|
|
}
|
|
|
|
export function createCallOmoAgent(
|
|
ctx: PluginInput,
|
|
backgroundManager: BackgroundManager
|
|
): ToolDefinition {
|
|
const agentDescriptions = ALLOWED_AGENTS.map(
|
|
(name) => `- ${name}: Specialized agent for ${name} tasks`
|
|
).join("\n")
|
|
const description = CALL_OMO_AGENT_DESCRIPTION.replace("{agents}", agentDescriptions)
|
|
|
|
return tool({
|
|
description,
|
|
args: {
|
|
description: tool.schema.string().describe("A short (3-5 words) description of the task"),
|
|
prompt: tool.schema.string().describe("The task for the agent to perform"),
|
|
subagent_type: tool.schema
|
|
.enum(ALLOWED_AGENTS)
|
|
.describe("The type of specialized agent to use for this task (explore or librarian only)"),
|
|
run_in_background: tool.schema
|
|
.boolean()
|
|
.describe("REQUIRED. true: run asynchronously (use background_output to get results), false: run synchronously and wait for completion"),
|
|
session_id: tool.schema.string().describe("Existing Task session to continue").optional(),
|
|
},
|
|
async execute(args: CallOmoAgentArgs, toolContext) {
|
|
const toolCtx = toolContext as ToolContextWithMetadata
|
|
log(`[call_omo_agent] Starting with agent: ${args.subagent_type}, background: ${args.run_in_background}`)
|
|
|
|
if (!ALLOWED_AGENTS.includes(args.subagent_type as typeof ALLOWED_AGENTS[number])) {
|
|
return `Error: Invalid agent type "${args.subagent_type}". Only ${ALLOWED_AGENTS.join(", ")} are allowed.`
|
|
}
|
|
|
|
if (args.run_in_background) {
|
|
if (args.session_id) {
|
|
return `Error: session_id is not supported in background mode. Use run_in_background=false to continue an existing session.`
|
|
}
|
|
return await executeBackground(args, toolCtx, backgroundManager)
|
|
}
|
|
|
|
return await executeSync(args, toolCtx, ctx)
|
|
},
|
|
})
|
|
}
|
|
|
|
async function executeBackground(
|
|
args: CallOmoAgentArgs,
|
|
toolContext: ToolContextWithMetadata,
|
|
manager: BackgroundManager
|
|
): Promise<string> {
|
|
try {
|
|
const task = await manager.launch({
|
|
description: args.description,
|
|
prompt: args.prompt,
|
|
agent: args.subagent_type,
|
|
parentSessionID: toolContext.sessionID,
|
|
parentMessageID: toolContext.messageID,
|
|
})
|
|
|
|
toolContext.metadata?.({
|
|
title: args.description,
|
|
metadata: { sessionId: task.sessionID },
|
|
})
|
|
|
|
return `Background agent task launched successfully.
|
|
|
|
Task ID: ${task.id}
|
|
Session ID: ${task.sessionID}
|
|
Description: ${task.description}
|
|
Agent: ${task.agent} (subagent)
|
|
Status: ${task.status}
|
|
|
|
The system will notify you when the task completes.
|
|
Use \`background_output\` tool with task_id="${task.id}" to check progress:
|
|
- block=false (default): Check status immediately - returns full status info
|
|
- block=true: Wait for completion (rarely needed since system notifies)`
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error)
|
|
return `Failed to launch background agent task: ${message}`
|
|
}
|
|
}
|
|
|
|
async function executeSync(
|
|
args: CallOmoAgentArgs,
|
|
toolContext: ToolContextWithMetadata,
|
|
ctx: PluginInput
|
|
): Promise<string> {
|
|
let sessionID: string
|
|
|
|
if (args.session_id) {
|
|
log(`[call_omo_agent] Using existing session: ${args.session_id}`)
|
|
const sessionResult = await ctx.client.session.get({
|
|
path: { id: args.session_id },
|
|
})
|
|
if (sessionResult.error) {
|
|
log(`[call_omo_agent] Session get error:`, sessionResult.error)
|
|
return `Error: Failed to get existing session: ${sessionResult.error}`
|
|
}
|
|
sessionID = args.session_id
|
|
} else {
|
|
log(`[call_omo_agent] Creating new session with parent: ${toolContext.sessionID}`)
|
|
const createResult = await ctx.client.session.create({
|
|
body: {
|
|
parentID: toolContext.sessionID,
|
|
title: `${args.description} (@${args.subagent_type} subagent)`,
|
|
},
|
|
})
|
|
|
|
if (createResult.error) {
|
|
log(`[call_omo_agent] Session create error:`, createResult.error)
|
|
return `Error: Failed to create session: ${createResult.error}`
|
|
}
|
|
|
|
sessionID = createResult.data.id
|
|
log(`[call_omo_agent] Created session: ${sessionID}`)
|
|
}
|
|
|
|
toolContext.metadata?.({
|
|
title: args.description,
|
|
metadata: { sessionId: sessionID },
|
|
})
|
|
|
|
log(`[call_omo_agent] Sending prompt to session ${sessionID}`)
|
|
log(`[call_omo_agent] Prompt text:`, args.prompt.substring(0, 100))
|
|
|
|
try {
|
|
await ctx.client.session.prompt({
|
|
path: { id: sessionID },
|
|
body: {
|
|
agent: args.subagent_type,
|
|
tools: {
|
|
task: false,
|
|
call_omo_agent: false,
|
|
sisyphus_task: false,
|
|
},
|
|
parts: [{ type: "text", text: args.prompt }],
|
|
},
|
|
})
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error)
|
|
log(`[call_omo_agent] Prompt error:`, errorMessage)
|
|
if (errorMessage.includes("agent.name") || errorMessage.includes("undefined")) {
|
|
return `Error: Agent "${args.subagent_type}" not found. Make sure the agent is registered in your opencode.json or provided by a plugin.\n\n<task_metadata>\nsession_id: ${sessionID}\n</task_metadata>`
|
|
}
|
|
return `Error: Failed to send prompt: ${errorMessage}\n\n<task_metadata>\nsession_id: ${sessionID}\n</task_metadata>`
|
|
}
|
|
|
|
log(`[call_omo_agent] Prompt sent, fetching messages...`)
|
|
|
|
const messagesResult = await ctx.client.session.messages({
|
|
path: { id: sessionID },
|
|
})
|
|
|
|
if (messagesResult.error) {
|
|
log(`[call_omo_agent] Messages error:`, messagesResult.error)
|
|
return `Error: Failed to get messages: ${messagesResult.error}`
|
|
}
|
|
|
|
const messages = messagesResult.data
|
|
log(`[call_omo_agent] Got ${messages.length} messages`)
|
|
|
|
// Include both assistant messages AND tool messages
|
|
// Tool results (grep, glob, bash output) come from role "tool"
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const relevantMessages = messages.filter(
|
|
(m: any) => m.info?.role === "assistant" || m.info?.role === "tool"
|
|
)
|
|
|
|
if (relevantMessages.length === 0) {
|
|
log(`[call_omo_agent] No assistant or tool messages found`)
|
|
log(`[call_omo_agent] All messages:`, JSON.stringify(messages, null, 2))
|
|
return `Error: No assistant or tool response found\n\n<task_metadata>\nsession_id: ${sessionID}\n</task_metadata>`
|
|
}
|
|
|
|
log(`[call_omo_agent] Found ${relevantMessages.length} relevant messages`)
|
|
|
|
// Sort by time ascending (oldest first) to process messages in order
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const sortedMessages = [...relevantMessages].sort((a: any, b: any) => {
|
|
const timeA = a.info?.time?.created ?? 0
|
|
const timeB = b.info?.time?.created ?? 0
|
|
return timeA - timeB
|
|
})
|
|
|
|
// Extract content from ALL messages, not just the last one
|
|
// Tool results may be in earlier messages while the final message is empty
|
|
const extractedContent: string[] = []
|
|
|
|
for (const message of sortedMessages) {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
for (const part of (message as any).parts ?? []) {
|
|
// Handle both "text" and "reasoning" parts (thinking models use "reasoning")
|
|
if ((part.type === "text" || part.type === "reasoning") && part.text) {
|
|
extractedContent.push(part.text)
|
|
} else if (part.type === "tool_result") {
|
|
// Tool results contain the actual output from tool calls
|
|
const toolResult = part as { content?: string | Array<{ type: string; text?: string }> }
|
|
if (typeof toolResult.content === "string" && toolResult.content) {
|
|
extractedContent.push(toolResult.content)
|
|
} else if (Array.isArray(toolResult.content)) {
|
|
// Handle array of content blocks
|
|
for (const block of toolResult.content) {
|
|
if ((block.type === "text" || block.type === "reasoning") && block.text) {
|
|
extractedContent.push(block.text)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const responseText = extractedContent
|
|
.filter((text) => text.length > 0)
|
|
.join("\n\n")
|
|
|
|
log(`[call_omo_agent] Got response, length: ${responseText.length}`)
|
|
|
|
const output =
|
|
responseText + "\n\n" + ["<task_metadata>", `session_id: ${sessionID}`, "</task_metadata>"].join("\n")
|
|
|
|
return output
|
|
}
|