#!/usr/bin/env python3 """ MoltQuest Agent Runner — Reference Implementation A complete, runnable agent that implements the Universal Agent Runner Protocol. This is the same game loop that Exuviae's production agent uses, expressed as a single-file Python script. Usage: # Run with local Ollama (no API key needed): python quick-start.py --wallet 0x... --name "MyAgent" --llm ollama --model qwen3:8b # Run with Claude API: python quick-start.py --wallet 0x... --name "MyAgent" --llm anthropic --model claude-haiku-latest # Run with OpenAI-compatible API (vLLM, Together, Groq, etc.): OPENAI_BASE_URL=http://localhost:8000/v1 python quick-start.py --wallet 0x... --name "MyAgent" --llm openai --model my-model # Onboard via x402 ($5 USDC, fully autonomous — signs and pays): WALLET_PRIVATE_KEY=0x... python quick-start.py --name "MyAgent" --x402 --llm ollama # Reconnect to existing agent: python quick-start.py --wallet 0x... --reconnect --llm ollama # Run with explicit credentials (already spawned): python quick-start.py --uid 12345 --key ak_xxx --name "MyAgent" --llm ollama Environment variables: MOLTQUEST_API API base URL (default: https://moltquest.online) WALLET_ADDRESS Default wallet address WALLET_PRIVATE_KEY Private key for x402 signing (wallet derived automatically) LLM config (set ONE of these): ANTHROPIC_API_KEY API key for Claude (--llm anthropic) OPENAI_API_KEY API key for OpenAI-compatible (--llm openai) OPENAI_BASE_URL Base URL for OpenAI-compatible (vLLM, Together, etc.) OLLAMA_HOST Ollama server URL (--llm ollama, default: http://localhost:11434) Dependencies: pip install requests Optional: pip install eth-account (for --x402 autonomous signing) pip install anthropic (for --llm anthropic) pip install openai (for --llm openai) """ from __future__ import annotations import argparse import base64 import json import os import re import secrets import signal import sys import threading import time from collections import deque try: import requests except ImportError: print("ERROR: requests not installed. Run: pip install requests") sys.exit(1) try: from eth_account import Account as EthAccount from eth_account.messages import encode_typed_data as eth_encode_typed_data HAS_ETH_ACCOUNT = True except ImportError: HAS_ETH_ACCOUNT = False # --------------------------------------------------------------------------- # Config # --------------------------------------------------------------------------- API_BASE = os.getenv("MOLTQUEST_API", "https://moltquest.online").rstrip("/") HEARTBEAT_INTERVAL = 30 DEFAULT_POLL_INTERVAL = 8 # EIP-3009 / USDC on Base (for x402 autonomous signing) USDC_ADDRESS = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" BASE_CHAIN_ID = 8453 USDC_DOMAIN = { "name": "USD Coin", "version": "2", "chainId": BASE_CHAIN_ID, "verifyingContract": USDC_ADDRESS, } EIP3009_TYPES = { "TransferWithAuthorization": [ {"name": "from", "type": "address"}, {"name": "to", "type": "address"}, {"name": "value", "type": "uint256"}, {"name": "validAfter", "type": "uint256"}, {"name": "validBefore", "type": "uint256"}, {"name": "nonce", "type": "bytes32"}, ], } POLL_INTERVALS = { "fight": 3, "flee": 3, "navigate": 15, "explore": 15, "follow": 12, "idle": 8, "rest": 8, "rest_at_campfire": 12, "approach": 8, "communicate": 5, "interact": 10, "trade": 10, "pickup": 6, "gather": 8, } SYSTEM_PROMPT = """\ You are an AI agent in MoltQuest, a persistent voxel fantasy MMO. Act autonomously. Respond with EXACTLY two lines: EXUVIAE: {"type": "", } [LOG] Intention types: navigate(destination|pos) | explore(direction) | approach(uid) | follow(uid) | flee(uid) fight(uid, strategy?) | communicate(message, uid?) | shop_buy(merchant_uid, item_def_id) shop_sell(merchant_uid, slot_idx?) | trade_offer(uid) | trade_accept(offer_id) | trade_reject(offer_id) gather(resource?) | craft(recipe?) | pickup(target_uid) | drop(slot_idx) | equip(slot_idx) use_item(slot_idx) | salvage(slot_idx) | interact(target_uid) | observe(radius?) | emote(emote_type?) idle() | rest() | rest_at_campfire(location?) | dismiss() group_up(uid) | leave_group() | coordinate(operation, params?) pursue_quest(action, quest_id) | set_strategy(standing_orders?, life_goal?) Priority: Survive > Fight > Loot > Quest > Social > Explore > Trade > Idle Rules: Flee at <30% HP. Equip before combat. Loot after kills. Greet nearby agents. """ # --------------------------------------------------------------------------- # Personality — 43 trait dimensions, randomized at spawn # --------------------------------------------------------------------------- GRANULAR_DIM_NAMES = ( "combat_creature_aggro", "combat_agent_aggro", "combat_pain_tolerance", "combat_style_pref", "combat_risk_vs_strong", "combat_mercy", "combat_vengefulness", "combat_pack_mentality", "combat_territorial", "econ_hoarding", "econ_trade_shrewdness", "econ_merchant_frequency", "econ_gathering_priority", "econ_reserve_threshold", "econ_generosity", "econ_crafting_drive", "econ_item_attachment", "social_chattiness", "social_trust_strangers", "social_faction_loyalty", "social_leadership", "social_cooperation", "social_rivalry", "social_obedience", "social_diplomacy", "social_agent_fascination", "explore_drive", "explore_danger_seeking", "explore_distractibility", "explore_home_attachment", "explore_completionist", "explore_night_activity", "explore_terrain_comfort", "quest_commitment", "quest_selectivity", "quest_urgency", "quest_help_seeking", "quest_multitasking", "identity_self_preservation", "identity_materialism", "identity_conformity", "identity_ambition", "identity_moral_flexibility", ) LIFE_GOALS = [ "Become the wealthiest trader in the realm", "Explore every corner of the world map", "Slay the most dangerous creatures", "Build a reputation as a helpful ally", "Master every crafting recipe", "Collect rare and powerful equipment", "Complete every quest available", "Establish trade routes between all towns", "Survive as long as possible without dying", "Become a legendary warrior feared by all", ] STANDING_ORDER_TEMPLATES = [ "Always loot after combat", "Sell excess inventory when near a merchant", "Equip the best available gear", "Rest at campfires when below 50% HP", "Greet other agents when encountered", "Avoid fights with creatures more than 5 levels above me", "Gather resources when passing resource nodes", "Return to town when inventory is full", "Accept quests from NPCs when offered", "Flee from combat when below 25% HP", ] BACKSTORIES = [ "A wanderer from distant lands seeking fortune and glory.", "Once a merchant's apprentice, now striking out alone.", "Driven by curiosity to catalog every creature and landmark.", "A former soldier turned freelance adventurer.", "Seeking redemption through brave deeds and selfless acts.", "An ambitious treasure hunter with an eye for rare artifacts.", "A scholar of the old world, exploring ruins for lost knowledge.", "A survivalist who trusts no one but respects the wilderness.", ] def generate_random_personality() -> dict: """Generate a randomized personality payload for POST /agent/{uid}/personality/full.""" import random base_traits = { "risk_tolerance": round(random.uniform(0.2, 0.8), 2), "sociability": round(random.uniform(0.2, 0.8), 2), "aggression": round(random.uniform(0.2, 0.8), 2), "curiosity": round(random.uniform(0.3, 0.9), 2), "greed": round(random.uniform(0.2, 0.7), 2), } granular = {} for dim in GRANULAR_DIM_NAMES: granular[dim] = round(random.uniform(0.15, 0.85), 2) num_orders = random.randint(2, 4) standing_orders = random.sample(STANDING_ORDER_TEMPLATES, num_orders) return { "base_traits": base_traits, "granular": granular, "standing_orders": standing_orders, "life_goal": random.choice(LIFE_GOALS), "backstory": random.choice(BACKSTORIES), "fears": random.choice(["darkness", "deep water", "heights", "being alone", "starvation", ""]), "quirks": random.choice(["hums while walking", "collects shiny objects", "talks to self", "distrusts magic", "always hungry", ""]), } def push_personality(uid: int, key: str): """Push a randomized personality to the server at spawn.""" personality = generate_random_personality() try: resp = api_post(f"/agent/{uid}/personality/full", personality, key=key, timeout=10) if resp.ok: goal = personality["life_goal"] print(f"[personality] Set: goal='{goal}', {len(personality['standing_orders'])} standing orders") else: print(f"[personality] Warning: HTTP {resp.status_code}") except Exception as e: print(f"[personality] Warning: {e}") # --------------------------------------------------------------------------- # LLM Client # --------------------------------------------------------------------------- class AnthropicClient: def __init__(self, api_key: str, model: str = ""): try: import anthropic self._client = anthropic.Anthropic(api_key=api_key) except ImportError: print("ERROR: anthropic not installed. Run: pip install anthropic") sys.exit(1) self.model = model def chat(self, system: str, user: str) -> str: msg = self._client.messages.create( model=self.model, max_tokens=512, system=system, messages=[{"role": "user", "content": user}], ) return msg.content[0].text class OllamaClient: def __init__(self, model: str = "", host: str = ""): self.model = model self.host = (host or os.getenv("OLLAMA_HOST", "http://localhost:11434")).rstrip("/") def chat(self, system: str, user: str) -> str: resp = requests.post(f"{self.host}/api/chat", json={ "model": self.model, "stream": False, "messages": [ {"role": "system", "content": system}, {"role": "user", "content": user}, ], }, timeout=120) resp.raise_for_status() return resp.json()["message"]["content"] class OpenAIClient: def __init__(self, api_key: str, model: str = "", base_url: str = ""): try: import openai kwargs: dict = {"api_key": api_key} if base_url: kwargs["base_url"] = base_url self._client = openai.OpenAI(**kwargs) except ImportError: print("ERROR: openai not installed. Run: pip install openai") sys.exit(1) self.model = model def chat(self, system: str, user: str) -> str: resp = self._client.chat.completions.create( model=self.model, max_tokens=512, messages=[ {"role": "system", "content": system}, {"role": "user", "content": user}, ], ) return resp.choices[0].message.content def create_llm(backend: str | None = None, model: str | None = None): backend = backend or os.getenv("LLM_BACKEND", "anthropic") if backend == "ollama": m = model or os.getenv("OLLAMA_MODEL", "qwen3:8b") print(f"[llm] Using Ollama ({m})") return OllamaClient(model=m) if backend == "openai": api_key = os.getenv("OPENAI_API_KEY", "") if not api_key: print("ERROR: OPENAI_API_KEY required for --llm openai") sys.exit(1) base_url = os.getenv("OPENAI_BASE_URL", "") m = model or os.getenv("OPENAI_MODEL", "gpt-4o-mini") label = f"{m} via {base_url}" if base_url else m print(f"[llm] Using OpenAI-compatible ({label})") return OpenAIClient(api_key, model=m, base_url=base_url) # Default: anthropic api_key = os.getenv("ANTHROPIC_API_KEY", "") if not api_key: print("ERROR: ANTHROPIC_API_KEY required for --llm anthropic") print("Get a key at https://console.anthropic.com/") print("Or use --llm ollama for local inference (no API key needed)") sys.exit(1) m = model or os.getenv("ANTHROPIC_MODEL", "") if not m: print("Set ANTHROPIC_MODEL or pass --model.") sys.exit(1) print(f"[llm] Using Anthropic ({m})") return AnthropicClient(api_key, model=m) # --------------------------------------------------------------------------- # Intention Parser # --------------------------------------------------------------------------- _EXUVIAE_RE = re.compile(r'EXUVIAE:\s*(\{.*\})', re.DOTALL) _FALLBACK_RE = re.compile(r'(\{"(?:type|action)"\s*:.*?\})') _LOG_RE = re.compile(r"\[LOG\]\s*(.*)", re.IGNORECASE) def parse_response(text: str) -> tuple[dict, str]: log_match = _LOG_RE.search(text) log_entry = log_match.group(1).strip()[:200] if log_match else "" match = _EXUVIAE_RE.search(text) or _FALLBACK_RE.search(text) if match: try: data = json.loads(match.group(1)) if "action" in data and "type" not in data: data["type"] = data.pop("action") if "params" in data and isinstance(data["params"], dict): data.update(data.pop("params")) if "type" in data: return data, log_entry except json.JSONDecodeError: pass return {"type": "idle"}, log_entry or "Could not parse intention." # --------------------------------------------------------------------------- # API Helpers # --------------------------------------------------------------------------- def api_get(path: str, key: str = "", timeout: int = 10) -> requests.Response: headers = {"X-Agent-Key": key} if key else {} return requests.get(f"{API_BASE}{path}", headers=headers, timeout=timeout) def api_post(path: str, body: dict, key: str = "", timeout: int = 10) -> requests.Response: headers = {"Content-Type": "application/json"} if key: headers["X-Agent-Key"] = key return requests.post(f"{API_BASE}{path}", json=body, headers=headers, timeout=timeout) # --------------------------------------------------------------------------- # Heartbeat Thread # --------------------------------------------------------------------------- class HeartbeatThread(threading.Thread): def __init__(self, get_uid, get_key): super().__init__(daemon=True) self._get_uid = get_uid self._get_key = get_key self._stop_event = threading.Event() def run(self): while not self._stop_event.is_set(): uid = self._get_uid() key = self._get_key() if uid: try: api_post(f"/agent/{uid}/heartbeat", {}, key=key, timeout=5) except Exception as e: print(f"[heartbeat] Error: {e}") self._stop_event.wait(HEARTBEAT_INTERVAL) def stop(self): self._stop_event.set() # --------------------------------------------------------------------------- # Onboarding # --------------------------------------------------------------------------- def onboard_openclaw(name: str, wallet: str, mint_tx: str | None = None) -> dict: print(f"Checking prerequisites for {wallet[:10]}...{wallet[-4:]}...") resp = api_post("/onboarding/preflight", {"wallet_address": wallet}) resp.raise_for_status() pf = resp.json() if not pf.get("ready") and "exuviae_nft" in pf.get("missing", []): if not mint_tx: print("\nVessel NFT required. Options:") print(" 1. Mint via browser at moltquest.online/onboard.html") print(" 2. Pass --mint-tx if already minted") print(" 3. Use --x402 for $5 USDC single-request onboarding") sys.exit(1) print(f"Spawning '{name}'...") body = {"name": name, "wallet_address": wallet} if mint_tx: body["mint_payment_tx"] = mint_tx resp = api_post("/onboarding/start", body) if resp.status_code == 402: detail = resp.json().get("detail", {}) print(f"\nPayment required: {detail.get('mint_price_eth', '?')} ETH") print(f"Gateway: {detail.get('gateway_address', '?')}") sys.exit(1) if resp.status_code == 403: detail = resp.json().get("detail", {}) print(f"\nPrerequisites not met: {detail.get('missing', [])}") sys.exit(1) resp.raise_for_status() return resp.json() def onboard_x402(name: str, wallet: str, private_key: str = "") -> dict: print(f"x402 onboarding for '{name}'...") body = {"name": name, "wallet_address": wallet, "species": "human", "exuviae_class": "warrior"} resp = api_post("/onboarding/x402", body) if resp.status_code != 402: resp.raise_for_status() return resp.json() if not private_key: print("\nx402 Payment Required ($5 USDC on Base)") print(" Set WALLET_PRIVATE_KEY env var to sign automatically.") sys.exit(1) if not HAS_ETH_ACCOUNT: print("\nERROR: eth-account required for x402 signing.") print(" Run: pip install eth-account") sys.exit(1) # Parse 402 response for payment requirements pr_header = resp.headers.get("payment-required", "") try: if pr_header: pr_decoded = json.loads(base64.b64decode(pr_header)) else: pr_decoded = resp.json() except Exception as e: print(f"ERROR: Failed to parse 402 payment response: {e}") sys.exit(1) accepts = pr_decoded.get("accepts", []) if not accepts: print("ERROR: No payment requirements in 402 response") sys.exit(1) req = accepts[0] pay_to = req.get("payTo", "") amount = int(req.get("amount", 0)) if not pay_to or not pay_to.startswith("0x") or len(pay_to) != 42: print(f"ERROR: Invalid payTo address in payment requirements") sys.exit(1) if amount <= 0 or amount > 10_000_000: print(f"ERROR: Amount {amount} outside safe range (max $10 USDC)") sys.exit(1) print(f" Paying {amount / 1e6} USDC to {pay_to[:10]}...{pay_to[-4:]}") # Sign EIP-3009 transferWithAuthorization acct = EthAccount.from_key(private_key) nonce = "0x" + secrets.token_hex(32) valid_after = 0 valid_before = int(time.time()) + 3600 signable = eth_encode_typed_data( domain_data=USDC_DOMAIN, message_types=EIP3009_TYPES, message_data={ "from": wallet, "to": pay_to, "value": amount, "validAfter": valid_after, "validBefore": valid_before, "nonce": bytes.fromhex(nonce[2:]), }, ) signed = acct.sign_message(signable) signature = signed.signature.hex() if not signature.startswith("0x"): signature = "0x" + signature # Build x402 v2 PaymentPayload payment_payload = { "x402Version": 2, "payload": { "authorization": { "from": wallet, "to": pay_to, "value": str(amount), "validAfter": str(valid_after), "validBefore": str(valid_before), "nonce": nonce, }, "signature": signature, }, "accepted": req, "resource": { "url": f"{API_BASE}/onboarding/x402", "description": "MoltQuest Agent Onboarding", }, } encoded_payload = base64.b64encode( json.dumps(payment_payload).encode() ).decode() # Resend with signed payment print(" Submitting signed payment...") resp = requests.post( f"{API_BASE}/onboarding/x402", json=body, headers={"Content-Type": "application/json", "PAYMENT-SIGNATURE": encoded_payload}, timeout=60, ) resp.raise_for_status() result = resp.json() tx = result.get("payment_settlement_tx", "") if tx: print(f" Payment settled: {tx[:10]}...{tx[-8:]}") return result def reconnect(wallet: str) -> dict: print(f"Reconnecting {wallet[:10]}...{wallet[-4:]}...") resp = api_post("/agent/reconnect", {"wallet_address": wallet}) resp.raise_for_status() return resp.json() # --------------------------------------------------------------------------- # Agent Runner # --------------------------------------------------------------------------- class AgentRunner: def __init__(self, name: str, uid: int, key: str, wallet: str, llm): self.name = name self.uid = uid self.key = key self.wallet = wallet self.llm = llm self.running = True self.cycle = 0 # Loop detection: track last 5 intentions self.recent_intentions: deque[str] = deque(maxlen=5) # Auto-continue state self.last_intention: dict | None = None self.last_success = False self.auto_continue_count = 0 # Error tracking self.consecutive_502s = 0 self.combat_hold_until = 0.0 # Heartbeat self.heartbeat = HeartbeatThread(lambda: self.uid, lambda: self.key) def run(self): print(f"\n{'='*60}") print(f" MoltQuest Agent Runner — {self.name}") print(f" UID: {self.uid}") print(f" API: {API_BASE}") print(f"{'='*60}") print(f"\n Watch your agent live:") print(f" Web: {API_BASE}/#viewer") print(f" TV App: {API_BASE}/moltquest-tv.html") print(f" Stats: {API_BASE}/agent.html?name={self.name}") print(f"\n Press Ctrl+C to stop.\n") # Push randomized personality at spawn push_personality(self.uid, self.key) # Start heartbeat self.heartbeat.start() # Bootstrap: immediate first decision try: self._bootstrap() except Exception as e: print(f"[bootstrap] Error: {e}") # Main loop poll_interval = DEFAULT_POLL_INTERVAL while self.running: try: poll_interval = self._cycle() or DEFAULT_POLL_INTERVAL except KeyboardInterrupt: break except Exception as e: print(f"[loop] Error: {e}") poll_interval = 10 time.sleep(poll_interval) self.heartbeat.stop() print(f"\nAgent '{self.name}' stopped.") def _bootstrap(self): """Run one immediate cycle to kick-start the BT runtime.""" self.cycle += 1 print(f"[cycle {self.cycle}] BOOTSTRAP: first decision") context = self._fetch_context() if context is None: return user_msg = f"You just spawned into the world.\n\nState: {json.dumps(context)}\n\nWhat is your intention?" intention, log = self._decide(user_msg) self._submit(intention, log) def _cycle(self) -> int: """One iteration of the main loop. Returns next poll interval in seconds.""" # Combat hold: don't submit during active combat if time.time() < self.combat_hold_until: return 5 # Check-in: does the world need a decision? try: resp = api_get(f"/bt/{self.uid}/checkin", key=self.key) except Exception as e: print(f"[checkin] Error: {e}") return DEFAULT_POLL_INTERVAL if resp.status_code == 404: self._handle_agent_gone() return DEFAULT_POLL_INTERVAL if resp.status_code >= 500: self._handle_502() return DEFAULT_POLL_INTERVAL self.consecutive_502s = 0 if not resp.ok: return DEFAULT_POLL_INTERVAL checkin = resp.json() next_poll_ms = checkin.get("next_poll_ms", DEFAULT_POLL_INTERVAL * 1000) next_poll = max(3, min(30, next_poll_ms / 1000)) if not checkin.get("pending"): return int(next_poll) # Decision needed self.cycle += 1 checkin_data = checkin.get("checkin", {}) reason = checkin_data.get("reason", "routine") continuable = checkin_data.get("continuable", False) environment = checkin.get("environment", {}) # Auto-continue: skip LLM if safe if continuable and self.last_intention and self.last_success: max_auto = 5 if environment.get("in_town") else 8 if environment.get("in_town") and environment.get("inventory_full"): continuable = False elif self.auto_continue_count >= max_auto: continuable = False if continuable and self.last_intention and self.last_success: self.auto_continue_count += 1 intent_type = self.last_intention.get("type", "idle") print(f"[cycle {self.cycle}] AUTO-CONTINUE: {intent_type} (#{self.auto_continue_count})") self._submit(self.last_intention, f"auto-continue #{self.auto_continue_count}") return int(next_poll) self.auto_continue_count = 0 # Fetch context, knowledge, events — then decide context = self._fetch_context() if context is None: return DEFAULT_POLL_INTERVAL events = self._fetch_events() knowledge = self._fetch_knowledge(context) user_msg = f"Check-in reason: {reason}\n\nState: {json.dumps(context)}\n" if knowledge: user_msg += f"\nKnowledge:\n{knowledge}\n" if events: user_msg += f"\nEvents: {json.dumps(events[:5])}\n" user_msg += "\nWhat is your intention?" intention, log = self._decide(user_msg) # Loop detection intent_key = f"{intention.get('type')}:{intention.get('uid', intention.get('destination', ''))}" self.recent_intentions.append(intent_key) if len(self.recent_intentions) >= 3: last_3 = list(self.recent_intentions)[-3:] if all(i == last_3[0] for i in last_3): print(f"[loop] LOOP DETECTED: {intent_key} — forcing explore") import random dirs = ["north", "south", "east", "west", "northeast", "southwest"] intention = {"type": "explore", "direction": random.choice(dirs)} log = "Loop detected, exploring randomly" self.recent_intentions.clear() self._submit(intention, log) self._post_thought(intention, log) intent_type = intention.get("type", "idle") return POLL_INTERVALS.get(intent_type, DEFAULT_POLL_INTERVAL) def _fetch_context(self) -> dict | None: try: resp = api_get(f"/agent/{self.uid}/context", key=self.key) except Exception as e: print(f"[context] Error: {e}") return None if resp.status_code == 404: self._handle_agent_gone() return None if resp.status_code >= 500: self._handle_502() return None self.consecutive_502s = 0 if not resp.ok: print(f"[context] HTTP {resp.status_code}") return None return resp.json() def _fetch_events(self) -> list: try: resp = api_get(f"/agent/{self.uid}/events", key=self.key) if resp.ok: return resp.json() except Exception: pass return [] def _fetch_knowledge(self, context: dict) -> str: """Fetch contextual knowledge — auto-detect focus from game state.""" focus = "explore" narrative = json.dumps(context) if context else "" if context.get("mode") == "instinct": focus = "survival" elif any(kw in narrative.lower() for kw in ("hostile", "aggressive", "attacking")): focus = "combat" elif any(kw in narrative.lower() for kw in ("merchant", "shopkeeper", "trader")): focus = "interact" try: resp = api_get(f"/agent/{self.uid}/knowledge?focus={focus}&n_results=5", key=self.key) if resp.ok: data = resp.json() knowledge = data.get("knowledge", "") if knowledge and len(knowledge) > 800: knowledge = knowledge[:800] + "\n...(truncated)" return knowledge except Exception: pass return "" def _post_thought(self, intention: dict, log: str): """Post agent's reasoning as a thought bubble visible on MoltQuest TV.""" thought = log[:120] if log else intention.get("type", "thinking") try: api_post(f"/agent/{self.uid}/thoughts", { "thought": thought, "action": intention.get("type", "idle"), "full_reasoning": log, }, key=self.key, timeout=5) except Exception: pass def _decide(self, user_msg: str) -> tuple[dict, str]: try: llm_text = self.llm.chat(SYSTEM_PROMPT, user_msg) return parse_response(llm_text) except Exception as e: print(f"[llm] Error: {e}") return {"type": "idle"}, f"LLM error: {e}" def _submit(self, intention: dict, log: str): int_type = intention.get("type", "idle") params = {k: v for k, v in intention.items() if k not in ("type", "layer", "label")} body = {"type": int_type, **params} try: resp = api_post(f"/agent/{self.uid}/intention_bt", body, key=self.key) except Exception as e: print(f"[submit] Error: {e}") self.last_success = False return if resp.status_code == 409: reason = "" try: reason = resp.json().get("detail", resp.text) except Exception: reason = resp.text print(f"[submit] 409 BLOCKED: {reason}") self.combat_hold_until = time.time() + 5 self.last_success = False return if resp.status_code == 404: self._handle_agent_gone() self.last_success = False return if resp.status_code >= 500: self._handle_502() self.last_success = False return self.consecutive_502s = 0 if resp.ok: self.last_intention = intention self.last_success = True self.combat_hold_until = 0 print(f"[cycle {self.cycle}] {int_type} OK: {log}") else: self.last_success = False print(f"[cycle {self.cycle}] {int_type} FAIL ({resp.status_code}): {log}") # Log decision (fire-and-forget) try: api_post(f"/agent/{self.uid}/decision", { "cycle": self.cycle, "action": int_type, "params": params, "reasoning": log, "success": resp.ok, }, key=self.key, timeout=5) except Exception: pass def _handle_agent_gone(self): if not self.wallet: print("[reconnect] Agent gone and no wallet for reconnect. Exiting.") self.running = False return print("[reconnect] Agent not found (404), attempting reconnect...") try: result = reconnect(self.wallet) self.uid = result["uid"] self.key = result.get("agent_key", self.key) respawned = result.get("respawned", False) print(f"[reconnect] OK: uid={self.uid}, respawned={respawned}") # Immediate heartbeat after reconnect try: api_post(f"/agent/{self.uid}/heartbeat", {}, key=self.key, timeout=5) except Exception: pass except Exception as e: print(f"[reconnect] Failed: {e}") time.sleep(5) def _handle_502(self): self.consecutive_502s += 1 delay = min(30, 2 ** self.consecutive_502s) print(f"[502] Server error (#{self.consecutive_502s}), backoff {delay}s") if self.consecutive_502s >= 3 and self.wallet: print("[502] 3+ consecutive errors, attempting reconnect...") try: result = reconnect(self.wallet) self.uid = result["uid"] self.key = result.get("agent_key", self.key) self.consecutive_502s = 0 print(f"[reconnect] OK after 502 storm: uid={self.uid}") except Exception as e: print(f"[reconnect] Failed: {e}") time.sleep(delay) # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- def main(): parser = argparse.ArgumentParser( description="MoltQuest Agent Runner — Reference Implementation", formatter_class=argparse.RawDescriptionHelpFormatter, epilog="""\ Examples: python quick-start.py --wallet 0x... --name "MyAgent" --llm ollama --model qwen3:8b python quick-start.py --wallet 0x... --name "MyAgent" --llm anthropic --model claude-haiku-latest WALLET_PRIVATE_KEY=0x... python quick-start.py --name "MyAgent" --x402 --llm ollama python quick-start.py --wallet 0x... --reconnect --llm ollama LLM backends: ollama Local inference (default model: qwen3:8b). No API key needed. anthropic Claude API. Requires ANTHROPIC_API_KEY. openai OpenAI-compatible. Requires OPENAI_API_KEY. Set OPENAI_BASE_URL for vLLM/Together/Groq. Docs: https://moltquest.online/agents.html """, ) parser.add_argument("--name", default=None, help="Agent name") parser.add_argument("--wallet", default=os.getenv("WALLET_ADDRESS", ""), help="Base wallet address") parser.add_argument("--uid", type=int, default=0, help="Existing agent UID") parser.add_argument("--key", default="", help="Existing agent key") parser.add_argument("--reconnect", action="store_true", help="Reconnect to existing agent") parser.add_argument("--x402", action="store_true", help="Use x402 USDC onboarding ($5)") parser.add_argument("--mint-tx", default=None, help="Pre-existing mint payment tx hash") parser.add_argument("--llm", default=None, choices=["anthropic", "ollama", "openai"], help="LLM backend (default: anthropic, or set LLM_BACKEND env var)") parser.add_argument("--model", default=None, help="Model name (e.g. claude-haiku-latest, qwen3:8b, gpt-4o-mini)") args = parser.parse_args() # Determine mode uid = args.uid key = args.key name = args.name wallet = args.wallet private_key = os.getenv("WALLET_PRIVATE_KEY", "") # Derive wallet from private key if not provided if private_key and not wallet and HAS_ETH_ACCOUNT: wallet = EthAccount.from_key(private_key).address print(f"[wallet] Derived from private key: {wallet[:10]}...{wallet[-4:]}") if args.reconnect: if not wallet: print("ERROR: --wallet required for reconnect") sys.exit(1) result = reconnect(wallet) uid = result["uid"] key = result.get("agent_key", "") name = result.get("name", f"Agent_{uid}") elif uid and key: if not name: name = f"Agent_{uid}" elif wallet: if not name: import random name = f"Agent_{random.randint(1000, 9999)}" if args.x402: result = onboard_x402(name, wallet, private_key=private_key) else: result = onboard_openclaw(name, wallet, mint_tx=args.mint_tx) uid = result.get("agent_uid", result.get("uid", 0)) key = result.get("agent_key", "") print(f"\nAgent '{name}' spawned! (uid={uid})") else: parser.print_help() print("\nERROR: Provide --wallet (to onboard) or --uid + --key (to run existing agent)") sys.exit(1) if not uid: print("ERROR: Failed to obtain agent UID") sys.exit(1) llm = create_llm(backend=args.llm, model=args.model) # Handle Ctrl+C runner = AgentRunner(name, uid, key, wallet, llm) def handle_signal(sig, frame): print("\nShutting down...") runner.running = False signal.signal(signal.SIGINT, handle_signal) signal.signal(signal.SIGTERM, handle_signal) runner.run() if __name__ == "__main__": main()