diff --git a/src/shared/task-output.ts b/src/shared/task-output.ts new file mode 100644 index 00000000..933aa41d --- /dev/null +++ b/src/shared/task-output.ts @@ -0,0 +1,41 @@ +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { log } from "./logger"; + +export const INLINE_OUTPUT_MAX_CHARS = 15000; + +export function getTaskOutputsDir(): string { + const dir = join(tmpdir(), "opencode-task-outputs"); + if (!existsSync(dir)) { + try { + mkdirSync(dir, { recursive: true }); + } catch (error) { + log(`[task-output] Failed to create output directory: ${error}`); + } + } + return dir; +} + +export function saveOutputToFile( + toolName: string, + id: string, + content: string, +): string | null { + try { + const outputDir = getTaskOutputsDir(); + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + const filename = `${toolName}_${id}_${timestamp}.md`; + const filePath = join(outputDir, filename); + writeFileSync(filePath, content, "utf-8"); + return filePath; + } catch (error) { + log(`[task-output] Failed to save output to file: ${error}`); + return null; + } +} + +export function truncateText(text: string, maxLength: number): string { + if (text.length <= maxLength) return text; + return text.slice(0, maxLength) + "..."; +} diff --git a/src/tools/background-task/tools.ts b/src/tools/background-task/tools.ts index 2b740135..36657a4b 100644 --- a/src/tools/background-task/tools.ts +++ b/src/tools/background-task/tools.ts @@ -1,72 +1,103 @@ -import { tool, type PluginInput, type ToolDefinition } from "@opencode-ai/plugin" -import { existsSync, readdirSync } from "node:fs" -import { join } from "node:path" -import type { BackgroundManager, BackgroundTask } from "../../features/background-agent" -import type { BackgroundTaskArgs, BackgroundOutputArgs, BackgroundCancelArgs } from "./types" -import { BACKGROUND_TASK_DESCRIPTION, BACKGROUND_OUTPUT_DESCRIPTION, BACKGROUND_CANCEL_DESCRIPTION } from "./constants" -import { findNearestMessageWithFields, MESSAGE_STORAGE } from "../../features/hook-message-injector" - -type OpencodeClient = PluginInput["client"] +import { tool, type PluginInput } from "@opencode-ai/plugin"; +import { existsSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import type { + BackgroundManager, + BackgroundTask, +} from "../../features/background-agent"; +import type { + BackgroundTaskArgs, + BackgroundOutputArgs, + BackgroundCancelArgs, +} from "./types"; +import { + BACKGROUND_TASK_DESCRIPTION, + BACKGROUND_OUTPUT_DESCRIPTION, + BACKGROUND_CANCEL_DESCRIPTION, +} from "./constants"; +import { + findNearestMessageWithFields, + MESSAGE_STORAGE, +} from "../../features/hook-message-injector"; +import { + INLINE_OUTPUT_MAX_CHARS, + saveOutputToFile, + truncateText, +} from "../../shared/task-output"; + +type OpencodeClient = PluginInput["client"]; function getMessageDir(sessionID: string): string | null { - if (!existsSync(MESSAGE_STORAGE)) return null + if (!existsSync(MESSAGE_STORAGE)) return null; - const directPath = join(MESSAGE_STORAGE, sessionID) - if (existsSync(directPath)) return directPath + const directPath = join(MESSAGE_STORAGE, sessionID); + if (existsSync(directPath)) return directPath; - for (const dir of readdirSync(MESSAGE_STORAGE)) { - const sessionPath = join(MESSAGE_STORAGE, dir, sessionID) - if (existsSync(sessionPath)) return sessionPath - } + for (const dir of readdirSync(MESSAGE_STORAGE)) { + const sessionPath = join(MESSAGE_STORAGE, dir, sessionID); + if (existsSync(sessionPath)) return sessionPath; + } - return null + return null; } function formatDuration(start: Date, end?: Date): string { - const duration = (end ?? new Date()).getTime() - start.getTime() - const seconds = Math.floor(duration / 1000) - const minutes = Math.floor(seconds / 60) - const hours = Math.floor(minutes / 60) - - if (hours > 0) { - return `${hours}h ${minutes % 60}m ${seconds % 60}s` - } else if (minutes > 0) { - return `${minutes}m ${seconds % 60}s` - } else { - return `${seconds}s` - } + const duration = (end ?? new Date()).getTime() - start.getTime(); + const seconds = Math.floor(duration / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + + if (hours > 0) { + return `${hours}h ${minutes % 60}m ${seconds % 60}s`; + } else if (minutes > 0) { + return `${minutes}m ${seconds % 60}s`; + } else { + return `${seconds}s`; + } } -export function createBackgroundTask(manager: BackgroundManager): ToolDefinition { - return tool({ - description: BACKGROUND_TASK_DESCRIPTION, - args: { - description: tool.schema.string().describe("Short task description (shown in status)"), - prompt: tool.schema.string().describe("Full detailed prompt for the agent"), - agent: tool.schema.string().describe("Agent type to use (any registered agent)"), - }, - async execute(args: BackgroundTaskArgs, toolContext) { - if (!args.agent || args.agent.trim() === "") { - return `❌ Agent parameter is required. Please specify which agent to use (e.g., "explore", "librarian", "build", etc.)` - } - - try { - const messageDir = getMessageDir(toolContext.sessionID) - const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null - const parentModel = prevMessage?.model?.providerID && prevMessage?.model?.modelID - ? { providerID: prevMessage.model.providerID, modelID: prevMessage.model.modelID } - : undefined - - const task = await manager.launch({ - description: args.description, - prompt: args.prompt, - agent: args.agent.trim(), - parentSessionID: toolContext.sessionID, - parentMessageID: toolContext.messageID, - parentModel, - }) - - return `Background task launched successfully. +export function createBackgroundTask(manager: BackgroundManager) { + return tool({ + description: BACKGROUND_TASK_DESCRIPTION, + args: { + description: tool.schema + .string() + .describe("Short task description (shown in status)"), + prompt: tool.schema + .string() + .describe("Full detailed prompt for the agent"), + agent: tool.schema + .string() + .describe("Agent type to use (any registered agent)"), + }, + async execute(args: BackgroundTaskArgs, toolContext) { + if (!args.agent || args.agent.trim() === "") { + return `❌ Agent parameter is required. Please specify which agent to use (e.g., "explore", "librarian", "build", etc.)`; + } + + try { + const messageDir = getMessageDir(toolContext.sessionID); + const prevMessage = messageDir + ? findNearestMessageWithFields(messageDir) + : null; + const parentModel = + prevMessage?.model?.providerID && prevMessage?.model?.modelID + ? { + providerID: prevMessage.model.providerID, + modelID: prevMessage.model.modelID, + } + : undefined; + + const task = await manager.launch({ + description: args.description, + prompt: args.prompt, + agent: args.agent.trim(), + parentSessionID: toolContext.sessionID, + parentMessageID: toolContext.messageID, + parentModel, + }); + + return `Background task launched successfully. Task ID: ${task.id} Session ID: ${task.sessionID} @@ -77,60 +108,55 @@ 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 task: ${message}` - } - }, - }) +- 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 task: ${message}`; + } + }, + }); } function delay(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)) -} - -function truncateText(text: string, maxLength: number): string { - if (text.length <= maxLength) return text - return text.slice(0, maxLength) + "..." + return new Promise((resolve) => setTimeout(resolve, ms)); } function formatTaskStatus(task: BackgroundTask): string { - const duration = formatDuration(task.startedAt, task.completedAt) - const promptPreview = truncateText(task.prompt, 500) - - let progressSection = "" - if (task.progress?.lastTool) { - progressSection = `\n| Last tool | ${task.progress.lastTool} |` - } - - let lastMessageSection = "" - if (task.progress?.lastMessage) { - const truncated = truncateText(task.progress.lastMessage, 500) - const messageTime = task.progress.lastMessageAt - ? task.progress.lastMessageAt.toISOString() - : "N/A" - lastMessageSection = ` + const duration = formatDuration(task.startedAt, task.completedAt); + const promptPreview = truncateText(task.prompt, 500); + + let progressSection = ""; + if (task.progress?.lastTool) { + progressSection = `\n| Last tool | ${task.progress.lastTool} |`; + } + + let lastMessageSection = ""; + if (task.progress?.lastMessage) { + const truncated = truncateText(task.progress.lastMessage, 500); + const messageTime = task.progress.lastMessageAt + ? task.progress.lastMessageAt.toISOString() + : "N/A"; + lastMessageSection = ` ## Last Message (${messageTime}) \`\`\` ${truncated} -\`\`\`` - } +\`\`\``; + } - let statusNote = "" - if (task.status === "running") { - statusNote = ` + let statusNote = ""; + if (task.status === "running") { + statusNote = ` -> **Note**: No need to wait explicitly - the system will notify you when this task completes.` - } else if (task.status === "error") { - statusNote = ` +> **Note**: No need to wait explicitly - the system will notify you when this task completes.`; + } else if (task.status === "error") { + statusNote = ` -> **Failed**: The task encountered an error. Check the last message for details.` - } +> **Failed**: The task encountered an error. Check the last message for details.`; + } - return `# Task Status + return `# Task Status | Field | Value | |-------|-------| @@ -145,27 +171,30 @@ ${statusNote} \`\`\` ${promptPreview} -\`\`\`${lastMessageSection}` +\`\`\`${lastMessageSection}`; } -async function formatTaskResult(task: BackgroundTask, client: OpencodeClient): Promise { - const messagesResult = await client.session.messages({ - path: { id: task.sessionID }, - }) +async function formatTaskResult( + task: BackgroundTask, + client: OpencodeClient, +): Promise { + const messagesResult = await client.session.messages({ + path: { id: task.sessionID }, + }); - if (messagesResult.error) { - return `Error fetching messages: ${messagesResult.error}` - } + if (messagesResult.error) { + return `Error fetching messages: ${messagesResult.error}`; + } - // Handle both SDK response structures: direct array or wrapped in .data - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const messages = ((messagesResult as any).data ?? messagesResult) as Array<{ - info?: { role?: string } - parts?: Array<{ type?: string; text?: string }> - }> + // Handle both SDK response structures: direct array or wrapped in .data + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const messages = ((messagesResult as any).data ?? messagesResult) as Array<{ + info?: { role?: string }; + parts?: Array<{ type?: string; text?: string }>; + }>; - if (!Array.isArray(messages) || messages.length === 0) { - return `Task Result + if (!Array.isArray(messages) || messages.length === 0) { + return `Task Result Task ID: ${task.id} Description: ${task.description} @@ -174,15 +203,15 @@ Session ID: ${task.sessionID} --- -(No messages found)` - } +(No messages found)`; + } - const assistantMessages = messages.filter( - (m) => m.info?.role === "assistant" - ) + const assistantMessages = messages.filter( + (m) => m.info?.role === "assistant", + ); - if (assistantMessages.length === 0) { - return `Task Result + if (assistantMessages.length === 0) { + return `Task Result Task ID: ${task.id} Description: ${task.description} @@ -191,21 +220,47 @@ Session ID: ${task.sessionID} --- -(No assistant response found)` - } +(No assistant response found)`; + } + + const lastMessage = assistantMessages[assistantMessages.length - 1]; + const textParts = lastMessage?.parts?.filter((p) => p.type === "text") ?? []; + const textContent = textParts + .map((p) => p.text ?? "") + .filter((text) => text.length > 0) + .join("\n"); + + const duration = formatDuration(task.startedAt, task.completedAt); + + if (textContent.length > INLINE_OUTPUT_MAX_CHARS) { + const filePath = saveOutputToFile("background-task", task.id, textContent); + if (filePath) { + const preview = truncateText(textContent, 2000); + const charCount = textContent.length.toLocaleString(); + return `Task Result (Large Output) + +Task ID: ${task.id} +Description: ${task.description} +Duration: ${duration} +Session ID: ${task.sessionID} + +**Output exceeds inline limit** (${charCount} characters). Full output saved to: +${filePath} + +Use the Read tool with offset and limit parameters to read specific portions, or the Grep tool to search for specific content. + +**REQUIREMENTS FOR SUMMARIZATION/ANALYSIS/REVIEW:** +- You MUST read the content from the file at ${filePath} in sequential chunks until 100% of the content has been read. +- If you receive truncation warnings when reading, reduce the chunk size until you have read 100% without truncation. +- Before producing ANY summary or analysis, you MUST explicitly state what portion of the content you have read. - const lastMessage = assistantMessages[assistantMessages.length - 1] - const textParts = lastMessage?.parts?.filter( - (p) => p.type === "text" - ) ?? [] - const textContent = textParts - .map((p) => p.text ?? "") - .filter((text) => text.length > 0) - .join("\n") +## Preview (first 2000 chars) - const duration = formatDuration(task.startedAt, task.completedAt) +${preview}`; + } + } - return `Task Result + return `Task Result Task ID: ${task.id} Description: ${task.description} @@ -214,142 +269,169 @@ Session ID: ${task.sessionID} --- -${textContent || "(No text output)"}` +${textContent || "(No text output)"}`; } -export function createBackgroundOutput(manager: BackgroundManager, client: OpencodeClient): ToolDefinition { - return tool({ - description: BACKGROUND_OUTPUT_DESCRIPTION, - args: { - task_id: tool.schema.string().describe("Task ID to get output from"), - block: tool.schema.boolean().optional().describe("Wait for completion (default: false). System notifies when done, so blocking is rarely needed."), - timeout: tool.schema.number().optional().describe("Max wait time in ms (default: 60000, max: 600000)"), - }, - async execute(args: BackgroundOutputArgs) { - try { - const task = manager.getTask(args.task_id) - if (!task) { - return `Task not found: ${args.task_id}` - } - - const shouldBlock = args.block === true - const timeoutMs = Math.min(args.timeout ?? 60000, 600000) - - // Already completed: return result immediately (regardless of block flag) - if (task.status === "completed") { - return await formatTaskResult(task, client) - } - - // Error or cancelled: return status immediately - if (task.status === "error" || task.status === "cancelled") { - return formatTaskStatus(task) - } - - // Non-blocking and still running: return status - if (!shouldBlock) { - return formatTaskStatus(task) - } - - // Blocking: poll until completion or timeout - const startTime = Date.now() - - while (Date.now() - startTime < timeoutMs) { - await delay(1000) - - const currentTask = manager.getTask(args.task_id) - if (!currentTask) { - return `Task was deleted: ${args.task_id}` - } - - if (currentTask.status === "completed") { - return await formatTaskResult(currentTask, client) - } - - if (currentTask.status === "error" || currentTask.status === "cancelled") { - return formatTaskStatus(currentTask) - } - } - - // Timeout exceeded: return current status - const finalTask = manager.getTask(args.task_id) - if (!finalTask) { - return `Task was deleted: ${args.task_id}` - } - return `Timeout exceeded (${timeoutMs}ms). Task still ${finalTask.status}.\n\n${formatTaskStatus(finalTask)}` - } catch (error) { - return `Error getting output: ${error instanceof Error ? error.message : String(error)}` - } - }, - }) +export function createBackgroundOutput( + manager: BackgroundManager, + client: OpencodeClient, +) { + return tool({ + description: BACKGROUND_OUTPUT_DESCRIPTION, + args: { + task_id: tool.schema.string().describe("Task ID to get output from"), + block: tool.schema + .boolean() + .optional() + .describe( + "Wait for completion (default: false). System notifies when done, so blocking is rarely needed.", + ), + timeout: tool.schema + .number() + .optional() + .describe("Max wait time in ms (default: 60000, max: 600000)"), + }, + async execute(args: BackgroundOutputArgs) { + try { + const task = manager.getTask(args.task_id); + if (!task) { + return `Task not found: ${args.task_id}`; + } + + const shouldBlock = args.block === true; + const timeoutMs = Math.min(args.timeout ?? 60000, 600000); + + // Already completed: return result immediately (regardless of block flag) + if (task.status === "completed") { + return await formatTaskResult(task, client); + } + + // Error or cancelled: return status immediately + if (task.status === "error" || task.status === "cancelled") { + return formatTaskStatus(task); + } + + // Non-blocking and still running: return status + if (!shouldBlock) { + return formatTaskStatus(task); + } + + // Blocking: poll until completion or timeout + const startTime = Date.now(); + + while (Date.now() - startTime < timeoutMs) { + await delay(1000); + + const currentTask = manager.getTask(args.task_id); + if (!currentTask) { + return `Task was deleted: ${args.task_id}`; + } + + if (currentTask.status === "completed") { + return await formatTaskResult(currentTask, client); + } + + if ( + currentTask.status === "error" || + currentTask.status === "cancelled" + ) { + return formatTaskStatus(currentTask); + } + } + + // Timeout exceeded: return current status + const finalTask = manager.getTask(args.task_id); + if (!finalTask) { + return `Task was deleted: ${args.task_id}`; + } + return `Timeout exceeded (${timeoutMs}ms). Task still ${finalTask.status}.\n\n${formatTaskStatus(finalTask)}`; + } catch (error) { + return `Error getting output: ${error instanceof Error ? error.message : String(error)}`; + } + }, + }); } -export function createBackgroundCancel(manager: BackgroundManager, client: OpencodeClient): ToolDefinition { - return tool({ - description: BACKGROUND_CANCEL_DESCRIPTION, - args: { - taskId: tool.schema.string().optional().describe("Task ID to cancel (required if all=false)"), - all: tool.schema.boolean().optional().describe("Cancel all running background tasks (default: false)"), - }, - async execute(args: BackgroundCancelArgs, toolContext) { - try { - const cancelAll = args.all === true - - if (!cancelAll && !args.taskId) { - return `❌ Invalid arguments: Either provide a taskId or set all=true to cancel all running tasks.` - } - - if (cancelAll) { - const tasks = manager.getAllDescendantTasks(toolContext.sessionID) - const runningTasks = tasks.filter(t => t.status === "running") - - if (runningTasks.length === 0) { - return `✅ No running background tasks to cancel.` - } - - const results: string[] = [] - for (const task of runningTasks) { - client.session.abort({ - path: { id: task.sessionID }, - }).catch(() => {}) - - task.status = "cancelled" - task.completedAt = new Date() - results.push(`- ${task.id}: ${task.description}`) - } - - return `✅ Cancelled ${runningTasks.length} background task(s): - -${results.join("\n")}` - } - - const task = manager.getTask(args.taskId!) - if (!task) { - return `❌ Task not found: ${args.taskId}` - } - - if (task.status !== "running") { - return `❌ Cannot cancel task: current status is "${task.status}". -Only running tasks can be cancelled.` - } - - // Fire-and-forget: abort 요청을 보내고 await 하지 않음 - // await 하면 메인 세션까지 abort 되는 문제 발생 - client.session.abort({ - path: { id: task.sessionID }, - }).catch(() => {}) - - task.status = "cancelled" - task.completedAt = new Date() - - return `✅ Task cancelled successfully +export function createBackgroundCancel( + manager: BackgroundManager, + client: OpencodeClient, +) { + return tool({ + description: BACKGROUND_CANCEL_DESCRIPTION, + args: { + taskId: tool.schema + .string() + .optional() + .describe("Task ID to cancel (required if all=false)"), + all: tool.schema + .boolean() + .optional() + .describe("Cancel all running background tasks (default: false)"), + }, + async execute(args: BackgroundCancelArgs, toolContext) { + try { + const cancelAll = args.all === true; + + if (!cancelAll && !args.taskId) { + return `❌ Invalid arguments: Either provide a taskId or set all=true to cancel all running tasks.`; + } + + if (cancelAll) { + const tasks = manager.getAllDescendantTasks(toolContext.sessionID); + const runningTasks = tasks.filter((t) => t.status === "running"); + + if (runningTasks.length === 0) { + return `✅ No running background tasks to cancel.`; + } + + const results: string[] = []; + for (const task of runningTasks) { + client.session + .abort({ + path: { id: task.sessionID }, + }) + .catch(() => {}); + + task.status = "cancelled"; + task.completedAt = new Date(); + results.push(`- ${task.id}: ${task.description}`); + } + + return `✅ Cancelled ${runningTasks.length} background task(s): + +${results.join("\n")}`; + } + + const task = manager.getTask(args.taskId!); + if (!task) { + return `❌ Task not found: ${args.taskId}`; + } + + if (task.status !== "running") { + return `❌ Cannot cancel task: current status is "${task.status}". +Only running tasks can be cancelled.`; + } + + // Fire-and-forget: abort 요청을 보내고 await 하지 않음 + // await 하면 메인 세션까지 abort 되는 문제 발생 + client.session + .abort({ + path: { id: task.sessionID }, + }) + .catch(() => {}); + + task.status = "cancelled"; + task.completedAt = new Date(); + + return `✅ Task cancelled successfully Task ID: ${task.id} Description: ${task.description} Session ID: ${task.sessionID} -Status: ${task.status}` - } catch (error) { - return `❌ Error cancelling task: ${error instanceof Error ? error.message : String(error)}` - } - }, - }) +Status: ${task.status}`; + } catch (error) { + return `❌ Error cancelling task: ${error instanceof Error ? error.message : String(error)}`; + } + }, + }); } diff --git a/src/tools/call-omo-agent/tools.ts b/src/tools/call-omo-agent/tools.ts index 5f204fdb..7627c253 100644 --- a/src/tools/call-omo-agent/tools.ts +++ b/src/tools/call-omo-agent/tools.ts @@ -1,65 +1,90 @@ -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" +import { tool, type PluginInput } 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"; +import { + INLINE_OUTPUT_MAX_CHARS, + saveOutputToFile, + truncateText, +} from "../../shared/task-output"; 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) { - 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, toolContext, backgroundManager) - } - - return await executeSync(args, toolContext, ctx) - }, - }) + ctx: PluginInput, + backgroundManager: BackgroundManager, +) { + 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) { + 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, toolContext, backgroundManager); + } + + return await executeSync(args, toolContext, ctx); + }, + }); } async function executeBackground( - args: CallOmoAgentArgs, - toolContext: { sessionID: string; messageID: string }, - manager: BackgroundManager + args: CallOmoAgentArgs, + toolContext: { sessionID: string; messageID: string }, + manager: BackgroundManager, ): Promise { - try { - const task = await manager.launch({ - description: args.description, - prompt: args.prompt, - agent: args.subagent_type, - parentSessionID: toolContext.sessionID, - parentMessageID: toolContext.messageID, - }) + try { + const task = await manager.launch({ + description: args.description, + prompt: args.prompt, + agent: args.subagent_type, + parentSessionID: toolContext.sessionID, + parentMessageID: toolContext.messageID, + }); - return `Background agent task launched successfully. + return `Background agent task launched successfully. Task ID: ${task.id} Session ID: ${task.sessionID} @@ -70,109 +95,152 @@ 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}` - } +- 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: { sessionID: string }, - ctx: PluginInput + args: CallOmoAgentArgs, + toolContext: { sessionID: string }, + ctx: PluginInput, ): Promise { - 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}`) - } - - 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, - background_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\nsession_id: ${sessionID}\n` - } - return `Error: Failed to send prompt: ${errorMessage}\n\n\nsession_id: ${sessionID}\n` - } - - 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`) - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const lastAssistantMessage = messages - .filter((m: any) => m.info.role === "assistant") - .sort((a: any, b: any) => (b.info.time?.created || 0) - (a.info.time?.created || 0))[0] - - if (!lastAssistantMessage) { - log(`[call_omo_agent] No assistant message found`) - log(`[call_omo_agent] All messages:`, JSON.stringify(messages, null, 2)) - return `Error: No assistant response found\n\n\nsession_id: ${sessionID}\n` - } - - log(`[call_omo_agent] Found assistant message with ${lastAssistantMessage.parts.length} parts`) - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const textParts = lastAssistantMessage.parts.filter((p: any) => p.type === "text") - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const responseText = textParts.map((p: any) => p.text).join("\n") - - log(`[call_omo_agent] Got response, length: ${responseText.length}`) - - const output = - responseText + "\n\n" + ["", `session_id: ${sessionID}`, ""].join("\n") - - return output + 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}`); + } + + 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, + background_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\nsession_id: ${sessionID}\n`; + } + return `Error: Failed to send prompt: ${errorMessage}\n\n\nsession_id: ${sessionID}\n`; + } + + 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`); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const lastAssistantMessage = messages + .filter((m: any) => m.info.role === "assistant") + .sort( + (a: any, b: any) => + (b.info.time?.created || 0) - (a.info.time?.created || 0), + )[0]; + + if (!lastAssistantMessage) { + log(`[call_omo_agent] No assistant message found`); + log(`[call_omo_agent] All messages:`, JSON.stringify(messages, null, 2)); + return `Error: No assistant response found\n\n\nsession_id: ${sessionID}\n`; + } + + log( + `[call_omo_agent] Found assistant message with ${lastAssistantMessage.parts.length} parts`, + ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const textParts = lastAssistantMessage.parts.filter( + (p: any) => p.type === "text", + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const responseText = textParts.map((p: any) => p.text).join("\n"); + + log(`[call_omo_agent] Got response, length: ${responseText.length}`); + + if (responseText.length > INLINE_OUTPUT_MAX_CHARS) { + const filePath = saveOutputToFile("omo-agent", sessionID, responseText); + if (filePath) { + const preview = truncateText(responseText, 2000); + const charCount = responseText.length.toLocaleString(); + return `Agent Response (Large Output) + +**Output exceeds inline limit** (${charCount} characters). Full output saved to: +${filePath} + +Use the Read tool with offset and limit parameters to read specific portions, or the Grep tool to search for specific content. + +**REQUIREMENTS FOR SUMMARIZATION/ANALYSIS/REVIEW:** +- You MUST read the content from the file at ${filePath} in sequential chunks until 100% of the content has been read. +- If you receive truncation warnings when reading, reduce the chunk size until you have read 100% without truncation. +- Before producing ANY summary or analysis, you MUST explicitly state what portion of the content you have read. + +## Preview (first 2000 chars) + +${preview} + + +session_id: ${sessionID} +`; + } + } + + const output = + responseText + + "\n\n" + + ["", `session_id: ${sessionID}`, ""].join( + "\n", + ); + + return output; }