/** * AgentExecutor - Core agent execution engine with streaming support */ import path from 'path'; import type { ExecuteOptions, ParsedTask } from '@automaker/types'; import { isPipelineStatus } from '@automaker/types'; import { buildPromptWithImages, createLogger, isAuthenticationError } from '@automaker/utils'; import { getFeatureDir } from '@automaker/platform'; import * as secureFs from '../lib/secure-fs.js'; import { TypedEventBus } from './typed-event-bus.js'; import { FeatureStateManager } from './feature-state-manager.js'; import { PlanApprovalService } from './plan-approval-service.js'; import type { SettingsService } from './settings-service.js'; import { parseTasksFromSpec, detectTaskStartMarker, detectTaskCompleteMarker, detectPhaseCompleteMarker, detectSpecFallback, extractSummary, } from './spec-parser.js'; import { getPromptCustomization } from '../lib/settings-helpers.js'; import type { AgentExecutionOptions, AgentExecutionResult, AgentExecutorCallbacks, } from './agent-executor-types.js'; // Re-export types for backward compatibility export type { AgentExecutionOptions, AgentExecutionResult, WaitForApprovalFn, SaveFeatureSummaryFn, UpdateFeatureSummaryFn, BuildTaskPromptFn, } from './agent-executor-types.js'; const logger = createLogger('AgentExecutor'); const DEFAULT_MAX_TURNS = 10000; export class AgentExecutor { private static readonly WRITE_DEBOUNCE_MS = 500; private static readonly STREAM_HEARTBEAT_MS = 15_000; /** * Sanitize a provider error value into clean text. * Coalesces to string, removes ANSI codes, strips leading "Error:" prefix, * trims, and returns 'Unknown error' when empty. */ private static sanitizeProviderError(input: string | { error?: string } | undefined): string { let raw: string; if (typeof input === 'string') { raw = input; } else if (input && typeof input === 'object' && typeof input.error === 'string') { raw = input.error; } else { raw = ''; } const cleaned = raw .replace(/\x1b\[[0-9;]*m/g, '') .replace(/^Error:\s*/i, '') .trim(); return cleaned || 'Unknown error'; } constructor( private eventBus: TypedEventBus, private featureStateManager: FeatureStateManager, private planApprovalService: PlanApprovalService, private settingsService: SettingsService | null = null ) {} async execute( options: AgentExecutionOptions, callbacks: AgentExecutorCallbacks ): Promise { const { workDir, featureId, projectPath, abortController, branchName = null, provider, effectiveBareModel, previousContent, planningMode = 'skip', requirePlanApproval = false, specAlreadyDetected = false, existingApprovedPlanContent, persistedTasks, credentials, status, // Feature status for pipeline summary check claudeCompatibleProvider, mcpServers, sdkSessionId, sdkOptions, } = options; const { content: promptContent } = await buildPromptWithImages( options.prompt, options.imagePaths, workDir, false ); const resolvedMaxTurns = sdkOptions?.maxTurns ?? DEFAULT_MAX_TURNS; if (sdkOptions?.maxTurns == null) { logger.info( `[execute] Feature ${featureId}: sdkOptions.maxTurns is not set, defaulting to ${resolvedMaxTurns}. ` + `Model: ${effectiveBareModel}` ); } else { logger.info( `[execute] Feature ${featureId}: maxTurns=${resolvedMaxTurns}, model=${effectiveBareModel}` ); } const executeOptions: ExecuteOptions = { prompt: promptContent, model: effectiveBareModel, maxTurns: resolvedMaxTurns, cwd: workDir, allowedTools: sdkOptions?.allowedTools as string[] | undefined, abortController, systemPrompt: sdkOptions?.systemPrompt, settingSources: sdkOptions?.settingSources, mcpServers: mcpServers && Object.keys(mcpServers).length > 0 ? (mcpServers as Record) : undefined, thinkingLevel: options.thinkingLevel, reasoningEffort: options.reasoningEffort, credentials, claudeCompatibleProvider, sdkSessionId, }; const featureDirForOutput = getFeatureDir(projectPath, featureId); const outputPath = path.join(featureDirForOutput, 'agent-output.md'); const rawOutputPath = path.join(featureDirForOutput, 'raw-output.jsonl'); const enableRawOutput = process.env.AUTOMAKER_DEBUG_RAW_OUTPUT === 'true' || process.env.AUTOMAKER_DEBUG_RAW_OUTPUT === '1'; let responseText = previousContent ? `${previousContent}\n\n---\n\n## Follow-up Session\n\n` : ''; let specDetected = specAlreadyDetected, tasksCompleted = 0, aborted = false; let writeTimeout: ReturnType | null = null, rawOutputLines: string[] = [], rawWriteTimeout: ReturnType | null = null; const writeToFile = async (): Promise => { try { await secureFs.mkdir(path.dirname(outputPath), { recursive: true }); await secureFs.writeFile(outputPath, responseText); } catch (error) { logger.error(`Failed to write agent output for ${featureId}:`, error); } }; const scheduleWrite = (): void => { if (writeTimeout) clearTimeout(writeTimeout); writeTimeout = setTimeout(() => writeToFile(), AgentExecutor.WRITE_DEBOUNCE_MS); }; const appendRawEvent = (event: unknown): void => { if (!enableRawOutput) return; try { rawOutputLines.push(JSON.stringify({ timestamp: new Date().toISOString(), event })); if (rawWriteTimeout) clearTimeout(rawWriteTimeout); rawWriteTimeout = setTimeout(async () => { try { await secureFs.mkdir(path.dirname(rawOutputPath), { recursive: true }); await secureFs.appendFile(rawOutputPath, rawOutputLines.join('\n') + '\n'); rawOutputLines = []; } catch { /* ignore */ } }, AgentExecutor.WRITE_DEBOUNCE_MS); } catch { /* ignore */ } }; const streamStartTime = Date.now(); let receivedAnyStreamMessage = false; const streamHeartbeat = setInterval(() => { if (!receivedAnyStreamMessage) logger.info( `Waiting for first model response for feature ${featureId} (${Math.round((Date.now() - streamStartTime) / 1000)}s elapsed)...` ); }, AgentExecutor.STREAM_HEARTBEAT_MS); const planningModeRequiresApproval = planningMode === 'spec' || planningMode === 'full' || (planningMode === 'lite' && requirePlanApproval); const requiresApproval = planningModeRequiresApproval && requirePlanApproval; if (existingApprovedPlanContent && persistedTasks && persistedTasks.length > 0) { const result = await this.executeTasksLoop( options, persistedTasks, existingApprovedPlanContent, responseText, scheduleWrite, callbacks ); clearInterval(streamHeartbeat); if (writeTimeout) clearTimeout(writeTimeout); if (rawWriteTimeout) clearTimeout(rawWriteTimeout); await writeToFile(); // Extract and save summary from the new content generated in this session await this.extractAndSaveSessionSummary( projectPath, featureId, result.responseText, previousContent, callbacks, status ); return { responseText: result.responseText, specDetected: true, tasksCompleted: result.tasksCompleted, aborted: result.aborted, }; } logger.info(`Starting stream for feature ${featureId}...`); try { const stream = provider.executeQuery(executeOptions); streamLoop: for await (const msg of stream) { if (msg.session_id && msg.session_id !== options.sdkSessionId) { options.sdkSessionId = msg.session_id; } receivedAnyStreamMessage = true; appendRawEvent(msg); if (abortController.signal.aborted) { aborted = true; throw new Error('Feature execution aborted'); } if (msg.type === 'assistant' && msg.message?.content) { for (const block of msg.message.content) { if (block.type === 'text') { const newText = block.text || ''; if (!newText) continue; if (responseText.length > 0 && newText.length > 0) { const endsWithSentence = /[.!?:]\s*$/.test(responseText), endsWithNewline = /\n\s*$/.test(responseText); if ( !endsWithNewline && (endsWithSentence || /^[\n#\-*>]/.test(newText)) && !/[a-zA-Z0-9]/.test(responseText.slice(-1)) ) responseText += '\n\n'; } responseText += newText; // Check for authentication errors using provider-agnostic utility if (block.text && isAuthenticationError(block.text)) throw new Error( 'Authentication failed: Invalid or expired API key. Please check your API key configuration or re-authenticate with your provider.' ); scheduleWrite(); const hasExplicitMarker = responseText.includes('[SPEC_GENERATED]'), hasFallbackSpec = !hasExplicitMarker && detectSpecFallback(responseText); if ( planningModeRequiresApproval && !specDetected && (hasExplicitMarker || hasFallbackSpec) ) { specDetected = true; const planContent = hasExplicitMarker ? responseText.substring(0, responseText.indexOf('[SPEC_GENERATED]')).trim() : responseText.trim(); if (!hasExplicitMarker) logger.info(`Using fallback spec detection for feature ${featureId}`); const result = await this.handleSpecGenerated( options, planContent, responseText, requiresApproval, scheduleWrite, callbacks ); responseText = result.responseText; tasksCompleted = result.tasksCompleted; break streamLoop; } if (!specDetected) this.eventBus.emitAutoModeEvent('auto_mode_progress', { featureId, branchName, content: block.text, }); } else if (block.type === 'tool_use') { this.eventBus.emitAutoModeEvent('auto_mode_tool', { featureId, branchName, tool: block.name, input: block.input, }); if (responseText.length > 0 && !responseText.endsWith('\n')) responseText += '\n'; responseText += `\n🔧 Tool: ${block.name}\n`; if (block.input) responseText += `Input: ${JSON.stringify(block.input, null, 2)}\n`; scheduleWrite(); } } } else if (msg.type === 'error') { const sanitized = AgentExecutor.sanitizeProviderError(msg.error); logger.error( `[execute] Feature ${featureId} received error from provider. ` + `raw="${msg.error}", sanitized="${sanitized}", session_id=${msg.session_id ?? 'none'}` ); throw new Error(sanitized); } else if (msg.type === 'result') { if (msg.subtype === 'success') { scheduleWrite(); } else if (msg.subtype?.startsWith('error')) { // Non-success result subtypes from the SDK (error_max_turns, error_during_execution, etc.) logger.error( `[execute] Feature ${featureId} ended with error subtype: ${msg.subtype}. ` + `session_id=${msg.session_id ?? 'none'}` ); throw new Error(`Agent execution ended with: ${msg.subtype}`); } else { logger.warn( `[execute] Feature ${featureId} received unhandled result subtype: ${msg.subtype}` ); } } } } finally { clearInterval(streamHeartbeat); if (writeTimeout) clearTimeout(writeTimeout); if (rawWriteTimeout) clearTimeout(rawWriteTimeout); const streamElapsedMs = Date.now() - streamStartTime; logger.info( `[execute] Stream ended for feature ${featureId} after ${Math.round(streamElapsedMs / 1000)}s. ` + `aborted=${aborted}, specDetected=${specDetected}, responseLength=${responseText.length}` ); await writeToFile(); if (enableRawOutput && rawOutputLines.length > 0) { try { await secureFs.mkdir(path.dirname(rawOutputPath), { recursive: true }); await secureFs.appendFile(rawOutputPath, rawOutputLines.join('\n') + '\n'); } catch { /* ignore */ } } } // Capture summary if it hasn't been captured by handleSpecGenerated or executeTasksLoop // or if we're in a simple execution mode (planningMode='skip') await this.extractAndSaveSessionSummary( projectPath, featureId, responseText, previousContent, callbacks, status ); return { responseText, specDetected, tasksCompleted, aborted }; } /** * Strip the follow-up session scaffold marker from content. * The scaffold is added when resuming a session with previous content: * "\n\n---\n\n## Follow-up Session\n\n" * This ensures fallback summaries don't include the scaffold header. * * The regex pattern handles variations in whitespace while matching the * scaffold structure: dashes followed by "## Follow-up Session" at the * start of the content. */ private static stripFollowUpScaffold(content: string): string { // Pattern matches: ^\s*---\s*##\s*Follow-up Session\s* // - ^ = start of content (scaffold is always at the beginning of sessionContent) // - \s* = any whitespace (handles \n\n before ---, spaces/tabs between markers) // - --- = literal dashes // - \s* = whitespace between dashes and heading // - ## = heading marker // - \s* = whitespace before "Follow-up" // - Follow-up Session = literal heading text // - \s* = trailing whitespace/newlines after heading const scaffoldPattern = /^\s*---\s*##\s*Follow-up Session\s*/; return content.replace(scaffoldPattern, ''); } /** * Extract summary ONLY from the new content generated in this session * and save it via the provided callback. */ private async extractAndSaveSessionSummary( projectPath: string, featureId: string, responseText: string, previousContent: string | undefined, callbacks: AgentExecutorCallbacks, status?: string ): Promise { const sessionContent = responseText.substring(previousContent ? previousContent.length : 0); const summary = extractSummary(sessionContent); if (summary) { await callbacks.saveFeatureSummary(projectPath, featureId, summary); return; } // If we're in a pipeline step, a summary is expected. Use a fallback if extraction fails. if (isPipelineStatus(status)) { // Strip any follow-up session scaffold before using as fallback const cleanSessionContent = AgentExecutor.stripFollowUpScaffold(sessionContent); const fallback = cleanSessionContent.trim(); if (fallback) { await callbacks.saveFeatureSummary(projectPath, featureId, fallback); } logger.warn( `[AgentExecutor] Mandatory summary extraction failed for pipeline feature ${featureId} (status="${status}")` ); } } private async executeTasksLoop( options: AgentExecutionOptions, tasks: ParsedTask[], planContent: string, initialResponseText: string, scheduleWrite: () => void, callbacks: AgentExecutorCallbacks, userFeedback?: string ): Promise<{ responseText: string; tasksCompleted: number; aborted: boolean }> { const { featureId, projectPath, abortController, branchName = null, provider, sdkOptions, } = options; logger.info(`Starting task execution for feature ${featureId} with ${tasks.length} tasks`); const taskPrompts = await getPromptCustomization(this.settingsService, '[AutoMode]'); let responseText = initialResponseText, tasksCompleted = 0; for (let taskIndex = 0; taskIndex < tasks.length; taskIndex++) { const task = tasks[taskIndex]; if (task.status === 'completed') { tasksCompleted++; continue; } if (abortController.signal.aborted) return { responseText, tasksCompleted, aborted: true }; await this.featureStateManager.updateTaskStatus( projectPath, featureId, task.id, 'in_progress' ); this.eventBus.emitAutoModeEvent('auto_mode_task_started', { featureId, projectPath, branchName, taskId: task.id, taskDescription: task.description, taskIndex, tasksTotal: tasks.length, }); await this.featureStateManager.updateFeaturePlanSpec(projectPath, featureId, { currentTaskId: task.id, }); const taskPrompt = callbacks.buildTaskPrompt( task, tasks, taskIndex, planContent, taskPrompts.taskExecution.taskPromptTemplate, userFeedback ); const taskMaxTurns = sdkOptions?.maxTurns ?? DEFAULT_MAX_TURNS; logger.info( `[executeTasksLoop] Feature ${featureId}, task ${task.id} (${taskIndex + 1}/${tasks.length}): ` + `maxTurns=${taskMaxTurns} (sdkOptions.maxTurns=${sdkOptions?.maxTurns ?? 'undefined'})` ); const taskStream = provider.executeQuery( this.buildExecOpts(options, taskPrompt, taskMaxTurns) ); let taskOutput = '', taskStartDetected = false, taskCompleteDetected = false; for await (const msg of taskStream) { if (msg.session_id && msg.session_id !== options.sdkSessionId) { options.sdkSessionId = msg.session_id; } if (msg.type === 'assistant' && msg.message?.content) { for (const b of msg.message.content) { if (b.type === 'text') { const text = b.text || ''; taskOutput += text; responseText += text; this.eventBus.emitAutoModeEvent('auto_mode_progress', { featureId, branchName, content: text, }); scheduleWrite(); if (!taskStartDetected) { const sid = detectTaskStartMarker(taskOutput); if (sid) { taskStartDetected = true; await this.featureStateManager.updateTaskStatus( projectPath, featureId, sid, 'in_progress' ); } } if (!taskCompleteDetected) { const completeMarker = detectTaskCompleteMarker(taskOutput); if (completeMarker) { taskCompleteDetected = true; await this.featureStateManager.updateTaskStatus( projectPath, featureId, completeMarker.id, 'completed', completeMarker.summary ); } } const pn = detectPhaseCompleteMarker(text); if (pn !== null) this.eventBus.emitAutoModeEvent('auto_mode_phase_complete', { featureId, projectPath, branchName, phaseNumber: pn, }); } else if (b.type === 'tool_use') this.eventBus.emitAutoModeEvent('auto_mode_tool', { featureId, branchName, tool: b.name, input: b.input, }); } } else if (msg.type === 'error') { const fallback = `Error during task ${task.id}`; const sanitized = AgentExecutor.sanitizeProviderError(msg.error || fallback); logger.error( `[executeTasksLoop] Feature ${featureId} task ${task.id} received error from provider. ` + `raw="${msg.error}", sanitized="${sanitized}", session_id=${msg.session_id ?? 'none'}` ); throw new Error(sanitized); } else if (msg.type === 'result') { if (msg.subtype === 'success') { taskOutput += msg.result || ''; responseText += msg.result || ''; } else if (msg.subtype?.startsWith('error')) { logger.error( `[executeTasksLoop] Feature ${featureId} task ${task.id} ended with error subtype: ${msg.subtype}. ` + `session_id=${msg.session_id ?? 'none'}` ); throw new Error(`Agent execution ended with: ${msg.subtype}`); } else { logger.warn( `[executeTasksLoop] Feature ${featureId} task ${task.id} received unhandled result subtype: ${msg.subtype}` ); } } } if (!taskCompleteDetected) await this.featureStateManager.updateTaskStatus( projectPath, featureId, task.id, 'completed' ); tasksCompleted = taskIndex + 1; this.eventBus.emitAutoModeEvent('auto_mode_task_complete', { featureId, projectPath, branchName, taskId: task.id, tasksCompleted, tasksTotal: tasks.length, }); await this.featureStateManager.updateFeaturePlanSpec(projectPath, featureId, { tasksCompleted, }); if (task.phase) { const next = tasks[taskIndex + 1]; if (!next || next.phase !== task.phase) { const m = task.phase.match(/Phase\s*(\d+)/i); if (m) this.eventBus.emitAutoModeEvent('auto_mode_phase_complete', { featureId, projectPath, branchName, phaseNumber: parseInt(m[1], 10), }); } } } return { responseText, tasksCompleted, aborted: false }; } private async handleSpecGenerated( options: AgentExecutionOptions, planContent: string, initialResponseText: string, requiresApproval: boolean, scheduleWrite: () => void, callbacks: AgentExecutorCallbacks ): Promise<{ responseText: string; tasksCompleted: number }> { const { featureId, projectPath, branchName = null, planningMode = 'skip', provider, sdkOptions, } = options; let responseText = initialResponseText, parsedTasks = parseTasksFromSpec(planContent); logger.info(`Parsed ${parsedTasks.length} tasks from spec for feature ${featureId}`); await this.featureStateManager.updateFeaturePlanSpec(projectPath, featureId, { status: 'generated', content: planContent, version: 1, generatedAt: new Date().toISOString(), reviewedByUser: false, tasks: parsedTasks, tasksTotal: parsedTasks.length, tasksCompleted: 0, }); const planSummary = extractSummary(planContent); if (planSummary) await callbacks.updateFeatureSummary(projectPath, featureId, planSummary); let approvedPlanContent = planContent, userFeedback: string | undefined, currentPlanContent = planContent, planVersion = 1; if (requiresApproval) { let planApproved = false; while (!planApproved) { logger.info( `Spec v${planVersion} generated for feature ${featureId}, waiting for approval` ); this.eventBus.emitAutoModeEvent('plan_approval_required', { featureId, projectPath, branchName, planContent: currentPlanContent, planningMode, planVersion, }); const approvalResult = await callbacks.waitForApproval(featureId, projectPath); if (approvalResult.approved) { planApproved = true; userFeedback = approvalResult.feedback; approvedPlanContent = approvalResult.editedPlan || currentPlanContent; if (approvalResult.editedPlan) { // Re-parse tasks from edited plan to ensure we execute the updated tasks const editedTasks = parseTasksFromSpec(approvalResult.editedPlan); parsedTasks = editedTasks; await this.featureStateManager.updateFeaturePlanSpec(projectPath, featureId, { content: approvalResult.editedPlan, tasks: editedTasks, tasksTotal: editedTasks.length, tasksCompleted: 0, }); } this.eventBus.emitAutoModeEvent('plan_approved', { featureId, projectPath, branchName, hasEdits: !!approvalResult.editedPlan, planVersion, }); } else { const hasFeedback = approvalResult.feedback?.trim().length, hasEdits = approvalResult.editedPlan?.trim().length; if (!hasFeedback && !hasEdits) throw new Error('Plan cancelled by user'); planVersion++; this.eventBus.emitAutoModeEvent('plan_revision_requested', { featureId, projectPath, branchName, feedback: approvalResult.feedback, hasEdits: !!hasEdits, planVersion, }); const revPrompts = await getPromptCustomization(this.settingsService, '[AutoMode]'); const taskEx = planningMode === 'full' ? '```tasks\n## Phase 1: Foundation\n- [ ] T001: [Description] | File: [path/to/file]\n```' : '```tasks\n- [ ] T001: [Description] | File: [path/to/file]\n```'; let revPrompt = revPrompts.taskExecution.planRevisionTemplate .replace(/\{\{planVersion\}\}/g, String(planVersion - 1)) .replace( /\{\{previousPlan\}\}/g, hasEdits ? approvalResult.editedPlan || currentPlanContent : currentPlanContent ) .replace( /\{\{userFeedback\}\}/g, approvalResult.feedback || 'Please revise the plan based on the edits above.' ) .replace(/\{\{planningMode\}\}/g, planningMode) .replace(/\{\{taskFormatExample\}\}/g, taskEx); await this.featureStateManager.updateFeaturePlanSpec(projectPath, featureId, { status: 'generating', version: planVersion, }); let revText = ''; for await (const msg of provider.executeQuery( this.buildExecOpts(options, revPrompt, sdkOptions?.maxTurns ?? DEFAULT_MAX_TURNS) )) { if (msg.session_id && msg.session_id !== options.sdkSessionId) { options.sdkSessionId = msg.session_id; } if (msg.type === 'assistant' && msg.message?.content) for (const b of msg.message.content) if (b.type === 'text') { revText += b.text || ''; this.eventBus.emitAutoModeEvent('auto_mode_progress', { featureId, branchName, content: b.text, }); } if (msg.type === 'error') { const cleanedError = (msg.error || 'Error during plan revision') .replace(/\x1b\[[0-9;]*m/g, '') .replace(/^Error:\s*/i, '') .trim() || 'Error during plan revision'; throw new Error(cleanedError); } if (msg.type === 'result' && msg.subtype === 'success') revText += msg.result || ''; } const mi = revText.indexOf('[SPEC_GENERATED]'); currentPlanContent = mi > 0 ? revText.substring(0, mi).trim() : revText.trim(); const revisedTasks = parseTasksFromSpec(currentPlanContent); if (revisedTasks.length === 0 && (planningMode === 'spec' || planningMode === 'full')) this.eventBus.emitAutoModeEvent('plan_revision_warning', { featureId, projectPath, branchName, planningMode, warning: 'Revised plan missing tasks block', }); await this.featureStateManager.updateFeaturePlanSpec(projectPath, featureId, { status: 'generated', content: currentPlanContent, version: planVersion, tasks: revisedTasks, tasksTotal: revisedTasks.length, tasksCompleted: 0, }); parsedTasks = revisedTasks; responseText += revText; } } } else { this.eventBus.emitAutoModeEvent('plan_auto_approved', { featureId, projectPath, branchName, planContent, planningMode, }); } await this.featureStateManager.updateFeaturePlanSpec(projectPath, featureId, { status: 'approved', approvedAt: new Date().toISOString(), reviewedByUser: requiresApproval, }); let tasksCompleted = 0; if (parsedTasks.length > 0) { const r = await this.executeTasksLoop( options, parsedTasks, approvedPlanContent, responseText, scheduleWrite, callbacks, userFeedback ); responseText = r.responseText; tasksCompleted = r.tasksCompleted; } else { const r = await this.executeSingleAgentContinuation( options, approvedPlanContent, userFeedback, responseText ); responseText = r.responseText; } return { responseText, tasksCompleted }; } private buildExecOpts(o: AgentExecutionOptions, prompt: string, maxTurns: number) { return { prompt, model: o.effectiveBareModel, maxTurns, cwd: o.workDir, allowedTools: o.sdkOptions?.allowedTools as string[] | undefined, abortController: o.abortController, thinkingLevel: o.thinkingLevel, reasoningEffort: o.reasoningEffort, mcpServers: o.mcpServers && Object.keys(o.mcpServers).length > 0 ? (o.mcpServers as Record) : undefined, credentials: o.credentials, claudeCompatibleProvider: o.claudeCompatibleProvider, sdkSessionId: o.sdkSessionId, }; } private async executeSingleAgentContinuation( options: AgentExecutionOptions, planContent: string, userFeedback: string | undefined, initialResponseText: string ): Promise<{ responseText: string }> { const { featureId, branchName = null, provider } = options; logger.info(`No parsed tasks, using single-agent execution for feature ${featureId}`); const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]'); const contPrompt = prompts.taskExecution.continuationAfterApprovalTemplate .replace(/\{\{userFeedback\}\}/g, userFeedback || '') .replace(/\{\{approvedPlan\}\}/g, planContent); let responseText = initialResponseText; for await (const msg of provider.executeQuery( this.buildExecOpts(options, contPrompt, options.sdkOptions?.maxTurns ?? DEFAULT_MAX_TURNS) )) { if (msg.session_id && msg.session_id !== options.sdkSessionId) { options.sdkSessionId = msg.session_id; } if (msg.type === 'assistant' && msg.message?.content) for (const b of msg.message.content) { if (b.type === 'text') { responseText += b.text || ''; this.eventBus.emitAutoModeEvent('auto_mode_progress', { featureId, branchName, content: b.text, }); } else if (b.type === 'tool_use') this.eventBus.emitAutoModeEvent('auto_mode_tool', { featureId, branchName, tool: b.name, input: b.input, }); } else if (msg.type === 'error') { const cleanedError = (msg.error || 'Unknown error during implementation') .replace(/\x1b\[[0-9;]*m/g, '') .replace(/^Error:\s*/i, '') .trim() || 'Unknown error during implementation'; throw new Error(cleanedError); } else if (msg.type === 'result' && msg.subtype === 'success') responseText += msg.result || ''; } return { responseText }; } }