background.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  1. import { tool, type PluginInput, type ToolDefinition } from "@opencode-ai/plugin";
  2. import type { BackgroundTaskManager } from "../features";
  3. import { getSubagentNames } from "../agents";
  4. import {
  5. POLL_INTERVAL_MS,
  6. MAX_POLL_TIME_MS,
  7. DEFAULT_TIMEOUT_MS,
  8. STABLE_POLLS_THRESHOLD,
  9. } from "../config";
  10. import type { TmuxConfig } from "../config/schema";
  11. import type { PluginConfig } from "../config";
  12. import { applyAgentVariant, resolveAgentVariant, sleep } from "../shared";
  13. import { log } from "../shared/logger";
  14. const z = tool.schema;
  15. type ToolContext = {
  16. sessionID: string;
  17. messageID: string;
  18. agent: string;
  19. abort: AbortSignal;
  20. };
  21. /**
  22. * Creates background task tools (background_task, background_output, background_cancel).
  23. * @param ctx - The plugin input context.
  24. * @param manager - The background task manager instance.
  25. * @param tmuxConfig - Optional tmux configuration for spawning panes.
  26. * @param pluginConfig - Optional plugin configuration for agent variants.
  27. * @returns A record of tool definitions.
  28. */
  29. export function createBackgroundTools(
  30. ctx: PluginInput,
  31. manager: BackgroundTaskManager,
  32. tmuxConfig?: TmuxConfig,
  33. pluginConfig?: PluginConfig
  34. ): Record<string, ToolDefinition> {
  35. const agentNames = getSubagentNames().join(", ");
  36. const background_task = tool({
  37. description: `Run agent task. Use sync=true to wait for result, sync=false (default) to run in background.
  38. Agents: ${agentNames}.
  39. Async mode returns task_id immediately - use \`background_output\` to get results.
  40. Sync mode blocks until completion and returns the result directly.`,
  41. args: {
  42. description: z.string().describe("Short description of the task (5-10 words)"),
  43. prompt: z.string().describe("The task prompt for the agent"),
  44. agent: z.string().describe(`Agent to use: ${agentNames}`),
  45. sync: z.boolean().optional().describe("Wait for completion (default: false = async)"),
  46. session_id: z.string().optional().describe("Continue existing session (sync mode only)"),
  47. },
  48. async execute(args, toolContext) {
  49. const tctx = toolContext as ToolContext;
  50. const agent = String(args.agent);
  51. const prompt = String(args.prompt);
  52. const description = String(args.description);
  53. const isSync = args.sync === true;
  54. if (isSync) {
  55. return await executeSync(description, prompt, agent, tctx, ctx, tmuxConfig, pluginConfig, args.session_id as string | undefined);
  56. }
  57. const task = await manager.launch({
  58. agent,
  59. prompt,
  60. description,
  61. parentSessionId: tctx.sessionID,
  62. });
  63. return `Background task launched.
  64. Task ID: ${task.id}
  65. Agent: ${agent}
  66. Status: running
  67. Use \`background_output\` with task_id="${task.id}" to get results.`;
  68. },
  69. });
  70. const background_output = tool({
  71. description: "Get output from background task.",
  72. args: {
  73. task_id: z.string().describe("Task ID from background_task"),
  74. block: z.boolean().optional().describe("Wait for completion (default: false)"),
  75. timeout: z.number().optional().describe("Timeout in ms (default: 120000)"),
  76. },
  77. async execute(args) {
  78. const taskId = String(args.task_id);
  79. const block = args.block === true;
  80. const timeout = typeof args.timeout === "number" ? args.timeout : DEFAULT_TIMEOUT_MS;
  81. const task = await manager.getResult(taskId, block, timeout);
  82. if (!task) {
  83. return `Error: [background] task not found: ${taskId}`;
  84. }
  85. const duration = task.completedAt
  86. ? `${Math.floor((task.completedAt.getTime() - task.startedAt.getTime()) / 1000)}s`
  87. : "running";
  88. let output = `Task: ${task.id}
  89. Description: ${task.description}
  90. Status: ${task.status}
  91. Duration: ${duration}
  92. ---
  93. `;
  94. if (task.status === "completed" && task.result) {
  95. output += task.result;
  96. } else if (task.status === "failed") {
  97. output += `Error: ${task.error}`;
  98. } else {
  99. output += "(Task still running)";
  100. }
  101. return output;
  102. },
  103. });
  104. const background_cancel = tool({
  105. description: "Cancel running background task(s). Use all=true to cancel all.",
  106. args: {
  107. task_id: z.string().optional().describe("Specific task to cancel"),
  108. all: z.boolean().optional().describe("Cancel all running tasks"),
  109. },
  110. async execute(args) {
  111. if (args.all === true) {
  112. const count = manager.cancel();
  113. return `Cancelled ${count} running task(s).`;
  114. }
  115. if (typeof args.task_id === "string") {
  116. const count = manager.cancel(args.task_id);
  117. return count > 0 ? `Cancelled task ${args.task_id}.` : `Task ${args.task_id} not found or not running.`;
  118. }
  119. return "Specify task_id or use all=true.";
  120. },
  121. });
  122. return { background_task, background_output, background_cancel };
  123. }
  124. async function executeSync(
  125. description: string,
  126. prompt: string,
  127. agent: string,
  128. toolContext: ToolContext,
  129. ctx: PluginInput,
  130. tmuxConfig?: TmuxConfig,
  131. pluginConfig?: PluginConfig,
  132. existingSessionId?: string
  133. ): Promise<string> {
  134. const sessionResult = await getOrCreateSession(description, agent, toolContext, ctx, tmuxConfig, existingSessionId);
  135. if ("error" in sessionResult) {
  136. return sessionResult.error;
  137. }
  138. const sessionID = sessionResult.sessionID;
  139. // Disable recursive delegation tools to prevent infinite loops
  140. log(`[background-sync] launching sync task for agent="${agent}"`, { description });
  141. const resolvedVariant = resolveAgentVariant(pluginConfig, agent);
  142. type PromptBody = {
  143. agent: string;
  144. tools: { background_task: boolean; task: boolean };
  145. parts: Array<{ type: "text"; text: string }>;
  146. variant?: string;
  147. };
  148. const baseBody: PromptBody = {
  149. agent,
  150. tools: { background_task: false, task: false },
  151. parts: [{ type: "text" as const, text: prompt }],
  152. };
  153. const promptBody = applyAgentVariant(resolvedVariant, baseBody);
  154. try {
  155. await ctx.client.session.prompt({
  156. path: { id: sessionID },
  157. body: promptBody,
  158. });
  159. } catch (error) {
  160. return `Error: [background] executeSync: failed to send prompt: ${error instanceof Error ? error.message : String(error)}
  161. <task_metadata>
  162. session_id: ${sessionID}
  163. </task_metadata>`;
  164. }
  165. const pollStatus = await pollForCompletion(sessionID, toolContext, ctx);
  166. if (pollStatus === "aborted") {
  167. return `Task aborted.
  168. <task_metadata>
  169. session_id: ${sessionID}
  170. </task_metadata>`;
  171. } else if (pollStatus === "timeout") {
  172. return `Error: [background] executeSync: agent timed out after 5 minutes.
  173. <task_metadata>
  174. session_id: ${sessionID}
  175. </task_metadata>`;
  176. }
  177. const messagesResult = await ctx.client.session.messages({ path: { id: sessionID } });
  178. if (messagesResult.error) {
  179. return `Error: [background] executeSync: failed to get messages: ${messagesResult.error}`;
  180. }
  181. const responseText = extractAssistantResponse(
  182. messagesResult.data as Array<{ info?: { role: string }; parts?: Array<{ type: string; text?: string }> }>
  183. );
  184. if (responseText === null) {
  185. return `Error: [background] executeSync: no response from agent.
  186. <task_metadata>
  187. session_id: ${sessionID}
  188. </task_metadata>`;
  189. }
  190. // Pane closing is handled by TmuxSessionManager via polling
  191. return `${responseText}
  192. <task_metadata>
  193. session_id: ${sessionID}
  194. </task_metadata>`;
  195. }
  196. /**
  197. * Retrieves an existing session or creates a new one for background task execution.
  198. */
  199. async function getOrCreateSession(
  200. description: string,
  201. agent: string,
  202. toolContext: ToolContext,
  203. ctx: PluginInput,
  204. tmuxConfig?: TmuxConfig,
  205. existingSessionId?: string
  206. ): Promise<{ sessionID: string } | { error: string }> {
  207. if (existingSessionId) {
  208. const sessionResult = await ctx.client.session.get({ path: { id: existingSessionId } });
  209. if (sessionResult.error) {
  210. return { error: `Error: [background] executeSync: failed to get session: ${sessionResult.error}` };
  211. }
  212. return { sessionID: existingSessionId };
  213. }
  214. const parentSession = await ctx.client.session.get({ path: { id: toolContext.sessionID } }).catch(() => null);
  215. const parentDirectory = parentSession?.data?.directory ?? ctx.directory;
  216. const createResult = await ctx.client.session.create({
  217. body: {
  218. parentID: toolContext.sessionID,
  219. title: `${description} (@${agent})`,
  220. },
  221. query: { directory: parentDirectory },
  222. });
  223. if (createResult.error) {
  224. return { error: `Error: [background] executeSync: failed to create session: ${createResult.error}` };
  225. }
  226. const sessionID = createResult.data.id;
  227. // Give TmuxSessionManager time to spawn the pane via event hook
  228. // before we send the prompt (so the TUI can receive streaming updates)
  229. if (tmuxConfig?.enabled) {
  230. await sleep(500);
  231. }
  232. return { sessionID };
  233. }
  234. /**
  235. * Polls for the completion of a session's execution.
  236. */
  237. async function pollForCompletion(
  238. sessionID: string,
  239. toolContext: ToolContext,
  240. ctx: PluginInput
  241. ): Promise<"completed" | "aborted" | "timeout"> {
  242. const pollStart = Date.now();
  243. let lastMsgCount = 0;
  244. let stablePolls = 0;
  245. while (Date.now() - pollStart < MAX_POLL_TIME_MS) {
  246. if (toolContext.abort?.aborted) {
  247. return "aborted";
  248. }
  249. await sleep(POLL_INTERVAL_MS);
  250. const statusResult = await ctx.client.session.status();
  251. const allStatuses = (statusResult.data ?? {}) as Record<string, { type: string }>;
  252. const sessionStatus = allStatuses[sessionID];
  253. if (sessionStatus && sessionStatus.type !== "idle") {
  254. stablePolls = 0;
  255. lastMsgCount = 0;
  256. continue;
  257. }
  258. const messagesCheck = await ctx.client.session.messages({ path: { id: sessionID } });
  259. const msgs = ((messagesCheck as { data?: unknown }).data ?? messagesCheck) as Array<unknown>;
  260. const currentMsgCount = msgs.length;
  261. if (currentMsgCount > 0 && currentMsgCount === lastMsgCount) {
  262. stablePolls++;
  263. if (stablePolls >= STABLE_POLLS_THRESHOLD) return "completed";
  264. } else {
  265. stablePolls = 0;
  266. lastMsgCount = currentMsgCount;
  267. }
  268. }
  269. return "timeout";
  270. }
  271. /**
  272. * Extracts the assistant's response text from the session messages.
  273. */
  274. function extractAssistantResponse(
  275. messages: Array<{ info?: { role: string }; parts?: Array<{ type: string; text?: string }> }>
  276. ): string | null {
  277. const assistantMessages = messages.filter((m) => m.info?.role === "assistant");
  278. if (assistantMessages.length === 0) {
  279. return null;
  280. }
  281. const extractedContent: string[] = [];
  282. for (const message of assistantMessages) {
  283. for (const part of message.parts ?? []) {
  284. if ((part.type === "text" || part.type === "reasoning") && part.text) {
  285. extractedContent.push(part.text);
  286. }
  287. }
  288. }
  289. const responseText = extractedContent.filter((t) => t.length > 0).join("\n\n");
  290. return responseText.length > 0 ? responseText : null;
  291. }