import { findConfigRoot } from '../../../lib'; import type { AgentCoreProjectSpec, ProtocolMode } from '../../../schema'; import { detectContainerRuntime } from '../../external-requirements'; import { DevLogger } from '../../logging/dev-logger'; import { type A2AAgentCard, ConnectionError, type DevConfig, DevServer, type LogLevel, type McpTool, ServerError, callMcpTool, createDevServer, fetchA2AAgentCard, findAvailablePort, getDevConfig, getEndpointUrl, invokeA2AStreaming, invokeAgentStreaming, listMcpTools, loadDevEnv, loadProjectConfig, waitForPort, } from '../../operations/dev'; import { formatMcpToolList } from '../../operations/dev/utils'; import { spawn } from 'child_process'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; type ServerStatus = 'starting' | 'running' | 'error' | 'stopped'; export interface LogEntry { level: 'info' | 'system' | 'warn' | 'error' | 'response'; message: string; } export interface ConversationMessage { role: 'user' | 'assistant'; content: string; isError?: boolean; isHint?: boolean; isExec?: boolean; } const MAX_LOG_ENTRIES = 50; export function useDevServer(options: { workingDir: string; port: number; agentName?: string; onReady?: () => void; headers?: Record; }) { const [logs, setLogs] = useState([]); const [status, setStatus] = useState('starting'); const [isStreaming, setIsStreaming] = useState(false); const [conversation, setConversation] = useState([]); const [streamingResponse, setStreamingResponse] = useState(null); const [project, setProject] = useState(null); const [configRoot, setConfigRoot] = useState(undefined); const [envVars, setEnvVars] = useState>({}); const [configLoaded, setConfigLoaded] = useState(false); const [hasUndeployedMemory, setHasUndeployedMemory] = useState(false); const [logFilePath, setLogFilePath] = useState(undefined); const [targetPort] = useState(options.port); const [actualPort, setActualPort] = useState(targetPort); const actualPortRef = useRef(targetPort); const [restartTrigger, setRestartTrigger] = useState(0); // MCP session state const mcpSessionIdRef = useRef(undefined); const [mcpTools, setMcpTools] = useState([]); // A2A state const [a2aAgentCard, setA2aAgentCard] = useState(null); const [a2aStatus, setA2aStatus] = useState(null); const serverRef = useRef(null); const loggerRef = useRef(null); const logsRef = useRef([]); const onReadyRef = useRef(options.onReady); useEffect(() => { onReadyRef.current = options.onReady; }, [options.onReady]); // Track instance ID to ignore callbacks from stale server instances const instanceIdRef = useRef(0); // Track if we're intentionally restarting to ignore exit callbacks const isRestartingRef = useRef(false); const addLog = (level: LogEntry['level'], message: string) => { setLogs(prev => { const next = [...prev.slice(-MAX_LOG_ENTRIES), { level, message }]; logsRef.current = next; return next; }); // Also log to file (DevLogger filters to only important logs) loggerRef.current?.log(level, message); }; // Load config and env vars on mount useEffect(() => { const load = async () => { const root = findConfigRoot(options.workingDir); setConfigRoot(root ?? undefined); const cfg = await loadProjectConfig(options.workingDir); setProject(cfg); // Load env vars from deployed state + agentcore/.env if (root) { const devEnv = await loadDevEnv(options.workingDir); setEnvVars(devEnv.envVars); // Show warning only when some configured memories aren't deployed yet const configuredMemories = cfg?.memories ?? []; if (configuredMemories.length > 0) { setHasUndeployedMemory(devEnv.deployedMemoryCount < configuredMemories.length); } } setConfigLoaded(true); }; void load(); }, [options.workingDir]); const config: DevConfig | null = useMemo(() => { if (!project || !options.agentName) { return null; } return getDevConfig(options.workingDir, project, configRoot, options.agentName); }, [options.workingDir, project, configRoot, options.agentName]); const protocol: ProtocolMode = config?.protocol ?? 'HTTP'; // Start server when config is loaded useEffect(() => { if (!configLoaded || !config) return; // Increment instance ID to track this server instance instanceIdRef.current += 1; const currentInstanceId = instanceIdRef.current; const startServer = async () => { // Initialize file logger for this dev session loggerRef.current = new DevLogger({ baseDir: options.workingDir, agentName: config.agentName, }); setLogFilePath(loggerRef.current.getRelativeLogPath()); // A2A servers always use port 9000, MCP servers use port 8000 (framework defaults, not configurable via env) const isA2A = config.protocol === 'A2A'; const isMcp = config.protocol === 'MCP'; const fixedPort = isA2A ? 9000 : isMcp ? 8000 : targetPort; // On restart, reuse the same port. On initial start, find an available port. // If restart times out waiting for port, fall back to finding a new one. const isRestart = restartTrigger > 0; let portFree = true; if (isRestart) { portFree = await waitForPort(actualPortRef.current); if (!portFree) { addLog('warn', `Port ${actualPortRef.current} not released, finding new port`); } } let port: number; if (isA2A || isMcp) { // A2A/MCP must use their fixed ports; check availability but don't auto-assign another const available = await findAvailablePort(fixedPort); if (available !== fixedPort) { addLog('error', `Port ${fixedPort} is in use. ${config.protocol} agents require port ${fixedPort}.`); setStatus('error'); return; } port = fixedPort; } else { port = isRestart && portFree ? actualPortRef.current : await findAvailablePort(fixedPort); if (!isRestart && port !== fixedPort) { addLog('warn', `Port ${fixedPort} in use, using ${port}`); } } actualPortRef.current = port; setActualPort(port); let serverReady = false; const callbacks = { onLog: (level: LogLevel, message: string) => { // Ignore callbacks from stale server instances if (instanceIdRef.current !== currentInstanceId) return; // Detect when server is actually ready (only once) if ( !serverReady && (message.includes('Application startup complete') || message.includes('Uvicorn running')) ) { serverReady = true; setStatus('running'); onReadyRef.current?.(); const endpointUrl = getEndpointUrl(port, config.protocol); addLog('system', `Server ready at ${endpointUrl}`); } else { addLog(level, message); } }, onExit: (code: number | null) => { // Ignore exit events from stale server instances if (instanceIdRef.current !== currentInstanceId) return; // Ignore exit events when intentionally restarting if (isRestartingRef.current) { isRestartingRef.current = false; return; } setStatus(code === 0 ? 'stopped' : 'error'); addLog( 'system', code !== 0 && code !== null ? `Server crashed (code ${code}) — check logs above for details` : `Server exited (code ${code})` ); }, }; const server = createDevServer(config, { port, envVars, callbacks }); serverRef.current = server; await server.start(); }; void startServer(); return () => { serverRef.current?.kill(); loggerRef.current?.finalize(); }; }, [ configLoaded, config, config?.agentName, config?.module, config?.directory, config?.isPython, options.workingDir, targetPort, restartTrigger, envVars, ]); // MCP: auto-list tools when server becomes ready const mcpToolsRef = useRef([]); // A2A: fetch agent card when server becomes ready const fetchAgentCard = useCallback(async () => { try { const card = await fetchA2AAgentCard(actualPortRef.current, loggerRef.current ?? undefined); setA2aAgentCard(card); } catch (err) { const errMsg = err instanceof Error ? err.message : String(err); addLog('warn', `Failed to fetch agent card: ${errMsg}`); } }, []); const fetchMcpTools = useCallback(async () => { try { const result = await listMcpTools(actualPortRef.current, loggerRef.current ?? undefined, options.headers); setMcpTools(result.tools); mcpToolsRef.current = result.tools; mcpSessionIdRef.current = result.sessionId; } catch (err) { const errMsg = err instanceof Error ? err.message : String(err); addLog('error', `Failed to list MCP tools: ${errMsg}`); setMcpTools([]); mcpToolsRef.current = []; } }, [options.headers]); const invoke = async (message: string) => { // MCP: parse tool calls from chat input if (protocol === 'MCP') { if (message.trim().toLowerCase() === 'list') { setConversation(prev => [...prev, { role: 'user', content: message }]); await fetchMcpTools(); // Use ref for fresh value after async fetch const tools = mcpToolsRef.current; setConversation(prev => [...prev, { role: 'assistant', content: formatMcpToolList(tools), isHint: true }]); return; } // Parse "tool_name {json_args}" or just "tool_name" const match = /^(\S+)\s*(.*)/.exec(message); if (!match) return; const toolName = match[1]!; const argsStr = match[2]?.trim() ?? ''; setConversation(prev => [...prev, { role: 'user', content: message }]); setIsStreaming(true); try { let args: Record = {}; if (argsStr) { args = JSON.parse(argsStr) as Record; } const result = await callMcpTool( actualPort, toolName, args, mcpSessionIdRef.current, loggerRef.current ?? undefined, options.headers ); setConversation(prev => [...prev, { role: 'assistant', content: `Result: ${result}` }]); loggerRef.current?.log('system', `MCP call: ${toolName}(${argsStr})`); loggerRef.current?.log('response', result); } catch (err) { const errMsg = err instanceof Error ? err.message : String(err); addLog('error', `MCP call failed: ${errMsg}`); setConversation(prev => [...prev, { role: 'assistant', content: errMsg, isError: true }]); } finally { setIsStreaming(false); } return; } // HTTP and A2A: chat-style invoke setConversation(prev => [...prev, { role: 'user', content: message }]); setStreamingResponse(null); setIsStreaming(true); let responseContent = ''; try { // Select streaming function based on protocol if (protocol === 'A2A') { setA2aStatus(null); } const streamFn = protocol === 'A2A' ? invokeA2AStreaming({ port: actualPort, message, logger: loggerRef.current ?? undefined, onStatus: setA2aStatus, headers: options.headers, }) : invokeAgentStreaming({ port: actualPort, message, logger: loggerRef.current ?? undefined, headers: options.headers, }); for await (const chunk of streamFn) { responseContent += chunk; setStreamingResponse(responseContent); } // Add assistant response to conversation setConversation(prev => [...prev, { role: 'assistant', content: responseContent }]); setStreamingResponse(null); // Log final response to file loggerRef.current?.log('system', `\u2192 ${message}`); loggerRef.current?.log('response', responseContent); } catch (err) { const rawMsg = err instanceof Error ? err.message : 'Unknown error'; let errorMsg: string; let showHint = false; if (err instanceof ServerError) { // HTTP error — use the response body directly (avoids stderr race condition) errorMsg = err.message || `Server error (${err.statusCode})`; showHint = true; } else if (err instanceof ConnectionError) { // Connection failed after retries — check stderr logs for crash context const recentErrors = logsRef.current .filter(l => l.level === 'error') .slice(-5) .map(l => l.message); errorMsg = recentErrors.length > 0 ? recentErrors.join('\n') : `Connection failed: ${rawMsg}`; showHint = recentErrors.length > 0; } else { errorMsg = `Failed: ${rawMsg}`; } addLog('error', `Failed: ${rawMsg}`); const messages: ConversationMessage[] = [{ role: 'assistant', content: errorMsg, isError: true }]; if (showHint) { messages.push({ role: 'assistant', content: 'See logs for full stack trace.', isHint: true }); } setConversation(prev => [...prev, ...messages]); setStreamingResponse(null); } finally { setIsStreaming(false); setA2aStatus(null); } }; const runSpawnCommand = async ( spawnBinary: string, spawnArgs: string[], spawnOpts: { cwd?: string; env?: NodeJS.ProcessEnv }, label: string, prefix: string, command: string, onStart?: () => void ) => { setConversation(prev => [...prev, { role: 'user', content: `${prefix} ${command}`, isExec: true }]); setStreamingResponse(null); setIsStreaming(true); onStart?.(); let output = ''; try { await new Promise((resolve, reject) => { const child = spawn(spawnBinary, spawnArgs, { stdio: 'pipe', ...spawnOpts }); child.stdout?.on('data', (data: Buffer) => { output += data.toString(); setStreamingResponse(output); }); child.stderr?.on('data', (data: Buffer) => { output += data.toString(); setStreamingResponse(output); }); child.on('error', reject); child.on('close', code => { if (code !== 0 && code !== null) { output += `\n[exit code: ${code}]`; setStreamingResponse(output); } resolve(); }); }); setConversation(prev => [...prev, { role: 'assistant', content: output || '(no output)', isExec: true }]); setStreamingResponse(null); loggerRef.current?.log('system', `${label}: ${command}`); loggerRef.current?.log('response', output); } catch (err) { const errMsg = err instanceof Error ? err.message : String(err); addLog('error', `${label} failed: ${errMsg}`); setConversation(prev => [...prev, { role: 'assistant', content: `Error: ${errMsg}`, isError: true }]); setStreamingResponse(null); } finally { setIsStreaming(false); } }; const execCommand = async (command: string, onStart?: () => void) => { await runSpawnCommand( 'bash', ['-c', command], { cwd: options.workingDir, env: { ...process.env, ...envVars } }, 'exec', '!', command, onStart ); }; const execInContainer = async (command: string, onStart?: () => void) => { const containerName = `agentcore-dev-${config?.agentName ?? ''}`.toLowerCase(); const detection = await detectContainerRuntime(); if (!detection.runtime) { addLog('error', 'No container runtime found (docker, podman, or finch required)'); setConversation(prev => [ ...prev, { role: 'assistant', content: 'Error: No container runtime found (docker, podman, or finch required)', isError: true, }, ]); return; } await runSpawnCommand( detection.runtime.binary, ['exec', containerName, 'bash', '-c', command], {}, 'container exec', '!!', command, onStart ); }; const clearLogs = () => { setLogs([]); logsRef.current = []; }; const restart = () => { addLog('system', 'Restarting server...'); isRestartingRef.current = true; serverRef.current?.kill(); setStatus('starting'); setRestartTrigger(t => t + 1); }; const stop = () => { serverRef.current?.kill(); loggerRef.current?.finalize(); setStatus('stopped'); }; const clearConversation = () => { setConversation([]); setStreamingResponse(null); }; const showMcpHint = () => { const tools = mcpToolsRef.current; if (tools.length > 0) { setConversation(prev => [...prev, { role: 'assistant', content: formatMcpToolList(tools), isHint: true }]); } }; return { logs, status, isStreaming, conversation, streamingResponse, config, configLoaded, actualPort, invoke, execCommand, execInContainer, isContainer: config?.buildType === 'Container', clearLogs, clearConversation, restart, stop, logFilePath, hasUndeployedMemory, hasVpc: project?.runtimes.find(a => a.name === config?.agentName)?.networkMode === 'VPC', protocol, mcpTools, fetchMcpTools, showMcpHint, a2aAgentCard, a2aStatus, fetchAgentCard, }; }