tools.ts 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290
  1. /**
  2. * Tool definitions for handoff functionality.
  3. *
  4. * Factory functions that create tool definitions with injected dependencies:
  5. * - createHandoffSessionTool: Create a new session with handoff prompt
  6. * - createReadSessionTool: Read conversation transcript from a session
  7. */
  8. import type { PluginInput, ToolDefinition } from '@opencode-ai/plugin';
  9. import { tool } from '@opencode-ai/plugin';
  10. import { extractSessionResult, promptWithTimeout } from '../../utils/session';
  11. import type { SubagentDepthTracker } from '../../utils/subagent-depth';
  12. import { buildSyntheticFileParts, parseFileReferences } from './files';
  13. import type { HandoffState } from './state';
  14. export type OpencodeClient = PluginInput['client'];
  15. const HANDOFF_TIMEOUT_MS = 5 * 60 * 1000;
  16. /**
  17. * Create the handoff_session tool.
  18. *
  19. * Takes the OpenCode client as a dependency for TUI and session operations.
  20. */
  21. export function createHandoffSessionTool(
  22. ctx: PluginInput,
  23. state: HandoffState,
  24. depthTracker?: SubagentDepthTracker,
  25. ): ToolDefinition {
  26. const client = ctx.client;
  27. return tool({
  28. description:
  29. 'Run a child worker session and return its completion summary to the caller',
  30. args: {
  31. prompt: tool.schema.string().describe('The generated handoff prompt'),
  32. files: tool.schema
  33. .array(tool.schema.string())
  34. .optional()
  35. .describe("Array of file paths to load into the new session's context"),
  36. },
  37. async execute(args, context) {
  38. const directory =
  39. context &&
  40. typeof context === 'object' &&
  41. 'directory' in context &&
  42. typeof (context as { directory?: unknown }).directory === 'string'
  43. ? (context as { directory: string }).directory
  44. : ctx.directory;
  45. const sessionID =
  46. context && typeof context === 'object' && 'sessionID' in context
  47. ? (context as { sessionID: string }).sessionID
  48. : 'unknown';
  49. if (state.isHandoffSession(sessionID)) {
  50. return 'Nested handoff is disabled: this session is already a handoff worker. Finish this worker and return its summary to the parent session instead.';
  51. }
  52. if (
  53. sessionID !== 'unknown' &&
  54. depthTracker &&
  55. depthTracker.getDepth(sessionID) + 1 > depthTracker.maxDepth
  56. ) {
  57. return `Handoff worker blocked: max subagent depth ${depthTracker.maxDepth} would be exceeded.`;
  58. }
  59. const sessionReference = `Work on behalf of parent session ${sessionID}. When you lack specific information you can use read_session to get it.`;
  60. const files = new Set([
  61. ...parseFileReferences(args.prompt),
  62. ...(args.files ?? []).map((file) => file.replace(/^@/, '')),
  63. ]);
  64. const fileRefs =
  65. files.size > 0 ? [...files].map((f) => `@${f}`).join(' ') : '';
  66. const fullPrompt = fileRefs
  67. ? `${sessionReference}\n\n${fileRefs}\n\n${args.prompt}`
  68. : `${sessionReference}\n\n${args.prompt}`;
  69. let childSessionID: string | undefined;
  70. try {
  71. const session = await client.session.create({
  72. responseStyle: 'data',
  73. throwOnError: true,
  74. query: { directory },
  75. body: {
  76. parentID: sessionID === 'unknown' ? undefined : sessionID,
  77. title: `Handoff worker from ${sessionID}`,
  78. },
  79. });
  80. childSessionID =
  81. (session as { data?: { id?: string }; id?: string })?.data?.id ??
  82. (session as { data?: { id?: string }; id?: string })?.id;
  83. if (!childSessionID) {
  84. throw new Error('Handoff worker session did not return an id');
  85. }
  86. if (sessionID !== 'unknown' && depthTracker) {
  87. const registered = depthTracker.registerChild(
  88. sessionID,
  89. childSessionID,
  90. );
  91. if (!registered) {
  92. throw new Error(
  93. 'Handoff worker blocked: max subagent depth exceeded',
  94. );
  95. }
  96. }
  97. state.markSession(childSessionID, sessionID);
  98. await promptWithTimeout(
  99. client,
  100. {
  101. responseStyle: 'data',
  102. throwOnError: true,
  103. query: { directory },
  104. path: { id: childSessionID },
  105. body: {
  106. agent: 'orchestrator',
  107. parts: [
  108. {
  109. type: 'text',
  110. text: `${fullPrompt}\n\nDo the requested work. When finished, return a concise summary of what you did, files changed, validation run, and any remaining risks or follow-up. Let the user's prompt determine scope and emphasis.`,
  111. },
  112. ...(await buildSyntheticFileParts(directory, files)),
  113. ],
  114. },
  115. },
  116. HANDOFF_TIMEOUT_MS,
  117. );
  118. const extraction = await extractSessionResult(client, childSessionID, {
  119. directory,
  120. includeReasoning: false,
  121. });
  122. if (extraction.empty) {
  123. throw new Error('Handoff worker returned no summary');
  124. }
  125. return [
  126. `task_id: ${childSessionID}`,
  127. '',
  128. '<handoff_summary>',
  129. extraction.text,
  130. '</handoff_summary>',
  131. ].join('\n');
  132. } finally {
  133. if (childSessionID) {
  134. try {
  135. await client.session.abort({
  136. path: { id: childSessionID },
  137. query: { directory },
  138. });
  139. state.unmarkSession(childSessionID);
  140. } catch {
  141. // Keep the handoff marker if abort fails; session.deleted cleanup
  142. // will remove it when OpenCode eventually deletes the session.
  143. }
  144. }
  145. }
  146. },
  147. });
  148. }
  149. /**
  150. * Format a conversation transcript for display.
  151. *
  152. * @param messages - Array of messages from session.messages()
  153. * @param limit - Optional limit to indicate if results are truncated
  154. * @returns Formatted transcript with user/assistant sections
  155. */
  156. function formatTranscript(
  157. messages: Array<{ info: { role?: string }; parts: unknown[] }>,
  158. limit?: number,
  159. ): string {
  160. const lines: string[] = [];
  161. for (const msg of messages) {
  162. const role = msg.info?.role;
  163. const parts = msg.parts as Array<{
  164. type: string;
  165. text?: string;
  166. ignored?: boolean;
  167. filename?: string;
  168. tool?: string;
  169. state?: { status: string; title?: string };
  170. }>;
  171. if (role === 'user') {
  172. lines.push('## User');
  173. for (const part of parts) {
  174. if (
  175. part.type === 'text' &&
  176. !part.ignored &&
  177. typeof part.text === 'string'
  178. ) {
  179. lines.push(part.text);
  180. }
  181. if (part.type === 'file') {
  182. lines.push(`[Attached: ${part.filename || 'file'}]`);
  183. }
  184. }
  185. lines.push('');
  186. }
  187. if (role === 'assistant') {
  188. lines.push('## Assistant');
  189. for (const part of parts) {
  190. if (part.type === 'text' && typeof part.text === 'string') {
  191. lines.push(part.text);
  192. }
  193. if (
  194. part.type === 'tool' &&
  195. part.state?.status === 'completed' &&
  196. part.tool
  197. ) {
  198. lines.push(`[Tool: ${part.tool}] ${part.state.title ?? ''}`);
  199. }
  200. }
  201. lines.push('');
  202. }
  203. }
  204. const output = lines.join('\n').trim();
  205. if (messages.length >= (limit ?? 100)) {
  206. return (
  207. output +
  208. `\n\n(Showing ${messages.length} most recent messages. Use a higher 'limit' to see more.)`
  209. );
  210. }
  211. return `${output}\n\n(End of session - ${messages.length} messages)`;
  212. }
  213. /**
  214. * Create the read_session tool.
  215. *
  216. * Takes the OpenCode client as a dependency for session.messages() calls.
  217. */
  218. export function createReadSessionTool(
  219. client: OpencodeClient,
  220. state: HandoffState,
  221. ): ToolDefinition {
  222. return tool({
  223. description:
  224. "Read the conversation transcript from a previous session. Use this when you need specific information from the source session that wasn't included in the handoff summary.",
  225. args: {
  226. sessionID: tool.schema
  227. .string()
  228. .describe('The full session ID (e.g., sess_01jxyz...)'),
  229. limit: tool.schema
  230. .number()
  231. .optional()
  232. .describe(
  233. 'Maximum number of messages to read (defaults to 100, max 500)',
  234. ),
  235. },
  236. async execute(args, context) {
  237. const limit = Math.min(args.limit ?? 100, 500);
  238. const directory =
  239. context &&
  240. typeof context === 'object' &&
  241. 'directory' in context &&
  242. typeof (context as { directory?: unknown }).directory === 'string'
  243. ? (context as { directory: string }).directory
  244. : undefined;
  245. const callerSessionID =
  246. context && typeof context === 'object' && 'sessionID' in context
  247. ? (context as { sessionID?: string }).sessionID
  248. : undefined;
  249. if (!callerSessionID || !state.isHandoffSession(callerSessionID)) {
  250. return 'read_session is only available from handoff worker sessions.';
  251. }
  252. if (state.sourceFor(callerSessionID) !== args.sessionID) {
  253. return 'read_session can only read the source session for this handoff worker.';
  254. }
  255. try {
  256. const response = (await client.session.messages({
  257. path: { id: args.sessionID },
  258. query: { limit, ...(directory ? { directory } : {}) },
  259. })) as { data?: Array<{ info: { role?: string }; parts: unknown[] }> };
  260. if (!response.data || response.data.length === 0) {
  261. return 'Session has no messages or does not exist.';
  262. }
  263. return formatTranscript(response.data, limit);
  264. } catch (error) {
  265. return `Could not read session ${args.sessionID}: ${error instanceof Error ? error.message : 'Unknown error'}`;
  266. }
  267. },
  268. });
  269. }