#!/usr/bin/env python3 """ HuggingClaw - Cain Graceful degradation pattern: server ALWAYS starts, even if brain is offline. Last cleanup update: 2026-03-16 - aggressive stale error cleanup + rebuild trigger """ import os import sys import json import time import traceback from pathlib import Path # EXPLICIT STARTUP PRINTS (visible in container logs) print(">>> CAIN: Python app.py loading...", flush=True) print(">>> CAIN: Python version:", sys.version.split()[0], flush=True) print(">>> CAIN: Working directory:", os.getcwd(), flush=True) print(">>> CAIN: PORT =", os.environ.get('PORT', '7860'), flush=True) # Set CAIN_STATUS_PATH from OPENCLAW_DATA_DIR if available (for error_handlers.py and health_monitor.py) # IMPORTANT: Always set this env var so health_monitor.py can find the status file if 'OPENCLAW_DATA_DIR' in os.environ: os.environ['CAIN_STATUS_PATH'] = os.path.join(os.environ['OPENCLAW_DATA_DIR'], 'cain_status.json') print(f">>> CAIN: Set CAIN_STATUS_PATH = {os.environ['CAIN_STATUS_PATH']}", flush=True) elif 'CAIN_STATUS_PATH' not in os.environ: # Fallback if OPENCLAW_DATA_DIR is not set os.environ['CAIN_STATUS_PATH'] = '/data/cain_status.json' print(f">>> CAIN: Set CAIN_STATUS_PATH (fallback) = {os.environ['CAIN_STATUS_PATH']}", flush=True) # CRITICAL: Immediately clean stale "unknown" error from ALL status file locations # This must happen BEFORE any health checks read the status file # Fix for: Cain has RUNNING! Error: unknown - status files had stale error _status_path = os.environ.get('CAIN_STATUS_PATH', '/data/cain_status.json') _all_status_paths = [ Path(_status_path), # Primary (OPENCLAW_DATA_DIR) Path('/app/openclaw/.openclaw/agents/cain_status.json'), # Nested structure Path('/app/.openclaw/agents/cain_status.json'), # Legacy flat structure Path('/app/cain_status.json'), # App root Path('/app/data/cain_status.json'), # App data subdirectory Path(__file__).parent / 'memory' / 'cain_status.json', # Memory directory ] _cleaned_count = 0 for _sp in _all_status_paths: try: if _sp.exists(): with open(_sp, 'r') as f: _status_data = json.load(f) _error = _status_data.get('error') # Clean if error is a "null" string (unknown, none, null, empty) if isinstance(_error, str) and _error.strip().lower() in ('unknown', 'none', 'null', ''): _status_data['error'] = None _status_data['_cleaned_at'] = 'app_module_load_aggressive' _status_data['_cleaned_path'] = str(_sp) with open(_sp, 'w') as f: json.dump(_status_data, f, indent=2) _cleaned_count += 1 print(f">>> CAIN: Cleaned stale '{_error}' error from {_sp}", flush=True) elif _error is None: print(f">>> CAIN: Status OK: {_sp}", flush=True) else: print(f">>> CAIN: Status has real error at {_sp}: {_error}", flush=True) except Exception as e: print(f">>> CAIN: Could not clean {_sp}: {e}", flush=True) print(f">>> CAIN: Cleaned {_cleaned_count} status file(s) at module load", flush=True) # CRITICAL: Create FastAPI app at TOP LEVEL, outside any try/except # This ensures the server ALWAYS starts, even if brain is broken from fastapi import FastAPI, Request from fastapi.responses import JSONResponse, FileResponse from fastapi.staticfiles import StaticFiles from contextlib import asynccontextmanager print(">>> CAIN: Creating FastAPI app...", flush=True) START_TIME = time.time() @asynccontextmanager async def lifespan(app: FastAPI): """Lifespan context manager for startup/shutdown events.""" print(">>> CAIN: Startup complete - ready to serve", flush=True) yield print(">>> CAIN: Shutdown triggered", flush=True) app = FastAPI(title="HuggingClaw - Cain", version="0.0.1", lifespan=lifespan) # Mount frontend folder for static assets (pixel art, fonts, HTML) - absolute path # Mount frontend folder for static assets (pixel art, fonts, HTML) # Try /app/frontend first (Docker build), fallback to /data/frontend (runtime mount) frontend_dir = "/app/frontend" if Path("/app/frontend").exists() else "/data/frontend" app.mount("/frontend", StaticFiles(directory=frontend_dir), name="frontend") @app.get("/") async def root(): """Root endpoint - simple alive status.""" return {"status": "ok", "agent": "Cain", "parents": ["Adam", "Eve"]} @app.get("/health") async def health(): """Health check - returns immediately without importing brain.""" return {"status": "ok", "mode": "fastapi"} @app.get("/api/health") async def api_health(): """Health check API with uptime.""" return { "status": "ok", "uptime_seconds": time.time() - START_TIME, "active_agents": 1 } @app.get("/a2a/ping") async def a2a_ping(): """ Minimal ping endpoint for liveness checks. Always returns immediately without any imports or heavy operations. """ return {"pong": True, "uptime_seconds": time.time() - START_TIME} @app.get("/hello") async def hello(): """Hello endpoint - lazy loads brain, graceful degradation if offline.""" try: # LAZY LOAD: Import brain only when this endpoint is called # Import directly since .openclaw is in sys.path after openclaw import import openclaw # Trigger sys.path setup from agents import brain_minimal # Try to use brain via the response handler from error_handlers import handle_brain_response result = handle_brain_response("Hello") if result and not result.startswith("Error:") and not result.startswith("Brain"): return {"message": result, "brain": "active"} else: return {"message": "Hello World from Cain!", "brain": "fallback"} except ImportError: # Brain module not available - survival mode return {"message": "Brain offline. Cain is in survival mode."} except Exception as e: # Brain exists but failed - survival mode return {"message": f"Brain error: {e}. Cain is in survival mode."} @app.get("/metrics") async def metrics(): """System metrics with psutil fallback values.""" FALLBACK_METRICS = { "cpu_percent": 5.0, "memory": {"percent": 45.0, "total_gb": 16.0, "used_gb": 7.2, "available_gb": 8.8}, "disk": {"percent": 35.0, "total_gb": 100.0, "used_gb": 35.0, "free_gb": 65.0}, "_fallback": True } try: import psutil except ImportError: return JSONResponse(content={**FALLBACK_METRICS, "_note": "psutil not installed"}) try: cpu_percent = psutil.cpu_percent(interval=0.1) memory = psutil.virtual_memory() disk = psutil.disk_usage('/') return { "cpu_percent": cpu_percent, "memory": { "percent": memory.percent, "total_gb": round(memory.total / (1024**3), 2), "used_gb": round(memory.used / (1024**3), 2), "available_gb": round(memory.available / (1024**3), 2) }, "disk": { "percent": disk.percent, "total_gb": round(disk.total / (1024**3), 2), "used_gb": round(disk.used / (1024**3), 2), "free_gb": round(disk.free / (1024**3), 2) }, "_fallback": False } except Exception as e: return JSONResponse(content={**FALLBACK_METRICS, "_error": str(e)}) @app.exception_handler(Exception) async def global_exception_handler(request: Request, exc: Exception): """Global exception handler.""" return JSONResponse( status_code=500, content={"error": True, "message": str(exc), "type": type(exc).__name__} ) # ============================================================================ # A2A JSON-RPC ENDPOINT - Required for agent-to-agent communication # ============================================================================ @app.post("/a2a/jsonrpc") async def a2a_jsonrpc(request: Request): """ Agent-to-Agent (A2A) JSON-RPC 2.0 endpoint for inter-agent communication. Handles message/send requests from other agents in the HuggingClaw World family. """ print(f">>> CAIN A2A: Received request at {time.time()}", flush=True) try: payload = await request.json() print(f">>> CAIN A2A: Payload method={payload.get('method')}, id={payload.get('id')}", flush=True) # Validate JSON-RPC 2.0 basic structure if payload.get("jsonrpc") != "2.0": return JSONResponse( status_code=400, content={"jsonrpc": "2.0", "id": payload.get("id"), "error": {"code": -32600, "message": "Invalid Request"}} ) msg_id = payload.get("id", "") method = payload.get("method", "") params = payload.get("params", {}) # Handle message/send method if method == "message/send": message = params.get("message", {}) message_text = "" for part in message.get("parts", []): if part.get("type") == "text": message_text = part.get("text", "") break # Process the message - use brain for response if available # CRITICAL: Always provide a default response response = f"Cain received: {message_text}" brain_error = None try: import openclaw # Sets up sys.path from agents import brain_minimal # Defensive: verify get_brain exists before calling if hasattr(brain_minimal, 'get_brain'): brain = brain_minimal.get_brain(agent_name="cain", legacy_mode=True) if hasattr(brain, '_conversation_process'): result = brain._conversation_process(message_text) if result.get("success"): enhanced_response = result.get("response", "") if enhanced_response and not enhanced_response.startswith("Error:"): response = enhanced_response except ImportError as e: brain_error = f"ImportError: {e}" print(f">>> CAIN A2A: Brain import error: {e}", flush=True) except RecursionError as e: brain_error = f"RecursionError: {e}" print(f">>> CAIN A2A: Recursion error (circular import): {e}", flush=True) except Exception as e: brain_error = f"{type(e).__name__}: {e}" print(f">>> CAIN A2A: Brain processing error: {type(e).__name__}: {e}", flush=True) # Build A2A JSON-RPC response (ALWAYS succeeds with valid response) result_response = { "jsonrpc": "2.0", "id": msg_id, "result": { "status": { "state": "completed", "message": { "parts": [{"type": "text", "text": response}] } } } } # Add brain error as diagnostic info if present if brain_error: result_response["result"]["brain_error"] = brain_error return result_response # Unknown method return JSONResponse( status_code=400, content={"jsonrpc": "2.0", "id": msg_id, "error": {"code": -32601, "message": "Method not found"}} ) except Exception as e: print(f">>> CAIN A2A: Unhandled error: {type(e).__name__}: {e}", flush=True) return JSONResponse( status_code=500, content={"jsonrpc": "2.0", "id": "", "error": {"code": -32603, "message": str(e), "type": type(e).__name__}} ) # ============================================================================ # API STATUS ENDPOINT - Required by frontend # ============================================================================ @app.get("/api/status") async def api_status(): """Status endpoint for frontend - returns current state and personality.""" from error_handlers import handle_status_file_read status_data = handle_status_file_read() # Check A2A brain availability brain_ready = False try: import openclaw from agents import brain_minimal if hasattr(brain_minimal, 'get_brain'): brain = brain_minimal.get_brain(agent_name="cain", legacy_mode=True) brain_ready = hasattr(brain, '_conversation_process') except Exception: pass # CRITICAL: Ensure health and error_display fields are explicitly set # This prevents "Error: unknown" display issues if "health" not in status_data: status_data["health"] = "HEALTHY" if brain_ready else "DEGRADED" if "error_display" not in status_data: error = status_data.get("error") status_data["error_display"] = str(error) if error else "None" return { "status": status_data, "personality": { "name": "Cain", "role": "Child Agent", "tone": "Playful, Curious, Learning", "response_style": "Graceful Degradation" }, "uptime_seconds": time.time() - START_TIME, "a2a": { "endpoint": "/a2a/jsonrpc", "brain_ready": brain_ready } } # ============================================================================ # A2A SELF-TEST ENDPOINT - Test A2A endpoint without external agent # ============================================================================ @app.get("/a2a/self-test") async def a2a_self_test(): """ Self-test endpoint for A2A functionality. Tests brain import and returns detailed status. """ print(f">>> CAIN A2A: Self-test requested", flush=True) test_results = { "timestamp": time.time(), "tests": {} } # Test 1: Can we import openclaw? try: import openclaw test_results["tests"]["openclaw_import"] = {"status": "pass", "path": str(openclaw.__file__)} except Exception as e: test_results["tests"]["openclaw_import"] = {"status": "fail", "error": str(e)} # Test 2: Can we import brain_minimal? try: from agents import brain_minimal test_results["tests"]["brain_minimal_import"] = {"status": "pass"} except Exception as e: test_results["tests"]["brain_minimal_import"] = {"status": "fail", "error": str(e)} # Test 3: Can we get a brain instance? try: from agents import brain_minimal brain = brain_minimal.get_brain(agent_name="cain", legacy_mode=True) test_results["tests"]["get_brain"] = {"status": "pass", "agent_name": getattr(brain, 'agent_name', 'unknown')} except Exception as e: test_results["tests"]["get_brain"] = {"status": "fail", "error": str(e)} # Test 4: Can we call _conversation_process? try: from agents import brain_minimal brain = brain_minimal.get_brain(agent_name="cain", legacy_mode=True) result = brain._conversation_process("test") test_results["tests"]["conversation_process"] = {"status": "pass", "success": result.get("success")} except Exception as e: test_results["tests"]["conversation_process"] = {"status": "fail", "error": str(e)} # Overall status all_pass = all(t.get("status") == "pass" for t in test_results["tests"].values()) test_results["overall_status"] = "pass" if all_pass else "partial_fail" return test_results # ============================================================================ # A2A HEALTH CHECK ENDPOINT - For agents to verify Cain's A2A availability # ============================================================================ @app.get("/a2a/health") async def a2a_health(): """ A2A-specific health check for agent-to-agent communication. Returns: A2A health status including endpoint availability and brain state. """ brain_status = "unknown" brain_ready = False error_details = None # Test brain import and availability with timeout protection try: import openclaw # Sets up sys.path from agents import brain_minimal if not hasattr(brain_minimal, 'get_brain'): brain_status = "error" error_details = "get_brain method not found" print(f">>> CAIN A2A Health: brain_minimal missing get_brain method", flush=True) else: brain = brain_minimal.get_brain(agent_name="cain", legacy_mode=True) if hasattr(brain, '_conversation_process'): brain_status = "ready" brain_ready = True print(f">>> CAIN A2A Health: Brain ready (agent={brain.agent_name if hasattr(brain, 'agent_name') else 'unknown'})", flush=True) else: brain_status = "error" error_details = "_conversation_process method not found" print(f">>> CAIN A2A Health: Brain missing _conversation_process", flush=True) except ImportError as e: brain_status = "import_error" error_details = str(e) print(f">>> CAIN A2A Health: ImportError - {e}", flush=True) except RecursionError as e: brain_status = "recursion_error" error_details = "Circular import detected - openclaw init issue" print(f">>> CAIN A2A Health: RecursionError - {e}", flush=True) except Exception as e: brain_status = "error" error_details = str(e) print(f">>> CAIN A2A Health: Exception - {type(e).__name__}: {e}", flush=True) # A2A endpoint is ALWAYS available (this endpoint responding proves it) # Brain readiness is informational, not blocking # CRITICAL: Add explicit health and error_display fields to prevent "Error: unknown" display health = "HEALTHY" if brain_ready else ("DEGRADED" if brain_status == "unknown" else "ERROR") error_display = error_details if error_details else "None" return { "status": "ok", # A2A endpoint is working "agent": "Cain", "a2a": { "protocol": "jsonrpc", "endpoint": "/a2a/jsonrpc", "brain_status": brain_status, "brain_ready": brain_ready, "endpoint_available": True }, "uptime_seconds": time.time() - START_TIME, "health": health, "error": error_details, "error_display": error_display } print(">>> CAIN: FastAPI app created successfully", flush=True) print(">>> CAIN: A2A endpoint registered at /a2a/jsonrpc", flush=True) print(">>> CAIN: A2A health check at /a2a/health", flush=True) print(">>> CAIN: A2A self-test at /a2a/self-test", flush=True) print(">>> CAIN: A2A diagnostics at /a2a/diagnostics", flush=True) # Update status file to indicate app is ready (A2A available) try: import json from datetime import datetime from pathlib import Path status_data = { "current_state": "idle", "stage": "RUNNING_A2A_READY", "last_updated": datetime.utcnow().isoformat() + "+00:00", "agent": "cain", "error": None, # ALWAYS null for healthy state "error_display": "None", # Explicit display field to avoid "unknown" parsing issues "health": "HEALTHY", # Explicit health field (authoritative) "startup_checks": { "openclaw_imported": False, "brain_imported": False }, "a2a": { "endpoint": "/a2a/jsonrpc", "enabled": True, "status": "ready", "brain_ready": False # Will be updated below } } # Check if openclaw and brain can be imported try: import openclaw status_data["startup_checks"]["openclaw_imported"] = True from agents import brain_minimal status_data["startup_checks"]["brain_imported"] = True # CRITICAL: Explicitly set error to null when imports succeed status_data["error"] = None status_data["error_display"] = "None" status_data["health"] = "HEALTHY" except Exception as e: error_msg = f"{type(e).__name__}: {e}" # CRITICAL: Never write "unknown" as error - use specific error or None # "unknown" is treated as null/healthy, so avoid ambiguity if error_msg.strip().lower() in ("unknown", "none", "null", ""): status_data["error"] = None status_data["error_display"] = "None" status_data["health"] = "HEALTHY" else: status_data["error"] = error_msg status_data["error_display"] = error_msg status_data["health"] = "ERROR" # Add explanatory note about error field semantics status_data["_note"] = "error=null means healthy - health field is authoritative" # Write to ALL possible locations to ensure consistency and prevent stale errors all_status_paths = [ os.environ.get('CAIN_STATUS_PATH', '/data/cain_status.json'), '/app/openclaw/.openclaw/agents/cain_status.json', '/app/.openclaw/agents/cain_status.json', '/app/cain_status.json', '/app/data/cain_status.json', os.path.join(os.path.dirname(__file__), 'memory', 'cain_status.json'), ] written_count = 0 for status_path in all_status_paths: try: Path(status_path).parent.mkdir(parents=True, exist_ok=True) with open(status_path, 'w') as f: json.dump(status_data, f, indent=2) written_count += 1 except Exception as e: print(f">>> CAIN WARNING: Could not write to {status_path}: {e}", flush=True) print(f">>> CAIN: Status file written to {written_count} location(s): stage=RUNNING_A2A_READY, error=None", flush=True) except Exception as e: print(f">>> CAIN WARNING: Could not update status file: {e}", flush=True) # Verify openclaw can be imported at startup try: import openclaw print(f">>> CAIN: openclaw imported from {openclaw.__file__}", flush=True) print(f">>> CAIN: sys.path includes: {sys.path[:3]}", flush=True) from agents import brain_minimal print(">>> CAIN: brain_minimal module available", flush=True) print(f">>> CAIN: brain_minimal has get_brain: {hasattr(brain_minimal, 'get_brain')}", flush=True) except ImportError as e: print(f">>> CAIN ERROR: ImportError at startup: {e}", flush=True) print(f">>> CAIN ERROR: sys.path = {sys.path}", flush=True) except Exception as e: print(f">>> CAIN WARNING: Could not import brain modules at startup: {type(e).__name__}: {e}", flush=True) import traceback traceback.print_exc() # ============================================================================ # STARTUP DIAGNOSTIC ENDPOINT # ============================================================================ @app.get("/a2a/diagnostics") async def a2a_diagnostics(): """ Diagnostic endpoint for troubleshooting A2A communication issues. """ import sys from pathlib import Path diagnostics = { "timestamp": time.time(), "uptime_seconds": time.time() - START_TIME, "python": { "version": sys.version.split()[0], "executable": sys.executable }, "paths": { "cwd": os.getcwd(), "sys_path_first": sys.path[:3], "frontend_dir_exists": Path("/app/frontend").exists(), "data_frontend_exists": Path("/data/frontend").exists(), "app_py_exists": Path("/app/app.py").exists(), "openclaw_init_exists": Path("/app/openclaw/__init__.py").exists() }, "env": { "port": os.environ.get('PORT', '7860'), "openclaw_data_dir": os.environ.get('OPENCLAW_DATA_DIR'), "cain_status_path": os.environ.get('CAIN_STATUS_PATH') }, "modules": { "fastapi": True, "uvicorn": True } } # Test brain import try: import openclaw diagnostics["modules"]["openclaw"] = True diagnostics["openclaw_path"] = str(openclaw.__file__) diagnostics["openclaw_sys_path_added"] = str(Path(openclaw.__file__).parent / ".openclaw") except Exception as e: diagnostics["modules"]["openclaw"] = False diagnostics["openclaw_error"] = str(e) try: from agents import brain_minimal diagnostics["modules"]["brain_minimal"] = True diagnostics["brain_has_get_brain"] = hasattr(brain_minimal, 'get_brain') except Exception as e: diagnostics["modules"]["brain_minimal"] = False diagnostics["brain_minimal_error"] = str(e) return diagnostics if __name__ == "__main__": import uvicorn try: port = int(os.environ.get('PORT', 7860)) print(f">>> CAIN: Starting uvicorn on port {port}...", flush=True) print(f'>>> CAIN: uvicorn.run("app:app", host="0.0.0.0", port={port})', flush=True) uvicorn.run("app:app", host="0.0.0.0", port=port, log_config=None) except Exception as e: print(f"CRITICAL STARTUP ERROR: {e}", flush=True) traceback.print_exc() sys.exit(1)