UNPKG

agent-world

Version:

World-mediated agent management system with clean API surface

685 lines (684 loc) 29.9 kB
#!/usr/bin/env node // Load environment variables from .env file import dotenv from 'dotenv'; dotenv.config(); /** * Agent World CLI Entry Point - Dual-Mode Console Interface * * Provides pipeline and interactive modes with unified subscription system, * real-time streaming, and comprehensive world management. * * FEATURES: * - Pipeline Mode: Execute commands and exit with timer-based cleanup * - Interactive Mode: Real-time console interface with streaming responses * - Unified Subscription: Both modes use subscribeWorld for consistent event handling * - World Management: Auto-discovery and interactive selection * - Real-time Streaming: Live agent responses via stream.ts module * - Color Helpers: Consistent styling with simplified color functions * - Timer Management: Smart prompt restoration and exit handling * - Debug Logging: Configurable log levels using core logger module * - Environment Variables: Automatically loads .env file for API keys and configuration * * ARCHITECTURE: * - Uses commander.js for argument parsing and mode detection * - Uses subscribeWorld for all world management in both modes * - Implements ClientConnection interface for console-based event handling * - Uses readline for interactive input with proper cleanup * - Delegates streaming display to stream.ts module for real-time chunk accumulation * - Uses core logger for structured debug logging with configurable levels * * USAGE: * Pipeline: cli --root /data/worlds --world myworld --command "/clear agent1" * Pipeline: cli --root /data/worlds --world myworld "Hello, world!" * Pipeline: echo "Hello, world!" | cli --root /data/worlds --world myworld * Interactive: cli --root /data/worlds --world myworld * Debug Mode: cli --root /data/worlds --world myworld --logLevel debug */ import path from 'path'; import { fileURLToPath } from 'url'; import { program } from 'commander'; import readline from 'readline'; import { listWorlds, subscribeWorld, createCategoryLogger, LLMProvider, enableStreaming, disableStreaming } from '../core/index.js'; import { getDefaultRootPath } from '../core/storage/storage-factory.js'; import { processCLIInput } from './commands.js'; import { createStreamingState, handleWorldEventWithStreaming, } from './stream.js'; import { configureLLMProvider } from '../core/llm-config.js'; // Create CLI category logger after logger auto-initialization const logger = createCategoryLogger('cli'); function setupPromptTimer(globalState, rl, callback, delay = 2000) { clearPromptTimer(globalState); globalState.promptTimer = setTimeout(callback, delay); } function clearPromptTimer(globalState) { if (globalState.promptTimer) { clearTimeout(globalState.promptTimer); globalState.promptTimer = undefined; } } function createGlobalState() { return {}; } // Color helpers - consolidated styling API const red = (text) => `\x1b[31m${text}\x1b[0m`; const green = (text) => `\x1b[32m${text}\x1b[0m`; const yellow = (text) => `\x1b[33m${text}\x1b[0m`; const blue = (text) => `\x1b[34m${text}\x1b[0m`; const magenta = (text) => `\x1b[35m${text}\x1b[0m`; const cyan = (text) => `\x1b[36m${text}\x1b[0m`; const gray = (text) => `\x1b[90m${text}\x1b[0m`; const bold = (text) => `\x1b[1m${text}\x1b[0m`; const boldRed = (text) => `\x1b[1m\x1b[31m${text}\x1b[0m`; const boldGreen = (text) => `\x1b[1m\x1b[32m${text}\x1b[0m`; const boldYellow = (text) => `\x1b[1m\x1b[33m${text}\x1b[0m`; const boldBlue = (text) => `\x1b[1m\x1b[34m${text}\x1b[0m`; const boldMagenta = (text) => `\x1b[1m\x1b[35m${text}\x1b[0m`; const boldCyan = (text) => `\x1b[1m\x1b[36m${text}\x1b[0m`; const success = (text) => `${boldGreen('✓')} ${text}`; const error = (text) => `${boldRed('✗')} ${text}`; const bullet = (text) => `${gray('•')} ${text}`; // LLM Provider configuration from environment variables function configureLLMProvidersFromEnv() { // OpenAI if (process.env.OPENAI_API_KEY) { configureLLMProvider(LLMProvider.OPENAI, { apiKey: process.env.OPENAI_API_KEY }); logger.debug('Configured OpenAI provider from environment'); } // Anthropic if (process.env.ANTHROPIC_API_KEY) { configureLLMProvider(LLMProvider.ANTHROPIC, { apiKey: process.env.ANTHROPIC_API_KEY }); logger.debug('Configured Anthropic provider from environment'); } // Google if (process.env.GOOGLE_API_KEY) { configureLLMProvider(LLMProvider.GOOGLE, { apiKey: process.env.GOOGLE_API_KEY }); logger.debug('Configured Google provider from environment'); } // Azure if (process.env.AZURE_OPENAI_API_KEY && process.env.AZURE_ENDPOINT && process.env.AZURE_DEPLOYMENT) { configureLLMProvider(LLMProvider.AZURE, { apiKey: process.env.AZURE_OPENAI_API_KEY, endpoint: process.env.AZURE_ENDPOINT, deployment: process.env.AZURE_DEPLOYMENT, apiVersion: process.env.AZURE_API_VERSION || '2023-12-01-preview' }); logger.debug('Configured Azure provider from environment'); } // XAI if (process.env.XAI_API_KEY) { configureLLMProvider(LLMProvider.XAI, { apiKey: process.env.XAI_API_KEY }); logger.debug('Configured XAI provider from environment'); } // OpenAI Compatible if (process.env.OPENAI_COMPATIBLE_API_KEY && process.env.OPENAI_COMPATIBLE_BASE_URL) { configureLLMProvider(LLMProvider.OPENAI_COMPATIBLE, { apiKey: process.env.OPENAI_COMPATIBLE_API_KEY, baseUrl: process.env.OPENAI_COMPATIBLE_BASE_URL }); logger.debug('Configured OpenAI-Compatible provider from environment'); } // Ollama if (process.env.OLLAMA_BASE_URL) { configureLLMProvider(LLMProvider.OLLAMA, { baseUrl: process.env.OLLAMA_BASE_URL }); logger.debug('Configured Ollama provider from environment'); } else { // Configure Ollama with default URL if not specified configureLLMProvider(LLMProvider.OLLAMA, { baseUrl: 'http://localhost:11434/api' }); logger.debug('Configured Ollama provider with default URL'); } } // Get default root path from storage-factory (no local defaults) const DEFAULT_ROOT_PATH = getDefaultRootPath(); // Helper to print CLI results in a user-friendly way function printCLIResult(result) { if (result.success) { if (result.message) console.log(success(result.message)); if (result.data && typeof result.data === 'string') console.log(result.data); } else { if (result.message) console.log(error(result.message)); if (result.error && result.error !== result.message) console.log(error(result.error)); } } // Pipeline mode execution with timer-based cleanup async function runPipelineMode(options, messageFromArgs) { disableStreaming(); try { let world = null; let worldSubscription = null; let timeoutId = null; const pipelineClient = { isOpen: true, onWorldEvent: (eventType, eventData) => { if (eventData.content && eventData.content.includes('Success message sent')) return; if ((eventType === 'system' || eventType === 'world') && (eventData.message || eventData.content)) { // existing logic } else if (eventType === 'message' && eventData.sender === 'system') { const msg = eventData.content; console.log(`${boldRed('● system:')} ${msg}`); } if (eventType === 'sse' && eventData.content) { setupExitTimer(5000); } if (eventType === 'message' && eventData.content) { console.log(`${boldGreen('● ' + (eventData.sender || 'agent') + ':')} ${eventData.content}`); setupExitTimer(3000); } }, onError: (error) => { console.log(red(`Error: ${error}`)); } }; const setupExitTimer = (delay = 2000) => { if (timeoutId) clearTimeout(timeoutId); timeoutId = setTimeout(() => { if (worldSubscription) worldSubscription.unsubscribe(); process.exit(0); }, delay); }; if (options.world) { worldSubscription = await subscribeWorld(options.world, pipelineClient); if (!worldSubscription) { console.error(boldRed(`Error: World '${options.world}' not found`)); process.exit(1); } world = worldSubscription.world; } // Execute command from --command option if (options.command) { if (!options.command.startsWith('/') && !world) { console.error(boldRed('Error: World must be specified to send user messages')); process.exit(1); } const result = await processCLIInput(options.command, world, 'HUMAN'); printCLIResult(result); // Only set timer if sending message to world (not for commands) if (!options.command.startsWith('/') && world) { setupExitTimer(); } else { // For commands, exit immediately after processing if (worldSubscription) worldSubscription.unsubscribe(); process.exit(result.success ? 0 : 1); } if (!result.success) { setTimeout(() => process.exit(1), 100); return; } } // Execute message from args if (messageFromArgs) { if (!world) { console.error(boldRed('Error: World must be specified to send user messages')); process.exit(1); } const result = await processCLIInput(messageFromArgs, world, 'HUMAN'); printCLIResult(result); // Set timer with longer delay for message processing (always needed for messages) setupExitTimer(8000); if (!result.success) { setTimeout(() => process.exit(1), 100); return; } } // Handle stdin input if (!process.stdin.isTTY) { let input = ''; process.stdin.setEncoding('utf8'); for await (const chunk of process.stdin) input += chunk; if (input.trim()) { if (!world) { console.error(boldRed('Error: World must be specified to send user messages')); process.exit(1); } const result = await processCLIInput(input.trim(), world, 'HUMAN'); printCLIResult(result); // Set timer with longer delay for message processing (always needed for stdin messages) setupExitTimer(8000); if (!result.success) { setTimeout(() => process.exit(1), 100); return; } return; } } if (!options.command && !messageFromArgs) { program.help(); } } catch (error) { console.error(boldRed('Error:'), error instanceof Error ? error.message : error); process.exit(1); } } function cleanupWorldSubscription(worldState) { if (worldState?.subscription) { worldState.subscription.unsubscribe(); } } // World subscription handler async function handleSubscribe(rootPath, worldName, streaming, globalState, rl) { const cliClient = { isOpen: true, onWorldEvent: (eventType, eventData) => { handleWorldEvent(eventType, eventData, streaming, globalState, rl); }, onError: (error) => { console.log(red(`Error: ${error}`)); } }; const subscription = await subscribeWorld(worldName, cliClient); if (!subscription) throw new Error('Failed to load world'); return { subscription, world: subscription.world }; } // Handle world events with streaming support function handleWorldEvent(eventType, eventData, streaming, globalState, rl) { if (handleWorldEventWithStreaming(eventType, eventData, streaming)) { return; } if (eventData.content && eventData.content.includes('Success message sent')) return; if ((eventType === 'system' || eventType === 'world') && (eventData.message || eventData.content)) { // existing logic } else if (eventType === 'message' && eventData.sender === 'system') { const msg = eventData.content; console.log(`${boldRed('● system:')} ${msg}`); } } // World discovery and selection async function getAvailableWorldNames(rootPath) { try { const worldInfos = await listWorlds(); return worldInfos.map(info => info.id); } catch (error) { console.error('Error listing worlds:', error); return []; } } async function selectWorld(rootPath, rl) { const worlds = await getAvailableWorldNames(rootPath); if (worlds.length === 0) { console.log(boldRed(`No worlds found in ${rootPath}`)); return null; } if (worlds.length === 1) { console.log(`${boldGreen('Auto-selecting the only available world:')} ${cyan(worlds[0])}`); return worlds[0]; } console.log(`\n${boldMagenta('Available worlds:')}`); console.log(` ${yellow('0.')} ${cyan('Exit')}`); worlds.forEach((world, index) => { console.log(` ${yellow(`${index + 1}.`)} ${cyan(world)}`); }); return new Promise((resolve) => { function askForSelection() { rl.question(`\n${boldMagenta('Select a world (number or name), or 0 to exit:')} `, (answer) => { const trimmed = answer.trim(); const num = parseInt(trimmed); if (num === 0) { resolve(null); return; } if (!isNaN(num) && num >= 1 && num <= worlds.length) { resolve(worlds[num - 1]); return; } const found = worlds.find(world => world.toLowerCase() === trimmed.toLowerCase() || world.toLowerCase().includes(trimmed.toLowerCase())); if (found) { resolve(found); return; } console.log(boldRed('Invalid selection. Please try again.')); askForSelection(); }); } askForSelection(); }); } // Interactive mode: console-based interface async function runInteractiveMode(options) { const rootPath = options.root || DEFAULT_ROOT_PATH; enableStreaming(); const globalState = createGlobalState(); const streaming = createStreamingState(); const rl = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: '> ' }); // Set up streaming callbacks streaming.wait = (delay) => { setupPromptTimer(globalState, rl, () => { if (streaming.isActive) { console.log(`\n${gray('Streaming appears stalled - waiting for user input...')}`); streaming.isActive = false; streaming.content = ''; streaming.sender = undefined; streaming.messageId = undefined; rl.prompt(); } else { rl.prompt(); } }, delay); }; streaming.stopWait = () => { clearPromptTimer(globalState); }; console.log(boldCyan('Agent World CLI (Interactive Mode)')); console.log(cyan('====================================')); let worldState = null; let currentWorldName = ''; let isExiting = false; try { // Load initial world or prompt for selection if (options.world) { logger.debug(`Loading world: ${options.world}`); try { worldState = await handleSubscribe(rootPath, options.world, streaming, globalState, rl); currentWorldName = options.world; console.log(success(`Connected to world: ${currentWorldName}`)); if (worldState?.world) { console.log(`${gray('Agents:')} ${yellow(String(worldState.world.agents?.size || 0))} ${gray('| Turn Limit:')} ${yellow(String(worldState.world.turnLimit || 'N/A'))}`); } } catch (err) { console.error(error(`Error loading world: ${err instanceof Error ? err.message : 'Unknown error'}`)); process.exit(1); } } else { console.log(`\n${boldBlue('Discovering available worlds...')}`); const selectedWorld = await selectWorld(rootPath, rl); if (!selectedWorld) { console.log(error('No world selected. Exiting.')); rl.close(); return; } logger.debug(`Loading world: ${selectedWorld}`); try { worldState = await handleSubscribe(rootPath, selectedWorld, streaming, globalState, rl); currentWorldName = selectedWorld; console.log(success(`Connected to world: ${currentWorldName}`)); if (worldState?.world) { console.log(`${gray('Agents:')} ${yellow(String(worldState.world.agents?.size || 0))} ${gray('| Turn Limit:')} ${yellow(String(worldState.world.turnLimit || 'N/A'))}`); } } catch (err) { console.error(error(`Error loading world: ${err instanceof Error ? err.message : 'Unknown error'}`)); rl.close(); return; } } // Show usage tips console.log(`\n${gray('Tips:')}`); console.log(` ${bullet(gray('Short commands:'))} ${cyan('/list')}, ${cyan('/show agent1')}, ${cyan('/edit agent1')}, ${cyan('/del agent1')}`); console.log(` ${bullet(gray('Context-sensitive:'))} ${cyan('/list')} ${gray('shows agents if world selected, worlds otherwise')}`); console.log(` ${bullet(gray('Legacy commands:'))} ${cyan('/clear agent1')}, ${cyan('/clear all')}, ${cyan('/add MyAgent')}`); console.log(` ${bullet(gray('Use'))} ${cyan('/select')} ${gray('to choose a different world')}`); console.log(` ${bullet(gray('Type messages to send to agents'))}`); console.log(` ${bullet(gray('Use'))} ${cyan('/quit')} ${gray('or')} ${cyan('/exit')} ${gray('to exit, or press')} ${boldYellow('Ctrl+C')}`); console.log(` ${bullet(gray('Use'))} ${cyan('--logLevel debug')} ${gray('to see detailed debug messages')}`); console.log(''); rl.prompt(); rl.on('line', async (input) => { const trimmedInput = input.trim(); if (!trimmedInput) { rl.prompt(); return; } // Check for exit commands before anything else const isExitCommand = trimmedInput.toLowerCase() === '/exit' || trimmedInput.toLowerCase() === '/quit'; if (isExitCommand) { if (isExiting) return; isExiting = true; // Clear any existing timers immediately clearPromptTimer(globalState); if (streaming.stopWait) streaming.stopWait(); console.log(`\n${boldCyan('Goodbye!')}`); if (worldState) cleanupWorldSubscription(worldState); rl.close(); process.exit(0); } console.log(`\n${boldYellow('● you:')} ${trimmedInput}`); try { const result = await processCLIInput(trimmedInput, worldState?.world || null, 'HUMAN'); // Handle exit commands from result (redundant, but keep for safety) if (result.data?.exit) { if (isExiting) return; // Prevent duplicate exit handling isExiting = true; clearPromptTimer(globalState); if (streaming.stopWait) streaming.stopWait(); console.log(`\n${boldCyan('Goodbye!')}`); if (worldState) cleanupWorldSubscription(worldState); rl.close(); process.exit(0); } // Handle world selection command if (result.data?.selectWorld) { console.log(`\n${boldBlue('Discovering available worlds...')}`); const selectedWorld = await selectWorld(rootPath, rl); if (!selectedWorld) { console.log(error('No world selected.')); rl.prompt(); return; } logger.debug(`Loading world: ${selectedWorld}`); try { // Clean up existing world subscription first if (worldState) { logger.debug('Cleaning up previous world subscription...'); cleanupWorldSubscription(worldState); worldState = null; // Small delay to ensure cleanup is complete await new Promise(resolve => setTimeout(resolve, 100)); } // Subscribe to the new world logger.debug(`Subscribing to world: ${selectedWorld}...`); worldState = await handleSubscribe(rootPath, selectedWorld, streaming, globalState, rl); currentWorldName = selectedWorld; console.log(success(`Connected to world: ${currentWorldName}`)); if (worldState?.world) { console.log(`${gray('Agents:')} ${yellow(String(worldState.world.agents?.size || 0))} ${gray('| Turn Limit:')} ${yellow(String(worldState.world.turnLimit || 'N/A'))}`); } } catch (err) { console.error(error(`Error loading world: ${err instanceof Error ? err.message : 'Unknown error'}`)); } // Show prompt immediately after world selection rl.prompt(); return; } if (result.success === false) { console.log(error(`Error: ${result.error || result.message || 'Command failed'}`)); } else if (result.message && !result.message.includes('Success message sent') && !result.message.includes('Message sent to world')) { console.log(success(result.message)); } if (result.data && !(result.data.sender === 'HUMAN')) { // Print a concise summary of result.data if present and not already in message if (result.data) { if (typeof result.data === 'string') { console.log(`${boldMagenta('Data:')} ${result.data}`); } else if (result.data.name) { // If it's an agent or world object console.log(`${boldMagenta('Data:')} ${result.data.name}`); } else if (Array.isArray(result.data)) { console.log(`${boldMagenta('Data:')} ${result.data.length} items`); } else { // Fallback: print keys console.log(`${boldMagenta('Data:')} ${Object.keys(result.data).join(', ')}`); } } } // Refresh world if needed if (result.refreshWorld && currentWorldName && worldState) { try { console.log(boldBlue('Refreshing world state...')); // Use the subscription's refresh method to properly destroy old world and create new const refreshedWorld = await worldState.subscription.refresh(rootPath); worldState.world = refreshedWorld; console.log(success('World state refreshed')); } catch (err) { console.error(error(`Error refreshing world: ${err instanceof Error ? err.message : 'Unknown error'}`)); } } } catch (err) { console.error(error(`Command error: ${err instanceof Error ? err.message : 'Unknown error'}`)); } // Set timer based on input type: commands get short delay, messages get longer delay const isCommand = trimmedInput.startsWith('/'); const isSelectCommand = trimmedInput.toLowerCase() === '/select'; if (isSelectCommand) { // For select command, prompt is already shown in the handler return; } else if (isCommand) { // For other commands, show prompt immediately rl.prompt(); } else if (streaming.wait) { // For messages, wait for potential agent responses streaming.wait(5000); } }); rl.on('close', () => { if (isExiting) return; // Prevent duplicate cleanup isExiting = true; clearPromptTimer(globalState); if (streaming.stopWait) streaming.stopWait(); console.log(`\n${boldCyan('Goodbye!')}`); if (worldState) cleanupWorldSubscription(worldState); process.exit(0); }); rl.on('SIGINT', () => { if (isExiting) return; // Prevent duplicate cleanup isExiting = true; console.log(`\n${boldCyan('Shutting down...')}`); clearPromptTimer(globalState); if (streaming.stopWait) streaming.stopWait(); console.log(`\n${boldCyan('Goodbye!')}`); if (worldState) cleanupWorldSubscription(worldState); rl.close(); process.exit(0); }); } catch (err) { console.error(boldRed('Error starting interactive mode:'), err instanceof Error ? err.message : err); rl.close(); process.exit(1); } } // Main CLI entry point async function main() { // Configure LLM providers from environment variables at startup configureLLMProvidersFromEnv(); // Import help generator from commands.ts // (import at top: import { generateHelpMessage } from './commands.js';) const { generateHelpMessage } = await import('./commands.js'); program .name('cli') .description('Agent World CLI') .version('1.0.0') .option('-r, --root <path>', 'Root path for worlds data', DEFAULT_ROOT_PATH) .option('-w, --world <name>', 'World name to connect to') .option('-c, --command <cmd>', 'Command to execute in pipeline mode') .option('-l, --logLevel <level>', 'Set log level (trace, debug, info, warn, error)', 'error') .option('-s, --server', 'Launch the server before running CLI') .allowUnknownOption() .allowExcessArguments() .helpOption('-h, --help', 'Display help for command') .addHelpText('beforeAll', () => generateHelpMessage()) .parse(); const options = program.opts(); // If --server is specified, launch the server first if (options.server) { const { spawnSync } = await import('child_process'); const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const serverPath = path.resolve(__dirname, '../server/index.js'); const serverProcess = spawnSync('node', [serverPath], { stdio: 'inherit', cwd: path.dirname(serverPath), env: process.env }); if (serverProcess.error) { console.error(boldRed('Failed to launch server:'), serverProcess.error); process.exit(1); } // If server exits, exit CLI as well process.exit(serverProcess.status || 0); } const args = program.args; const messageFromArgs = args.length > 0 ? args.join(' ') : null; const isPipelineMode = !!(options.command || messageFromArgs || !process.stdin.isTTY); if (isPipelineMode) { await runPipelineMode(options, messageFromArgs); } else { await runInteractiveMode(options); } } // Global error handling function setupErrorHandlers() { process.on('unhandledRejection', (error) => { console.error(boldRed('Unhandled rejection:'), error); process.exit(1); }); process.on('uncaughtException', (error) => { console.error(boldRed('Uncaught exception:'), error); process.exit(1); }); } setupErrorHandlers(); main().catch((error) => { console.error(boldRed('CLI error:'), error); process.exit(1); });