UNPKG

@autifyhq/muon

Version:

Muon - AI-Powered Playwright Test Coding Agent with Advanced Test Fixing Capabilities

431 lines (430 loc) 21.8 kB
import { MuonHTTPClient } from './http-client.js'; import { CodingTools } from './tools.js'; export class StreamingMuonAgent { constructor(config) { this.conversationHistory = []; // Local conversation history this.config = config; // Validate authentication - either API key or access token is required if (!config.apiKey && !config.accessToken) { throw new Error('Authentication is required to use Muon Agent.\n' + 'Option 1: Run "muon login" to authenticate via OAuth\n' + 'Option 2: Set MUON_API_KEY environment variable or pass apiKey in config.\n' + `Get your API key from: ${config.serverUrl}/keys`); } // Validate API key format if provided (but allow access tokens to bypass this) if (config.apiKey && !config.accessToken && !config.apiKey.startsWith('muon_live_') && !config.apiKey.startsWith('muon_test_')) { throw new Error('Invalid API key format. API keys should start with "muon_live_" or "muon_test_".\n' + `Get a valid API key from: ${config.serverUrl}/keys`); } this.tools = new CodingTools(config.projectPath || process.cwd(), undefined); this.httpClient = new MuonHTTPClient({ baseUrl: config.serverUrl, apiKey: config.apiKey, accessToken: config.accessToken, auth: config.auth, }); this.sessionId = this.generateSessionId(); } generateSessionId() { return `muon-${crypto.randomUUID()}`; } interrupt() { if (this.abortController) { this.abortController.abort(); } } async *query(prompt) { const startTime = Date.now(); this.abortController = new AbortController(); try { // Add user message to local conversation history const userMessage = { role: 'user', content: prompt, }; this.conversationHistory.push(userMessage); // Emit user message yield { type: 'user', content: prompt, session_id: this.sessionId, timestamp: new Date(), }; // Continue conversation loop until completion let iteration = 0; const maxIterations = 25; while (iteration < maxIterations) { iteration++; try { // Send all conversation history to server const endpoint = this.config.nlstepMode ? '/api/nlstep-agent-response' : '/api/agent-response'; const requestBody = { messages: this.conversationHistory, agentType: this.config.agentType || 'general', projectPath: this.config.projectPath, sessionId: this.sessionId, stream: true, }; // Add nlstep-specific fields when in nlstep mode if (this.config.nlstepMode) { ; requestBody.instruction = prompt; requestBody.conversationContext = `Session: ${this.sessionId}`; } const streamResponse = await this.httpClient.request(endpoint, { method: 'POST', body: JSON.stringify(requestBody), stream: true, }); let hasReceivedData = false; let streamCompleted = false; let pendingToolCalls = []; let assistantMessage = null; let accumulatedContent = ''; // Use the parsed data from HTTP client for await (const serverMessage of streamResponse) { hasReceivedData = true; if (this.abortController?.signal.aborted) { return { type: 'result', subtype: 'interrupted', duration_ms: Date.now() - startTime, session_id: this.sessionId, error: 'Task was interrupted', }; } // Convert timestamp if it's a string if (typeof serverMessage.timestamp === 'string') { serverMessage.timestamp = new Date(serverMessage.timestamp); } if (serverMessage.type === 'assistant_start') { accumulatedContent = ''; yield serverMessage; } else if (serverMessage.type === 'assistant_delta') { accumulatedContent += serverMessage.content || ''; yield serverMessage; } else if (serverMessage.type === 'assistant_complete') { yield serverMessage; // Build the assistant message for conversation history assistantMessage = { role: 'assistant', content: accumulatedContent || serverMessage.content, }; if (serverMessage.tool_calls && serverMessage.tool_calls.length > 0) { pendingToolCalls = serverMessage.tool_calls; assistantMessage.tool_calls = serverMessage.tool_calls; } } else if (serverMessage.type === 'stream_complete') { yield serverMessage; streamCompleted = true; break; } else if (serverMessage.type === 'error') { yield serverMessage; return { type: 'result', subtype: 'error', duration_ms: Date.now() - startTime, session_id: this.sessionId, error: serverMessage.content, }; } else { yield serverMessage; } } if (!hasReceivedData && !streamCompleted) { throw new Error('Stream ended without receiving any data'); } // Add assistant message to local conversation history if (assistantMessage) { this.conversationHistory.push(assistantMessage); } // Execute tool calls if we have any if (pendingToolCalls.length > 0) { for (const toolCall of pendingToolCalls) { if (this.abortController?.signal.aborted) { return { type: 'result', subtype: 'interrupted', duration_ms: Date.now() - startTime, session_id: this.sessionId, error: 'Task was interrupted during tool execution', }; } yield { type: 'tool_call', content: `Executing ${toolCall.function?.name || toolCall.name}...`, tool_calls: [toolCall], session_id: this.sessionId, timestamp: new Date(), }; try { const result = await this.executeToolCall({ id: toolCall.id, name: toolCall.function?.name || toolCall.name || 'unknown', arguments: toolCall.function?.arguments || JSON.stringify(toolCall.arguments || {}), }); if (this.abortController?.signal.aborted) { return { type: 'result', subtype: 'interrupted', duration_ms: Date.now() - startTime, session_id: this.sessionId, error: 'Task was interrupted during tool execution', }; } yield { type: 'tool_result', content: result, tool_call_id: toolCall.id, session_id: this.sessionId, timestamp: new Date(), }; // Add tool result to local conversation history this.conversationHistory.push({ role: 'tool', tool_call_id: toolCall.id, content: result, }); } catch (toolError) { if (this.abortController?.signal.aborted) { return { type: 'result', subtype: 'interrupted', duration_ms: Date.now() - startTime, session_id: this.sessionId, error: 'Task was interrupted during tool execution', }; } const errorMessage = `Tool ${toolCall.function?.name || toolCall.name} failed: ${toolError instanceof Error ? toolError.message : String(toolError)}`; yield { type: 'error', content: errorMessage, tool_call_id: toolCall.id, session_id: this.sessionId, timestamp: new Date(), }; // Add failed tool result to local conversation history this.conversationHistory.push({ role: 'tool', tool_call_id: toolCall.id, content: `FAILED: ${errorMessage}`, }); } } } else { // No tool calls, conversation is complete break; } } catch (error) { console.error('Streaming error:', error); // Fallback to non-streaming request console.log('🔄 Falling back to non-streaming request...'); try { const fallbackResponse = await this.httpClient.request('/api/agent-response', { method: 'POST', body: JSON.stringify({ messages: this.conversationHistory, // Send full conversation history agentType: this.config.agentType || 'general', projectPath: this.config.projectPath, sessionId: this.sessionId, stream: false, }), }); // Add assistant response to local conversation history const assistantMessage = { role: 'assistant', content: fallbackResponse.content, }; if (fallbackResponse.tool_calls) { assistantMessage.tool_calls = fallbackResponse.tool_calls; } this.conversationHistory.push(assistantMessage); // Emit assistant response from fallback yield { type: 'assistant', content: fallbackResponse.content, tool_calls: fallbackResponse.tool_calls, session_id: this.sessionId, timestamp: new Date(), }; // Execute tools if present if (fallbackResponse.tool_calls) { for (const toolCall of fallbackResponse.tool_calls) { if (this.abortController?.signal.aborted) { return { type: 'result', subtype: 'interrupted', duration_ms: Date.now() - startTime, session_id: this.sessionId, error: 'Task was interrupted during fallback tool execution', }; } yield { type: 'tool_call', content: `Executing ${toolCall.function?.name || toolCall.name}...`, tool_calls: [toolCall], session_id: this.sessionId, timestamp: new Date(), }; try { const result = await this.executeToolCall({ id: toolCall.id, name: toolCall.function?.name || toolCall.name, arguments: toolCall.function?.arguments || toolCall.arguments || '{}', }); if (this.abortController?.signal.aborted) { return { type: 'result', subtype: 'interrupted', duration_ms: Date.now() - startTime, session_id: this.sessionId, error: 'Task was interrupted during fallback tool execution', }; } yield { type: 'tool_result', content: result, tool_call_id: toolCall.id, session_id: this.sessionId, timestamp: new Date(), }; // Add tool result to local conversation history this.conversationHistory.push({ role: 'tool', tool_call_id: toolCall.id, content: result, }); } catch (toolError) { if (this.abortController?.signal.aborted) { return { type: 'result', subtype: 'interrupted', duration_ms: Date.now() - startTime, session_id: this.sessionId, error: 'Task was interrupted during fallback tool execution', }; } const errorMessage = `Tool ${toolCall.function?.name || toolCall.name} failed: ${toolError instanceof Error ? toolError.message : String(toolError)}`; yield { type: 'error', content: errorMessage, tool_call_id: toolCall.id, session_id: this.sessionId, timestamp: new Date(), }; // Add failed tool result to local conversation history this.conversationHistory.push({ role: 'tool', tool_call_id: toolCall.id, content: `FAILED: ${errorMessage}`, }); } } // Continue loop for next response continue; } // No tool calls in fallback, we're done break; } catch (fallbackError) { return { type: 'result', subtype: 'error', duration_ms: Date.now() - startTime, session_id: this.sessionId, error: fallbackError instanceof Error ? fallbackError.message : String(fallbackError), }; } } } if (iteration >= maxIterations) { console.warn(`⚠️ Agent reached maximum iteration limit (${maxIterations}), stopping.`); } return { type: 'result', subtype: 'success', duration_ms: Date.now() - startTime, session_id: this.sessionId, result: 'Task completed successfully', }; } catch (error) { if (this.abortController?.signal.aborted) { return { type: 'result', subtype: 'interrupted', duration_ms: Date.now() - startTime, session_id: this.sessionId, error: 'Task was interrupted', }; } return { type: 'result', subtype: 'error', duration_ms: Date.now() - startTime, session_id: this.sessionId, error: error instanceof Error ? error.message : String(error), }; } } async executeToolCall(toolCall) { if (this.abortController?.signal.aborted) { throw new Error('Tool execution aborted'); } const toolFunction = this.tools.getTools().find((t) => t.name === toolCall.name); if (!toolFunction) { throw new Error(`Unknown tool: ${toolCall.name}`); } if (this.abortController?.signal.aborted) { throw new Error('Tool execution aborted'); } const args = JSON.parse(toolCall.arguments); if (this.abortController?.signal.aborted) { throw new Error('Tool execution aborted'); } const result = await toolFunction.func(args); return typeof result === 'string' ? result : JSON.stringify(result); } abort() { this.abortController?.abort(); } // Direct nlstep call to /api/nlstep endpoint async executeNLStep(request) { try { const response = await this.httpClient.request('/api/nlstep', { method: 'POST', body: JSON.stringify(request), }); return response; } catch (error) { throw new Error(`NLStep execution failed: ${error instanceof Error ? error.message : 'Unknown error'}`); } } async cleanup() { this.abort(); await this.tools.closeBrowser(); } // Optional: Method to get conversation history for debugging getConversationHistory() { return [...this.conversationHistory]; } // Optional: Method to clear conversation history clearConversationHistory() { this.conversationHistory = []; } }