/** * March Agent SDK - Main Application * Port of Python march_agent/app.py */ import { Agent } from './agent.js' import { GatewayClient } from './gateway-client.js' import { ConversationClient } from './conversation-client.js' import { MemoryClient } from './memory-client.js' import { AttachmentClient } from './attachment-client.js' import { RegistrationError, ConfigurationError, ReconnectionError } from './exceptions.js' import { AI_INVENTORY_PATHS, SERVICES } from './api-paths.js' import type { AppOptions, RegisterOptions, AgentRegistrationData, ConnectionState, ConnectionStateListener, } from './types.js' /** * Main application class for March AI Agent framework. * * @example * ```typescript * import { MarchAgentApp } from 'march-ai-sdk' * * const app = new MarchAgentApp({ * gatewayUrl: 'agent-gateway:8080', * apiKey: 'your-api-key', * reconnection: { * maxRetries: 10, * initialDelayMs: 1000, * }, * }) * * const agent = app.registerMe({ * name: 'my-agent', * about: 'A helpful assistant', * document: 'Detailed description...', * }) * * agent.onMessage(async (message, sender) => { * const streamer = agent.streamer(message) * streamer.stream('Hello!') * await streamer.finish() * }) * * // Listen for connection state changes * app.onConnectionStateChange((state, error) => { * console.log(`Connection state: ${state}`, error?.message) * }) * * app.run() * ``` */ export class MarchAgentApp { readonly gatewayClient: GatewayClient readonly conversationClient: ConversationClient readonly memoryClient: MemoryClient readonly attachmentClient: AttachmentClient private readonly heartbeatInterval: number private readonly _maxConcurrentTasks: number private readonly errorMessageTemplate: string private agents: Agent[] = [] private running: boolean = false private shutdownRequested: boolean = false private appStateListeners: ConnectionStateListener[] = [] constructor(options: AppOptions) { this.heartbeatInterval = options.heartbeatInterval ?? 60 this._maxConcurrentTasks = options.maxConcurrentTasks ?? 100 this.errorMessageTemplate = options.errorMessageTemplate ?? 'I encountered an error while processing your message. Please try again or contact support if the issue persists.' // Create gateway client with reconnection options this.gatewayClient = new GatewayClient( options.gatewayUrl, options.apiKey, options.secure ?? false, options.reconnection ) // Set up connection state listener to handle reconnections this.gatewayClient.onConnectionStateChange((state, error) => { this.handleConnectionStateChange(state, error) }) // Create HTTP clients using gateway proxy URLs this.conversationClient = new ConversationClient( this.gatewayClient.conversationStoreUrl ) this.memoryClient = new MemoryClient( this.gatewayClient.aiMemoryUrl ) this.attachmentClient = new AttachmentClient( this.gatewayClient.attachmentUrl ) } /** * Register a listener for connection state changes at the app level. * Returns an unsubscribe function. */ onConnectionStateChange(listener: ConnectionStateListener): () => void { this.appStateListeners.push(listener) return () => { const index = this.appStateListeners.indexOf(listener) if (index !== -1) { this.appStateListeners.splice(index, 1) } } } /** * Get the current connection state. */ getConnectionState(): ConnectionState { return this.gatewayClient.getConnectionState() } /** * Handle connection state changes from the gateway client. */ private handleConnectionStateChange(state: ConnectionState, error?: Error): void { // Notify app-level listeners for (const listener of this.appStateListeners) { try { listener(state, error) } catch (e) { console.error('Error in app connection state listener:', e) } } // Handle reconnection completion if (state === 'connected' && this.running) { // Re-initialize agents after successful reconnection console.log('Connection restored, re-initializing agents...') this.reinitializeAgents() } // Handle permanent disconnection if (state === 'disconnected' && error instanceof ReconnectionError) { console.error('Permanent disconnection - max reconnection attempts exceeded') // The app will continue running but won't receive messages // Users can handle this via the state listener } } /** * Re-initialize agents after reconnection. */ private reinitializeAgents(): void { for (const agent of this.agents) { try { // Re-register the message handler with the gateway agent.initializeWithGateway() console.log(`Re-initialized agent: ${agent.name}`) } catch (error) { console.error(`Failed to re-initialize agent ${agent.name}:`, error) } } } /** * Register an agent with the backend. */ async registerMe(options: RegisterOptions): Promise { // Register with AI Inventory const agentData = await this.registerWithInventory(options) // Create agent instance const agent = new Agent({ name: options.name, gatewayClient: this.gatewayClient, agentData, heartbeatInterval: this.heartbeatInterval, conversationClient: this.conversationClient, memoryClient: this.memoryClient, attachmentClient: this.attachmentClient, errorMessageTemplate: this.errorMessageTemplate, }) this.agents.push(agent) console.log(`Registered agent: ${options.name}`) return agent } /** * Register agent with AI Inventory service. */ private async registerWithInventory(options: RegisterOptions): Promise { // Build registration payload (API expects camelCase) const payload: Record = { name: options.name, about: options.about, document: options.document, representationName: options.representationName || options.name, } if (options.baseUrl) { payload.baseUrl = options.baseUrl } if (options.metadata) { payload.metadata = options.metadata } if (options.relatedPages) { payload.relatedPages = options.relatedPages } // Register via gateway HTTP proxy try { const response = await this.gatewayClient.httpPost( SERVICES.AI_INVENTORY, AI_INVENTORY_PATHS.AGENT_REGISTER, payload ) if (!response.ok) { const errorText = await response.text() console.error('Registration failed:', response.status, errorText) throw new RegistrationError( `Failed to register agent ${options.name}: ${response.status}` ) } const data = await response.json() as Record return { id: data.id as string, name: data.name as string, about: data.about as string, document: data.document as string, representationName: data.representationName as string | undefined, baseUrl: data.baseUrl as string | undefined, metadata: data.metadata as Record | undefined, relatedPages: data.relatedPages as { name: string; endpoint: string }[] | undefined, } } catch (error) { if (error instanceof RegistrationError) throw error throw new RegistrationError(`Failed to register agent ${options.name}: ${error}`) } } /** * Start all registered agents and block until shutdown. */ async run(): Promise { if (this.agents.length === 0) { throw new ConfigurationError('No agents registered') } // Connect to gateway const agentNames = this.agents.map((a) => a.name) console.log(`Connecting to gateway with agents: ${agentNames.join(', ')}`) try { const topics = await this.gatewayClient.connect(agentNames) console.log(`Connected. Subscribed to topics: ${topics.join(', ')}`) } catch (error) { throw new ConfigurationError(`Failed to connect to gateway: ${error}`) } // Initialize all agents for (const agent of this.agents) { agent.initializeWithGateway() agent.startConsuming() } this.running = true console.log('Agent app is running. Press Ctrl+C to stop.') // Set up shutdown handlers process.on('SIGINT', () => this.shutdown()) process.on('SIGTERM', () => this.shutdown()) // Keep the process alive await this.consumeLoop() } /** * Main consume loop. * Monitors connection state and keeps the event loop alive. */ private async consumeLoop(): Promise { while (this.running && !this.shutdownRequested) { // The gateway client handles message dispatch via callbacks // We just need to keep the event loop alive await new Promise((resolve) => setTimeout(resolve, 100)) // Log periodic status when reconnecting const state = this.gatewayClient.getConnectionState() if (state === 'reconnecting') { const attempts = this.gatewayClient.getReconnectAttempts() if (attempts > 0 && attempts % 5 === 0) { console.log(`Still reconnecting... (attempt ${attempts})`) } } } } /** * Force a reconnection attempt. * Useful when you detect a stale connection (e.g., from heartbeat failures). */ async forceReconnect(): Promise { console.log('Force reconnection requested...') try { await this.gatewayClient.forceReconnect() } catch (error) { console.error('Force reconnection failed:', error) throw error } } /** * Check if the gateway is currently connected. */ isConnected(): boolean { return this.gatewayClient.isConnected() } /** * Shutdown all agents gracefully. */ shutdown(): void { if (this.shutdownRequested) { return } console.log('\nShutting down...') this.shutdownRequested = true this.running = false // Shutdown all agents for (const agent of this.agents) { agent.shutdown() } // Close gateway connection this.gatewayClient.close() console.log('Shutdown complete') } }