| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290 |
- /**
- * Tool definitions for handoff functionality.
- *
- * Factory functions that create tool definitions with injected dependencies:
- * - createHandoffSessionTool: Create a new session with handoff prompt
- * - createReadSessionTool: Read conversation transcript from a session
- */
- import type { PluginInput, ToolDefinition } from '@opencode-ai/plugin';
- import { tool } from '@opencode-ai/plugin';
- import { extractSessionResult, promptWithTimeout } from '../../utils/session';
- import type { SubagentDepthTracker } from '../../utils/subagent-depth';
- import { buildSyntheticFileParts, parseFileReferences } from './files';
- import type { HandoffState } from './state';
- export type OpencodeClient = PluginInput['client'];
- const HANDOFF_TIMEOUT_MS = 5 * 60 * 1000;
- /**
- * Create the handoff_session tool.
- *
- * Takes the OpenCode client as a dependency for TUI and session operations.
- */
- export function createHandoffSessionTool(
- ctx: PluginInput,
- state: HandoffState,
- depthTracker?: SubagentDepthTracker,
- ): ToolDefinition {
- const client = ctx.client;
- return tool({
- description:
- 'Run a child worker session and return its completion summary to the caller',
- args: {
- prompt: tool.schema.string().describe('The generated handoff prompt'),
- files: tool.schema
- .array(tool.schema.string())
- .optional()
- .describe("Array of file paths to load into the new session's context"),
- },
- async execute(args, context) {
- const directory =
- context &&
- typeof context === 'object' &&
- 'directory' in context &&
- typeof (context as { directory?: unknown }).directory === 'string'
- ? (context as { directory: string }).directory
- : ctx.directory;
- const sessionID =
- context && typeof context === 'object' && 'sessionID' in context
- ? (context as { sessionID: string }).sessionID
- : 'unknown';
- if (state.isHandoffSession(sessionID)) {
- return 'Nested handoff is disabled: this session is already a handoff worker. Finish this worker and return its summary to the parent session instead.';
- }
- if (
- sessionID !== 'unknown' &&
- depthTracker &&
- depthTracker.getDepth(sessionID) + 1 > depthTracker.maxDepth
- ) {
- return `Handoff worker blocked: max subagent depth ${depthTracker.maxDepth} would be exceeded.`;
- }
- const sessionReference = `Work on behalf of parent session ${sessionID}. When you lack specific information you can use read_session to get it.`;
- const files = new Set([
- ...parseFileReferences(args.prompt),
- ...(args.files ?? []).map((file) => file.replace(/^@/, '')),
- ]);
- const fileRefs =
- files.size > 0 ? [...files].map((f) => `@${f}`).join(' ') : '';
- const fullPrompt = fileRefs
- ? `${sessionReference}\n\n${fileRefs}\n\n${args.prompt}`
- : `${sessionReference}\n\n${args.prompt}`;
- let childSessionID: string | undefined;
- try {
- const session = await client.session.create({
- responseStyle: 'data',
- throwOnError: true,
- query: { directory },
- body: {
- parentID: sessionID === 'unknown' ? undefined : sessionID,
- title: `Handoff worker from ${sessionID}`,
- },
- });
- childSessionID =
- (session as { data?: { id?: string }; id?: string })?.data?.id ??
- (session as { data?: { id?: string }; id?: string })?.id;
- if (!childSessionID) {
- throw new Error('Handoff worker session did not return an id');
- }
- if (sessionID !== 'unknown' && depthTracker) {
- const registered = depthTracker.registerChild(
- sessionID,
- childSessionID,
- );
- if (!registered) {
- throw new Error(
- 'Handoff worker blocked: max subagent depth exceeded',
- );
- }
- }
- state.markSession(childSessionID, sessionID);
- await promptWithTimeout(
- client,
- {
- responseStyle: 'data',
- throwOnError: true,
- query: { directory },
- path: { id: childSessionID },
- body: {
- agent: 'orchestrator',
- parts: [
- {
- type: 'text',
- 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.`,
- },
- ...(await buildSyntheticFileParts(directory, files)),
- ],
- },
- },
- HANDOFF_TIMEOUT_MS,
- );
- const extraction = await extractSessionResult(client, childSessionID, {
- directory,
- includeReasoning: false,
- });
- if (extraction.empty) {
- throw new Error('Handoff worker returned no summary');
- }
- return [
- `task_id: ${childSessionID}`,
- '',
- '<handoff_summary>',
- extraction.text,
- '</handoff_summary>',
- ].join('\n');
- } finally {
- if (childSessionID) {
- try {
- await client.session.abort({
- path: { id: childSessionID },
- query: { directory },
- });
- state.unmarkSession(childSessionID);
- } catch {
- // Keep the handoff marker if abort fails; session.deleted cleanup
- // will remove it when OpenCode eventually deletes the session.
- }
- }
- }
- },
- });
- }
- /**
- * Format a conversation transcript for display.
- *
- * @param messages - Array of messages from session.messages()
- * @param limit - Optional limit to indicate if results are truncated
- * @returns Formatted transcript with user/assistant sections
- */
- function formatTranscript(
- messages: Array<{ info: { role?: string }; parts: unknown[] }>,
- limit?: number,
- ): string {
- const lines: string[] = [];
- for (const msg of messages) {
- const role = msg.info?.role;
- const parts = msg.parts as Array<{
- type: string;
- text?: string;
- ignored?: boolean;
- filename?: string;
- tool?: string;
- state?: { status: string; title?: string };
- }>;
- if (role === 'user') {
- lines.push('## User');
- for (const part of parts) {
- if (
- part.type === 'text' &&
- !part.ignored &&
- typeof part.text === 'string'
- ) {
- lines.push(part.text);
- }
- if (part.type === 'file') {
- lines.push(`[Attached: ${part.filename || 'file'}]`);
- }
- }
- lines.push('');
- }
- if (role === 'assistant') {
- lines.push('## Assistant');
- for (const part of parts) {
- if (part.type === 'text' && typeof part.text === 'string') {
- lines.push(part.text);
- }
- if (
- part.type === 'tool' &&
- part.state?.status === 'completed' &&
- part.tool
- ) {
- lines.push(`[Tool: ${part.tool}] ${part.state.title ?? ''}`);
- }
- }
- lines.push('');
- }
- }
- const output = lines.join('\n').trim();
- if (messages.length >= (limit ?? 100)) {
- return (
- output +
- `\n\n(Showing ${messages.length} most recent messages. Use a higher 'limit' to see more.)`
- );
- }
- return `${output}\n\n(End of session - ${messages.length} messages)`;
- }
- /**
- * Create the read_session tool.
- *
- * Takes the OpenCode client as a dependency for session.messages() calls.
- */
- export function createReadSessionTool(
- client: OpencodeClient,
- state: HandoffState,
- ): ToolDefinition {
- return tool({
- description:
- "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.",
- args: {
- sessionID: tool.schema
- .string()
- .describe('The full session ID (e.g., sess_01jxyz...)'),
- limit: tool.schema
- .number()
- .optional()
- .describe(
- 'Maximum number of messages to read (defaults to 100, max 500)',
- ),
- },
- async execute(args, context) {
- const limit = Math.min(args.limit ?? 100, 500);
- const directory =
- context &&
- typeof context === 'object' &&
- 'directory' in context &&
- typeof (context as { directory?: unknown }).directory === 'string'
- ? (context as { directory: string }).directory
- : undefined;
- const callerSessionID =
- context && typeof context === 'object' && 'sessionID' in context
- ? (context as { sessionID?: string }).sessionID
- : undefined;
- if (!callerSessionID || !state.isHandoffSession(callerSessionID)) {
- return 'read_session is only available from handoff worker sessions.';
- }
- if (state.sourceFor(callerSessionID) !== args.sessionID) {
- return 'read_session can only read the source session for this handoff worker.';
- }
- try {
- const response = (await client.session.messages({
- path: { id: args.sessionID },
- query: { limit, ...(directory ? { directory } : {}) },
- })) as { data?: Array<{ info: { role?: string }; parts: unknown[] }> };
- if (!response.data || response.data.length === 0) {
- return 'Session has no messages or does not exist.';
- }
- return formatTranscript(response.data, limit);
- } catch (error) {
- return `Could not read session ${args.sessionID}: ${error instanceof Error ? error.message : 'Unknown error'}`;
- }
- },
- });
- }
|