/** * Task tool implementation * * Spawns specialized subagents to handle complex, multi-step tasks autonomously. * Supports both built-in subagent types and custom subagents defined in .letta/agents/. */ import { clearSubagentConfigCache, discoverSubagents, getAllSubagentConfigs, } from "../../agent/subagents"; import { spawnSubagent } from "../../agent/subagents/manager"; import { addToMessageQueue } from "../../cli/helpers/messageQueueBridge.js"; import { completeSubagent, generateSubagentId, getSnapshot as getSubagentSnapshot, registerSubagent, } from "../../cli/helpers/subagentState.js"; import { formatTaskNotification } from "../../cli/helpers/taskNotifications.js"; import { runSubagentStopHooks } from "../../hooks"; import { appendToOutputFile, type BackgroundTask, backgroundTasks, createBackgroundOutputFile, getNextTaskId, } from "./process_manager.js"; import { LIMITS, truncateByChars } from "./truncation.js"; import { validateRequiredParams } from "./validation"; interface TaskArgs { command?: "run" | "refresh"; subagent_type?: string; prompt?: string; description?: string; model?: string; agent_id?: string; // Deploy an existing agent instead of creating new conversation_id?: string; // Resume from an existing conversation run_in_background?: boolean; // Run the task in background max_turns?: number; // Maximum number of agentic turns toolCallId?: string; // Injected by executeTool for linking subagent to parent tool call signal?: AbortSignal; // Injected by executeTool for interruption handling } // Valid subagent_types when deploying an existing agent const VALID_DEPLOY_TYPES = new Set(["explore", "general-purpose"]); const BACKGROUND_STARTUP_POLL_MS = 50; type TaskRunResult = { agentId: string; conversationId?: string; report: string; success: boolean; error?: string; totalTokens?: number; }; export interface SpawnBackgroundSubagentTaskArgs { subagentType: string; prompt: string; description: string; model?: string; toolCallId?: string; existingAgentId?: string; existingConversationId?: string; maxTurns?: number; /** * When true, skip injecting the completion notification into the primary * agent's message queue and hide from SubagentGroupDisplay. * Use `onComplete` to show a user-facing notification without leaking * into the agent's context. */ silentCompletion?: boolean; /** * Called after the subagent finishes (success or failure). * Runs regardless of `silentCompletion` and is awaited before * completion notifications/hooks continue. */ onComplete?: (result: { success: boolean; error?: string; }) => void | Promise; /** * Optional dependency overrides for tests. * Production callers should not provide this. */ deps?: Partial; } export interface SpawnBackgroundSubagentTaskResult { taskId: string; outputFile: string; subagentId: string; } interface SpawnBackgroundSubagentTaskDeps { spawnSubagentImpl: typeof spawnSubagent; addToMessageQueueImpl: typeof addToMessageQueue; formatTaskNotificationImpl: typeof formatTaskNotification; runSubagentStopHooksImpl: typeof runSubagentStopHooks; generateSubagentIdImpl: typeof generateSubagentId; registerSubagentImpl: typeof registerSubagent; completeSubagentImpl: typeof completeSubagent; getSubagentSnapshotImpl: typeof getSubagentSnapshot; } function buildTaskResultHeader( subagentType: string, result: Pick, ): string { return [ `subagent_type=${subagentType}`, result.agentId ? `agent_id=${result.agentId}` : undefined, result.conversationId ? `conversation_id=${result.conversationId}` : undefined, ] .filter(Boolean) .join(" "); } function writeTaskTranscriptStart( outputFile: string, description: string, subagentType: string, ): void { appendToOutputFile( outputFile, `[Task started: ${description}]\n[subagent_type: ${subagentType}]\n\n`, ); } function writeTaskTranscriptResult( outputFile: string, result: TaskRunResult, header: string, ): void { if (result.success) { appendToOutputFile( outputFile, `${header}\n\n${result.report}\n\n[Task completed]\n`, ); return; } appendToOutputFile( outputFile, `[error] ${result.error || "Subagent execution failed"}\n\n[Task failed]\n`, ); } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } /** * Wait briefly for a background subagent to publish its agent URL. * This keeps Task mostly non-blocking while allowing static transcript rows * to include an ADE link in the common case. */ export async function waitForBackgroundSubagentLink( subagentId: string, timeoutMs: number | null = null, signal?: AbortSignal, ): Promise { const deadline = timeoutMs !== null && timeoutMs > 0 ? Date.now() + timeoutMs : null; while (true) { if (signal?.aborted) { return; } const agent = getSubagentSnapshot().agents.find((a) => a.id === subagentId); if (!agent) { return; } if (agent.agentURL) { return; } if (agent.status === "error" || agent.status === "completed") { return; } if (deadline !== null && Date.now() >= deadline) { return; } await sleep(BACKGROUND_STARTUP_POLL_MS); } } /** * Spawn a background subagent task and return task metadata immediately. * Notification/hook behavior is identical to Task's background path. */ export function spawnBackgroundSubagentTask( args: SpawnBackgroundSubagentTaskArgs, ): SpawnBackgroundSubagentTaskResult { const { subagentType, prompt, description, model, toolCallId, existingAgentId, existingConversationId, maxTurns, silentCompletion, onComplete, deps, } = args; const spawnSubagentFn = deps?.spawnSubagentImpl ?? spawnSubagent; const addToMessageQueueFn = deps?.addToMessageQueueImpl ?? addToMessageQueue; const formatTaskNotificationFn = deps?.formatTaskNotificationImpl ?? formatTaskNotification; const runSubagentStopHooksFn = deps?.runSubagentStopHooksImpl ?? runSubagentStopHooks; const generateSubagentIdFn = deps?.generateSubagentIdImpl ?? generateSubagentId; const registerSubagentFn = deps?.registerSubagentImpl ?? registerSubagent; const completeSubagentFn = deps?.completeSubagentImpl ?? completeSubagent; const getSubagentSnapshotFn = deps?.getSubagentSnapshotImpl ?? getSubagentSnapshot; const subagentId = generateSubagentIdFn(); registerSubagentFn( subagentId, subagentType, description, toolCallId, true, silentCompletion, ); const taskId = getNextTaskId(); const outputFile = createBackgroundOutputFile(taskId); const abortController = new AbortController(); const bgTask: BackgroundTask = { description, subagentType, subagentId, status: "running", output: [], startTime: new Date(), outputFile, abortController, }; backgroundTasks.set(taskId, bgTask); writeTaskTranscriptStart(outputFile, description, subagentType); // Intentionally fire-and-forget: background tasks own their lifecycle and // capture failures in task state/transcripts instead of surfacing a promise // back to the caller. spawnSubagentFn( subagentType, prompt, model, subagentId, abortController.signal, existingAgentId, existingConversationId, maxTurns, ) .then(async (result) => { bgTask.status = result.success ? "completed" : "failed"; if (result.error) { bgTask.error = result.error; } const header = buildTaskResultHeader(subagentType, result); writeTaskTranscriptResult(outputFile, result, header); if (result.success) { bgTask.output.push(result.report || ""); } completeSubagentFn(subagentId, { success: result.success, error: result.error, totalTokens: result.totalTokens, }); try { await onComplete?.({ success: result.success, error: result.error }); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); appendToOutputFile(outputFile, `[onComplete error] ${errorMessage}\n`); } if (!silentCompletion) { const subagentSnapshot = getSubagentSnapshotFn(); const toolUses = subagentSnapshot.agents.find( (agent) => agent.id === subagentId, )?.toolCalls.length; const durationMs = Math.max(0, Date.now() - bgTask.startTime.getTime()); const fullResult = result.success ? `${header}\n\n${result.report || ""}` : result.error || "Subagent execution failed"; const userCwd = process.env.USER_CWD || process.cwd(); const { content: truncatedResult } = truncateByChars( fullResult, LIMITS.TASK_OUTPUT_CHARS, "Task", { workingDirectory: userCwd, toolName: "Task" }, ); const notificationXml = formatTaskNotificationFn({ taskId, status: result.success ? "completed" : "failed", summary: `Agent "${description}" ${result.success ? "completed" : "failed"}`, result: truncatedResult, outputFile, usage: { totalTokens: result.totalTokens, toolUses, durationMs, }, }); addToMessageQueueFn({ kind: "task_notification", text: notificationXml, }); } runSubagentStopHooksFn( subagentType, subagentId, result.success, result.error, result.agentId, result.conversationId, ).catch(() => { // Silently ignore hook errors }); }) .catch(async (error) => { const errorMessage = error instanceof Error ? error.message : String(error); bgTask.status = "failed"; bgTask.error = errorMessage; appendToOutputFile(outputFile, `[error] ${errorMessage}\n`); completeSubagentFn(subagentId, { success: false, error: errorMessage }); try { await onComplete?.({ success: false, error: errorMessage }); } catch (onCompleteError) { const callbackMessage = onCompleteError instanceof Error ? onCompleteError.message : String(onCompleteError); appendToOutputFile( outputFile, `[onComplete error] ${callbackMessage}\n`, ); } if (!silentCompletion) { const subagentSnapshot = getSubagentSnapshotFn(); const toolUses = subagentSnapshot.agents.find( (agent) => agent.id === subagentId, )?.toolCalls.length; const durationMs = Math.max(0, Date.now() - bgTask.startTime.getTime()); const notificationXml = formatTaskNotificationFn({ taskId, status: "failed", summary: `Agent "${description}" failed`, result: errorMessage, outputFile, usage: { toolUses, durationMs, }, }); addToMessageQueueFn({ kind: "task_notification", text: notificationXml, }); } runSubagentStopHooksFn( subagentType, subagentId, false, errorMessage, existingAgentId, existingConversationId, ).catch(() => { // Silently ignore hook errors }); }); return { taskId, outputFile, subagentId }; } /** * Task tool - Launch a specialized subagent to handle complex tasks */ export async function task(args: TaskArgs): Promise { const { command = "run", model, toolCallId, signal } = args; // Handle refresh command - re-discover subagents from .letta/agents/ directories if (command === "refresh") { // Clear the cache to force re-discovery clearSubagentConfigCache(); // Discover subagents from global and project directories const { subagents, errors } = await discoverSubagents(); // Get all configs (builtins + discovered) to report accurate count const allConfigs = await getAllSubagentConfigs(); const totalCount = Object.keys(allConfigs).length; const customCount = subagents.length; // Log any errors if (errors.length > 0) { for (const error of errors) { console.warn( `Subagent discovery error: ${error.path}: ${error.message}`, ); } } const errorSuffix = errors.length > 0 ? `, ${errors.length} error(s)` : ""; return `Refreshed subagents list: found ${totalCount} total (${customCount} custom)${errorSuffix}`; } // Determine if deploying an existing agent const isDeployingExisting = Boolean(args.agent_id || args.conversation_id); // Validate required parameters based on mode if (isDeployingExisting) { // Deploying existing agent: prompt and description required, subagent_type optional validateRequiredParams(args, ["prompt", "description"], "Task"); } else { // Creating new agent: subagent_type, prompt, and description required validateRequiredParams( args, ["subagent_type", "prompt", "description"], "Task", ); } // Extract validated params const inputPrompt = args.prompt as string; const description = args.description as string; // For existing agents, default subagent_type to "general-purpose" for permissions const subagent_type = isDeployingExisting ? args.subagent_type || "general-purpose" : (args.subagent_type as string); // Get all available subagent configs (built-in + custom) const allConfigs = await getAllSubagentConfigs(); // Validate subagent type if (!(subagent_type in allConfigs)) { const available = Object.keys(allConfigs).join(", "); return `Error: Invalid subagent type "${subagent_type}". Available types: ${available}`; } // For existing agents, only allow explore or general-purpose if (isDeployingExisting && !VALID_DEPLOY_TYPES.has(subagent_type)) { return `Error: When deploying an existing agent, subagent_type must be "explore" (read-only) or "general-purpose" (read-write). Got: "${subagent_type}"`; } const prompt = inputPrompt; const isBackground = args.run_in_background ?? false; // Handle background execution if (isBackground) { const { taskId, outputFile, subagentId } = spawnBackgroundSubagentTask({ subagentType: subagent_type, prompt, description, model, toolCallId, existingAgentId: args.agent_id, existingConversationId: args.conversation_id, maxTurns: args.max_turns, }); await waitForBackgroundSubagentLink(subagentId, null, signal); // Extract Letta agent ID from subagent state (available after link resolves) const linkedAgent = getSubagentSnapshot().agents.find( (a) => a.id === subagentId, ); const agentId = linkedAgent?.agentURL?.split("/agents/")[1] ?? null; const agentIdLine = agentId ? `\nAgent ID: ${agentId}` : ""; return `Task running in background with task ID: ${taskId}${agentIdLine}\nOutput file: ${outputFile}\n\nYou will be notified automatically when this task completes — a message will be delivered with the result. No need to poll, sleep-wait, or check the output file. Just continue with your current work.`; } // Register subagent with state store for UI display (foreground path) const subagentId = generateSubagentId(); registerSubagent(subagentId, subagent_type, description, toolCallId, false); // Foreground tasks now also write transcripts so users can inspect full output // even when inline content is truncated. const foregroundTaskId = getNextTaskId(); const outputFile = createBackgroundOutputFile(foregroundTaskId); writeTaskTranscriptStart(outputFile, description, subagent_type); try { const result = await spawnSubagent( subagent_type, prompt, model, subagentId, signal, args.agent_id, args.conversation_id, args.max_turns, ); // Mark subagent as completed in state store completeSubagent(subagentId, { success: result.success, error: result.error, totalTokens: result.totalTokens, }); // Run SubagentStop hooks (fire-and-forget) runSubagentStopHooks( subagent_type, subagentId, result.success, result.error, result.agentId, result.conversationId, ).catch(() => { // Silently ignore hook errors }); if (!result.success) { const errorMessage = result.error || "Subagent execution failed"; const failedResult: TaskRunResult = { ...result, error: errorMessage, }; writeTaskTranscriptResult(outputFile, failedResult, ""); return `Error: ${errorMessage}\nOutput file: ${outputFile}`; } // Include stable subagent metadata so orchestrators can attribute results. // Keep the tool return type as a string for compatibility. const header = buildTaskResultHeader(subagent_type, result); const fullOutput = `${header}\n\n${result.report}`; writeTaskTranscriptResult(outputFile, result, header); const userCwd = process.env.USER_CWD || process.cwd(); // Apply truncation to prevent excessive token usage (same pattern as Bash tool) const { content: truncatedOutput } = truncateByChars( fullOutput, LIMITS.TASK_OUTPUT_CHARS, "Task", { workingDirectory: userCwd, toolName: "Task" }, ); return `${truncatedOutput}\nOutput file: ${outputFile}`; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); completeSubagent(subagentId, { success: false, error: errorMessage }); // Run SubagentStop hooks for error case (fire-and-forget) runSubagentStopHooks( subagent_type, subagentId, false, errorMessage, args.agent_id, args.conversation_id, ).catch(() => { // Silently ignore hook errors }); appendToOutputFile( outputFile, `[error] ${errorMessage}\n\n[Task failed]\n`, ); return `Error: ${errorMessage}\nOutput file: ${outputFile}`; } }