@autifyhq/muon
Version:
Muon - AI-Powered Playwright Test Coding Agent with Advanced Test Fixing Capabilities
431 lines (430 loc) • 21.8 kB
JavaScript
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 = [];
}
}