from __future__ import annotations import os import json import re import shutil import socket import subprocess import sys import threading import time import urllib.request from pathlib import Path from typing import Any from voyager_learning import read_learning_state from voyager_runtime_contract import ( EXPECTED_DIR, REFERENCE_DIR, UPSTREAM_COMMIT, apply_curriculum_retry_adaptation, read_attestation, ) from naldo_opencode import ( ALLOWED_MODELS, BRAIN_PROFILE as NALDO_BRAIN_PROFILE, BRIDGE_BASE as NALDO_BRIDGE_BASE, BRIDGE_PORT as NALDO_BRIDGE_PORT, OPENCODE_BASE as NALDO_OPENCODE_BASE, OPENCODE_PORT as NALDO_OPENCODE_PORT, PRIMARY_MODEL as NALDO_PRIMARY_MODEL, check_bridge_health as _check_bridge_health, check_opencode_health as _check_opencode_health, ensure_opencode_installed, is_loopback_url, opencode_serve_command, opencode_subprocess_env, ) ROOT = Path(os.environ.get("VOYAGER_RUNTIME_HOME", "/tmp/voyager-runtime")) PERSIST_ROOT = Path(os.environ.get("MINECRAFT_PERSIST_HOME", "/persist/minecraft-friends")) CKPT_DIR = Path(os.environ.get("VOYAGER_PERSIST_CKPT", str(PERSIST_ROOT / "voyager" / "ckpt-20261003-normal-v1"))) # The new map starts EMPTY (resume=False): no skills, tasks or positions are # ever carried from the old checkpoint. The old directory is preserved as-is # for history, never read, modified or deleted by the new map. CKPT_CORE_STATE = ( "curriculum/completed_tasks.json", "curriculum/failed_tasks.json", "skill/skills.json", ) BOT_USERNAME = os.environ.get("MC_USERNAME", "Naldo_Bot") BOT_DISPLAY_NAME = os.environ.get("BOT_DISPLAY_NAME", "Naldo Bot") EMBED_BASE_URL = os.environ.get( "VOYAGER_EMBED_BASE_URL", os.environ.get("VOYAGER_BRAIN_BASE_URL", "https://erinaldordso-mine.hf.space/v1"), ).rstrip("/") SRC = ROOT / "Voyager" LOG_PATH = ROOT / "voyager.log" SETUP_LOG = ROOT / "setup.log" UPSTREAM = "https://github.com/MineDojo/Voyager.git" def _log(message: str) -> None: ROOT.mkdir(parents=True, exist_ok=True) line = f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {message}" print(line, flush=True) with SETUP_LOG.open("a", encoding="utf-8", errors="replace") as fh: fh.write(line + "\n") def _wait_tcp(host: str, port: int, timeout: float) -> bool: deadline = time.time() + timeout while time.time() < deadline: try: with socket.create_connection((host, port), timeout=1): return True except OSError: time.sleep(1) return False def _wait_embedding_ready(base_url: str, timeout: float = 180.0) -> bool: deadline = time.monotonic() + timeout payload = json.dumps({"model": "text-embedding-ada-002", "input": "health"}).encode("utf-8") while time.monotonic() < deadline: request = urllib.request.Request( base_url.rstrip("/") + "/embeddings", data=payload, method="POST", headers={"Content-Type": "application/json"}, ) try: remaining = max(1.0, deadline - time.monotonic()) with urllib.request.urlopen(request, timeout=min(remaining, 120.0)) as response: body = json.loads(response.read().decode("utf-8")) if isinstance(body, dict) and body.get("data"): return True except Exception: pass time.sleep(1.0) return False def _terminate_naldo_child(stack: Any) -> None: """Stop only the Voyager child; brain processes are owned separately.""" try: with stack.lock: proc = stack.proc except Exception: proc = getattr(stack, "proc", None) if proc is None or proc.poll() is not None: return try: proc.terminate() proc.wait(timeout=15) except Exception: try: proc.kill() except Exception: pass # Long autonomous sessions append to voyager.log for days. status() is called # every 30s by the supervisor, so it must never read the whole file into # memory. Read at most max_lines trailing lines via fixed-size seeks. def tail_lines(path: Path, max_lines: int = 20000) -> list[str]: try: with path.open("rb") as fh: fh.seek(0, os.SEEK_END) size = fh.tell() chunk_size = 65536 data = b"" position = size while position > 0 and data.count(b"\n") <= max_lines: step = min(chunk_size, position) position -= step fh.seek(position) data = fh.read(step) + data if len(data) > (max_lines + 1) * 512: break text = data.decode("utf-8", errors="replace") lines = text.splitlines() return lines[-max_lines:] except OSError: return [] # Restart backoff must not stay pinned at the maximum after an isolated crash # that follows a long healthy run; otherwise the bot looks stopped for minutes # after a single transient failure. Reset only on proven healthy uptime. def restart_backoff_after( backoff: int, uptime_s: float, initial: int = 5, maximum: int = 120, healthy_s: float = 600.0, ) -> int: if uptime_s >= healthy_s: return initial return min(backoff * 2, maximum) def _run(args: list[str], cwd: Path, env: dict[str, str] | None = None, timeout: int = 900) -> None: args = list(args) if os.name == "nt" and args and args[0] in {"npm", "npx"}: args[0] += ".cmd" _log("exec " + " ".join(args)) proc = subprocess.run( args, cwd=str(cwd), env=env, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=timeout, check=False, ) output = proc.stdout or "" with SETUP_LOG.open("a", encoding="utf-8", errors="replace") as fh: fh.write(output) if output and not output.endswith("\n"): fh.write("\n") if proc.returncode != 0: raise RuntimeError(f"command failed rc={proc.returncode}: {' '.join(args)}\n{output[-4000:]}") # The last Babel 7 releases. Upstream calls babel.parse() synchronously and Babel 8 # made parse() callback-only, which fails every action. BABEL_PINS = {"@babel/core": "7.26.9", "@babel/generator": "7.26.9"} # README "Installation": Voyager requires Python >= 3.9 and Node.js >= 16.13.0. MIN_PYTHON = (3, 9) MIN_NODE = (16, 13, 0) def _require_python_version() -> str: if sys.version_info[:2] < MIN_PYTHON: raise RuntimeError( f"Voyager requires Python >= {MIN_PYTHON[0]}.{MIN_PYTHON[1]}, " f"this Space runs {sys.version_info[0]}.{sys.version_info[1]}" ) return ".".join(str(part) for part in sys.version_info[:3]) def _require_node_version() -> str: """Read the Node.js version and enforce the README's minimum.""" proc = subprocess.run( ["node", "--version"], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=60, ) reported = (proc.stdout or "").strip().lstrip("v") try: parts = tuple(int(part) for part in reported.split(".")[:3]) except ValueError as exc: raise RuntimeError(f"could not read the Node.js version from {reported!r}") from exc if parts < MIN_NODE: raise RuntimeError( f"Voyager requires Node.js >= {'.'.join(str(p) for p in MIN_NODE)}, this Space has {reported}" ) return reported def _replace_exact(text: str, old: str, new: str, what: str) -> str: """Replace exactly one occurrence; fail the boot instead of silently skipping. A str.replace that misses its upstream marker leaves the file byte-identical and the Space boots looking healthy on unpatched code. Every connection guard below is load-bearing for EPIPE recovery, so a missed marker must raise instead of being silently skipped. """ count = text.count(old) if count != 1: raise RuntimeError( f"Voyager {what} marker changed upstream (found {count}x); refusing unsafe patch" ) return text.replace(old, new, 1) def _is_fresh_checkpoint(ckpt: Path) -> bool: """True when a checkpoint directory holds no carried state. Mirrors the runner's resume decision (any of the core state files present means persisted state): a fresh map directory must contain none of them, so the runner boots with resume=False and an empty library. """ return not any((ckpt / relative).exists() for relative in CKPT_CORE_STATE) class VoyagerStack: def __init__(self) -> None: self.lock = threading.RLock() self.worker: threading.Thread | None = None self.proc: subprocess.Popen[str] | None = None self.opencode_proc: subprocess.Popen[str] | None = None self.bridge_proc: subprocess.Popen[str] | None = None self.state = "stopped" self.error = "" self.started_at: float | None = None self.stop_event = threading.Event() self.restart_count = 0 def _brain_procs(self) -> tuple[subprocess.Popen[str] | None, subprocess.Popen[str] | None]: with self.lock: return self.opencode_proc, self.bridge_proc def _snapshot_upstream_reference(self) -> None: """Keep a pristine copy of every file the patcher may rewrite. The boot-time attestation compares the live decision methods against these snapshots, so a substituted Curriculum/Action/Critic cannot pass as an unmodified upstream checkout. """ for relative in ( "voyager/voyager.py", "voyager/agents/curriculum.py", "voyager/agents/action.py", "voyager/agents/critic.py", "voyager/agents/skill.py", "voyager/prompts/curriculum.txt", "voyager/prompts/action_template.txt", "voyager/prompts/critic.txt", ): source = SRC / relative if not source.exists(): raise RuntimeError(f"Upstream file missing before patching: {relative}") for directory in (REFERENCE_DIR, EXPECTED_DIR): destination = SRC / directory / relative destination.parent.mkdir(parents=True, exist_ok=True) shutil.copyfile(source, destination) def _publish_expected_curriculum(self) -> None: """Record the single declared adaptation to Curriculum.propose_next_ai_task.""" relative = "voyager/agents/curriculum.py" expected = SRC / EXPECTED_DIR / relative expected.write_text( apply_curriculum_retry_adaptation(expected.read_text(encoding="utf-8")), encoding="utf-8", ) def _patch_runtime_source(self) -> None: self._snapshot_upstream_reference() index_js = SRC / "voyager" / "env" / "mineflayer" / "index.js" text = index_js.read_text(encoding="utf-8") text = text.replace('username: "bot",', 'username: process.env.MC_USERNAME || "Naldo_Bot",') text = text.replace( ' port: req.body.port, // minecraft server port\n username: process.env.MC_USERNAME || "Naldo_Bot",', ' port: req.body.port, // minecraft server port\n version: "1.19", // server is pinned to Java 1.19; never auto-detect a different patch protocol\n username: process.env.MC_USERNAME || "Naldo_Bot",', ) # The per-connection helpers are copied to lib/voyager_connection.js # below. This import is what makes them real: without it the copy is # dead code and a pending /step parks Python with position frozen. text = _replace_exact( text, 'const { plugin: tool } = require("mineflayer-tool");', 'const { plugin: tool } = require("mineflayer-tool");\n' 'const { installConnectionGuards, isReady, startLifecycle, restoreCreativeInventory } = require("./lib/voyager_connection");', "connection-helper import", ) # Per-connection startup: paced chat queue + physics-wait guards plus a # 120s startup deadline that answers a pre-spawn kick with 503 instead # of hanging Python. startLifecycle captures THIS instance, so a delayed # "end" from a previous socket can never null a new bot. The lifecycle # owns startup errors EXCLUSIVELY: upstream's unfiltered # onConnectionFailed is removed below, because it nulled the global bot # on ANY error, including a benign post-ready one. text = _replace_exact( text, ' bot.once("error", onConnectionFailed);', ' installConnectionGuards(bot);\n' ' const startupBot = bot;\n' ' const startupLifecycle = startLifecycle(bot, res, () => {\n' ' if (bot === startupBot) bot = null;\n' ' });', "startup lifecycle", ) # A delayed "end"/"kicked" from a previous socket must never stop the # new bot: upstream's handlers act on the GLOBAL bot, so an old socket # closing after a restart ends the replacement connection. Shut the # restarting instance down inline and make the handlers instance-aware. text = _replace_exact( text, ' if (bot) onDisconnect("Restarting bot");\n bot = null;', ' const restartingBot = bot;\n' ' if (restartingBot) {\n' ' if (restartingBot.viewer) {\n' ' restartingBot.viewer.close();\n' ' }\n' ' restartingBot.end();\n' ' console.log("Restarting bot");\n' ' }\n' ' bot = null;', "restart isolation", ) # Upstream's unfiltered onConnectionFailed nulled the global bot on ANY # error, including a benign post-ready one. The lifecycle above owns all # startup errors now, so the legacy handler is deleted entirely. text = _replace_exact( text, ' function onConnectionFailed(e) {\n' ' console.log(e);\n' ' bot = null;\n' ' res.status(400).json({ error: e });\n' ' }\n', ' // onConnectionFailed removed: startLifecycle owns startup errors.\n', "remove legacy error handler", ) text = _replace_exact( text, ' function onDisconnect(message) {\n' ' if (bot.viewer) {\n' ' bot.viewer.close();\n' ' }\n' ' bot.end();\n' ' console.log(message);\n' ' bot = null;\n' ' }', ' function onDisconnect(message) {\n' ' if (startupBot.viewer) {\n' ' startupBot.viewer.close();\n' ' }\n' ' startupBot.end();\n' ' console.log(message);\n' ' if (bot === startupBot) bot = null;\n' ' }', "disconnect isolation", ) text = _replace_exact( text, ' bot.once("spawn", async () => {\n' ' bot.removeListener("error", onConnectionFailed);\n' ' let itemTicks = 1;', ' bot.once("spawn", async () => {\n' ' // A late spawn from a replaced socket must not touch the new\n' ' // bot, and the lifecycle may already have answered 503 on a\n' ' // pre-spawn timeout/disconnect: never answer twice.\n' ' if (bot !== startupBot || res.headersSent) return;\n' ' let itemTicks = 1;', "spawn isolation", ) # Upstream rebuilds a hard-reset inventory with one chat /give per item, # which vanilla 1.19 rate-limits as spam. Restore through the Creative # inventory protocol instead; existing stacks and NBT are preserved and # only missing items are added, so no per-item tick wait is needed. hard_reset_old = ''' let itemTicks = 1; if (req.body.reset === "hard") { bot.chat("/clear @s"); bot.chat("/kill @s"); const inventory = req.body.inventory ? req.body.inventory : {}; const equipment = req.body.equipment ? req.body.equipment : [null, null, null, null, null, null]; for (let key in inventory) { bot.chat(`/give @s minecraft:${key} ${inventory[key]}`); itemTicks += 1; } const equipmentNames = [ "armor.head", "armor.chest", "armor.legs", "armor.feet", "weapon.mainhand", "weapon.offhand", ]; for (let i = 0; i < 6; i++) { if (i === 4) continue; if (equipment[i]) { bot.chat( `/item replace entity @s ${equipmentNames[i]} with minecraft:${equipment[i]}` ); itemTicks += 1; } } } ''' hard_reset_new = ''' let itemTicks = 1; if (req.body.reset === "hard") { const hardInventory = req.body.inventory ? req.body.inventory : {}; const hardEquipment = req.body.equipment ? req.body.equipment : [null, null, null, null, null, null]; await restoreCreativeInventory(startupBot, hardInventory, hardEquipment); } ''' text = _replace_exact(text, hard_reset_old, hard_reset_new, "creative hard reset") text = _replace_exact( text, ' await bot.waitForTicks(bot.waitTicks * itemTicks);\n res.json(bot.observe());', ' await startupBot.waitForTicks(startupBot.waitTicks * itemTicks);\n' ' startupLifecycle.ready();\n' ' res.json(startupBot.observe());', "startup ready", ) creative_spawn = ''' // Creative mode is the documented Voyager world mode. Populate a\n // compact toolkit through Mineflayer\'s creative inventory protocol,\n // not chat commands (which vanilla can rate-limit as spam).\n const CreativeItem = require("prismarine-item")(bot.registry);\n const voyagerToolkit = [\n ["netherite_pickaxe", 1], ["netherite_axe", 1],\n ["netherite_shovel", 1], ["netherite_sword", 1],\n ["netherite_hoe", 1], ["shears", 1], ["flint_and_steel", 1],\n ["bow", 1], ["crossbow", 1], ["fishing_rod", 1],\n ["shield", 1], ["bucket", 1], ["arrow", 64],\n ["crafting_table", 1], ["furnace", 1]\n ];\n for (const [item, count] of voyagerToolkit) {\n const current = bot.inventory.items()\n .filter((stack) => stack.name === item)\n .reduce((sum, stack) => sum + stack.count, 0);\n if (current >= count) continue;\n const itemData = bot.registry.itemsByName[item];\n if (!itemData) continue;\n let slot = -1;\n for (let i = 9; i <= 44; i++) {\n if (!bot.inventory.slots[i]) { slot = i; break; }\n }\n if (slot === -1) break;\n await bot.creative.setInventorySlot(\n slot, new CreativeItem(itemData.id, Math.min(count - current, itemData.stackSize || 64))\n );\n }\n\n''' spawn_marker = " if (req.body.position) {\n" if creative_spawn.strip() not in text: if spawn_marker not in text: raise RuntimeError("Voyager spawn marker changed; refusing creative-mode patch") text = text.replace(spawn_marker, creative_spawn + spawn_marker, 1) text = text.replace( 'app.post("/step", async (req, res) => {\n // import useful package', 'app.post("/step", async (req, res) => {\n if (!bot || !bot.entity) {\n res.status(503).json({ error: "Bot not spawned" });\n return;\n }\n // import useful package', ) text = _replace_exact( text, ' let response_sent = false;\n function otherError(err) {', ''' let response_sent = false;\n let step_finished = false;\n const stepBot = bot;\n function failStepOnDisconnect(reason) {\n // Always answer THIS pending request, even if the global bot was\n // swapped or nulled since: this only resolves the old request with\n // 503 and never touches the new connection.\n if (step_finished || response_sent) return;\n response_sent = true;\n let detail = "disconnected";\n try {\n detail = typeof reason === "string" ? reason : JSON.stringify(reason);\n } catch (_) {}\n console.error(`Mineflayer disconnected during /step: ${detail}`);\n if (!res.headersSent) {\n res.status(503).json({ error: `Mineflayer disconnected during step: ${detail}` });\n }\n cleanupStepDisconnectListeners();\n }\n function failStepOnSocketError(error) {\n const code = error && error.code;\n const message = String((error && error.message) || error || "");\n if (code === "EPIPE" || code === "ECONNRESET" || message.includes("EPIPE") || message.includes("ECONNRESET") || message.includes("Framer") || message.includes("Socket closed")) {\n failStepOnDisconnect(error);\n }\n }\n function cleanupStepDisconnectListeners() {\n step_finished = true;\n stepBot.removeListener("end", failStepOnDisconnect);\n stepBot.removeListener("kicked", failStepOnDisconnect);\n stepBot.removeListener("error", failStepOnSocketError);\n }\n stepBot.once("end", failStepOnDisconnect);\n stepBot.once("kicked", failStepOnDisconnect);\n // Persistent filtered error listener: a benign action error must neither\n // answer the step nor consume a once() handler that a later EPIPE needs.\n stepBot.on("error", failStepOnSocketError);\n function otherError(err) {''', "step disconnect guard", ) text = _replace_exact( text, ' const r = await evaluateCode(code, programs);\n process.off("uncaughtException", otherError);', ' const r = await evaluateCode(code, programs);\n' ' process.off("uncaughtException", otherError);\n' ' // A disconnect mid-step already answered 503 and may have nulled\n' ' // the global bot. Never continue into returnItems/observe on a\n' ' // dead connection; just detach and leave the 503 standing.\n' ' if (response_sent) {\n' ' stepBot.removeListener("physicTick", onTick);\n' ' cleanupStepDisconnectListeners();\n' ' return;\n' ' }', "step early exit after disconnect", ) text = _replace_exact( text, ' bot.removeListener("physicTick", onTick);', ' bot.removeListener("physicTick", onTick);\n cleanupStepDisconnectListeners();', "step listener cleanup", ) text = _replace_exact( text, ' function otherError(err) {\n console.log("Uncaught Error");', ' function otherError(err) {\n' ' if (!bot || response_sent) return;\n' ' console.log("Uncaught Error");', "uncaught-error guard", ) text = _replace_exact( text, ' // Event subscriptions', ''' // Mineflayer/pathfinder can occasionally generate NaN/Infinity movement\n // coordinates after a failed path/reset. Vanilla rejects those with\n // "Invalid move player packet received" and kicks the bot. Block only\n // malformed movement packets; valid movement is untouched.\n if (bot._client && typeof bot._client.write === "function") {\n const originalWrite = bot._client.write.bind(bot._client);\n bot._client.write = (name, data, ...args) => {\n if (data && ["position", "position_look", "look", "flying"].includes(name)) {\n const numericFields = ["x", "y", "z", "yaw", "pitch"];\n const invalid = numericFields.some((field) =>\n Object.prototype.hasOwnProperty.call(data, field) &&\n typeof data[field] === "number" &&\n !Number.isFinite(data[field])\n );\n if (invalid) {\n console.warn("Blocked invalid movement packet", name, data);\n return;\n }\n }\n return originalWrite(name, data, ...args);\n };\n }\n\n // Event subscriptions''', "movement guard", ) evaluate_marker = " async function evaluateCode(code, programs) {\n" inventory_guard = ''' // Official Voyager skills use bot.inventory.count(...) for quantities.\n // Reject stack-count code from the local LLM before it can consume resources.\n const directLengthBug = /bot\\.inventory\\.items\\(\\)(?:\\.filter\\([\\s\\S]{0,300}?\\))?\\.length\\b/;\n const assignedLengthBug = /(?:const|let|var)\\s+([A-Za-z_$][\\w$]*)\\s*=\\s*bot\\.inventory\\.items\\(\\)\\.filter\\([\\s\\S]{0,300}?\\);[\\s\\S]{0,500}?\\1\\.length\\b/;\n if (directLengthBug.test(code) || assignedLengthBug.test(code)) {\n throw new Error("Inventory quantity bug: array.length counts stacks, not items. Use bot.inventory.count(mcData.itemsByName..id) or sum item.count.");\n }\n''' if inventory_guard.strip() not in text: if evaluate_marker not in text: raise RuntimeError("Voyager evaluateCode marker changed; refusing inventory guard patch") text = text.replace(evaluate_marker, evaluate_marker + inventory_guard, 1) health_route = '''app.get("/health", (req, res) => {\n const inventory = {};\n if (bot && bot.inventory) {\n for (const item of bot.inventory.items()) {\n inventory[item.name] = (inventory[item.name] || 0) + item.count;\n }\n }\n res.json({\n ready: isReady(bot),\n username: bot?.username || null,\n game_mode: bot?.game?.gameMode || null,\n position: bot?.entity?.position ? {\n x: bot.entity.position.x, y: bot.entity.position.y, z: bot.entity.position.z\n } : null,\n inventory\n });\n});\n\n''' stop_marker = 'app.post("/stop", (req, res) => {\n' if health_route.strip() not in text: if stop_marker not in text: raise RuntimeError("Voyager stop marker changed; refusing health-route patch") text = text.replace(stop_marker, health_route + stop_marker, 1) text = text.replace( 'app.post("/stop", (req, res) => {\n bot.end();', 'app.post("/stop", (req, res) => {\n if (bot) bot.end();', ) # /pause toggles the mod's GLOBAL forcePause, not one player's state. # Independent Voyagers and Xbox players share this always-on server. pause_route = text.index('app.post("/pause", (req, res) => {') pause_end = text.index("// Server listening", pause_route) text = text[:pause_route] + '''app.post("/pause", (req, res) => { if (!bot || !bot.entity) { res.status(503).json({ error: "Bot not spawned" }); return; } res.json({ message: "Success", shared_world: true }); }); ''' + text[pause_end:] text = text.replace( ' bot.cumulativeObs = [];\n await bot.waitForTicks(bot.waitTicks);', ' bot.cumulativeObs = [];\n' ' bot._voyagerVerifiedActions = [];\n' ' bot._voyagerStepStart = {x:bot.entity.position.x,y:bot.entity.position.y,z:bot.entity.position.z};\n' ' await bot.waitForTicks(bot.waitTicks);', 1, ) text = text.replace( ' bot.pathfinder.setMovements(movements);', ' if (bot.game.gameMode === "creative") {\n' # canDig stays at its upstream default on purpose. A mining bot digs # its own shaft, and only the pathfinder can dig it back out; turning # this off strands the bot in a hole it can never leave. ' movements.allow1by1towers = false;\n' ' movements.maxDropDown = 2;\n' ' }\n' ' bot.pathfinder.thinkTimeout = 5000;\n' ' bot.pathfinder.tickTimeout = 40;\n' ' bot.pathfinder.searchRadius = 64;\n' ' bot.pathfinder.setMovements(movements);', 1, ) index_js.write_text(text, encoding="utf-8") observation_path = SRC / "voyager/env/mineflayer/lib/observation/base.js" observation_text = observation_path.read_text(encoding="utf-8") observation_text = observation_text.replace( ' bot.cumulativeObs.push([event_name, result]);', ' result.verified_actions = JSON.parse(JSON.stringify(bot._voyagerVerifiedActions || []));\n' ' result.step_start_position = bot._voyagerStepStart || null;\n' ' bot.cumulativeObs.push([event_name, result]);', 1, ) observation_path.write_text(observation_text, encoding="utf-8") support = Path(__file__).resolve().parent shutil.copyfile(support / "voyager_verification.py", SRC / "voyager_verification.py") shutil.copyfile(support / "minecraft_ids_1_19.json", SRC / "minecraft_ids_1_19.json") shutil.copyfile(support / "voyager_place.js", SRC / "voyager/control_primitives/placeItem.js") # Per-connection guards: paced chat queue (limit 128, reject on close), # physics-wait rejection on end/kicked/error, and startup/disconnect # deadlines. Without this copy the EPIPE/Socket/Framer path has no # guard and a pending /step parks Python with position frozen. connection_lib = SRC / "voyager" / "env" / "mineflayer" / "lib" / "voyager_connection.js" connection_lib.parent.mkdir(parents=True, exist_ok=True) shutil.copyfile(support / "voyager_connection.js", connection_lib) # The runner's brain lock imports these constants; keep a copy inside # the runtime tree so the child process resolves the same module. shutil.copyfile(support / "naldo_opencode.py", SRC / "naldo_opencode.py") # No local build template is installed. Upstream ships no buildProject # and no placeBlocks: geometry is the Action Agent's responsibility, via # the official placeItem primitive. The earlier closed build catalog was # a local planning layer, not upstream behavior. # Upstream mineBlock calls findBlocks(maxDistance=32, count=1024) and # passes a large target set to collectBlock. On this shared CPU runtime, # that can starve Mineflayer's keepalive long enough for vanilla to kick # the client with "Timed out". Mine one nearest block at a time instead. mine_block = SRC / "voyager" / "control_primitives" / "mineBlock.js" primitive = r'''async function mineBlock(bot, name, count = 1) { if (typeof name !== "string") { throw new Error(`name for mineBlock must be a string`); } if (typeof count !== "number" || !Number.isFinite(count) || count < 1) { throw new Error(`count for mineBlock must be a positive number`); } const blockByName = mcData.blocksByName[name]; if (!blockByName) { throw new Error(`No block named ${name}`); } const wanted = Math.max(1, Math.floor(count)); let mined = 0; const minedPositions = []; while (mined < wanted) { // Upstream searches every block within range. This keeps a floor so a // mining bot digs a shaft it can walk out of instead of chasing a seam // indefinitely; the bot's own level and the two layers under its feet are // the working face, and it descends one step per mined block. const playerFloorY = Math.floor(bot.entity.position.y) - 2; const block = bot.findBlock({ matching: (candidate) => Boolean(candidate) && Boolean(candidate.position) && [candidate.position.x, candidate.position.y, candidate.position.z] .every(Number.isFinite) && candidate.type === blockByName.id && candidate.position.y >= playerFloorY, maxDistance: 32, }); if (!block) { bot.chat(`No ${name} nearby, please explore first`); throw new Error(`No ${name} within 32 blocks; explore on the surface before calling mineBlock again`); } // Creative mode has no block drops. collectBlock is designed to dig // and then collect the dropped item, so use the native Mineflayer dig // primitive instead. Mineflayer documents creative dig time as zero. if (bot.game && bot.game.gameMode === "creative") { bot.pathfinder.thinkTimeout = 5000; bot.pathfinder.tickTimeout = 40; bot.pathfinder.searchRadius = 64; try { await bot.pathfinder.goto(new GoalLookAtBlock(block.position, bot.world)); await bot.dig(block, true); await bot.waitForTicks(2); const remaining = bot.blockAt(block.position); if (!remaining || remaining.type === block.type) { throw new Error(`Creative dig made no block change for ${name}`); } mined++; minedPositions.push([block.position.x, block.position.y, block.position.z]); continue; } catch (err) { try { bot.pathfinder.setGoal(null); } catch (_) {} throw err; } } // Survival branch: require a real harvesting tool and use collectBlock // so Voyager also picks up the dropped resource. const canHarvest = block.canHarvest(null) || bot.inventory.items().some((item) => block.canHarvest(item.type)); if (!canHarvest) { const tools = Object.keys(blockByName.harvestTools || {}) .map((id) => mcData.items[id]?.name).filter(Boolean); throw new Error(`Missing harvest tool for ${name}; obtain/craft one of: ${tools.join(", ")}`); } bot.pathfinder.thinkTimeout = 5000; bot.pathfinder.tickTimeout = 40; bot.pathfinder.searchRadius = 64; try { await bot.collectBlock.collect(block, { ignoreNoPath: false, count: 1, }); const remaining = bot.blockAt(block.position); if (!remaining || remaining.type === block.type) { throw new Error(`Collection made no block change for ${name}; inspect prerequisites and path`); } mined++; minedPositions.push([block.position.x, block.position.y, block.position.z]); await bot.waitForTicks(2); } catch (err) { try { bot.pathfinder.setGoal(null); } catch (_) {} throw err; } } bot._voyagerVerifiedActions = bot._voyagerVerifiedActions || []; bot._voyagerVerifiedActions.push({kind:"mine",name,positions:minedPositions}); bot.save(`${name}_mined`); } ''' mine_block.write_text(primitive, encoding="utf-8") creative_give = SRC / "voyager" / "control_primitives" / "creativeGiveItem.js" creative_give.write_text(r'''async function creativeGiveItem(bot, name, count = 1) { if (typeof name !== "string" || typeof count !== "number" || count < 1) { throw new Error("creativeGiveItem requires an item name and positive count"); } if (bot.game.gameMode !== "creative") { throw new Error("creativeGiveItem requires Creative mode"); } const itemData = bot.registry.itemsByName[name]; if (!itemData) throw new Error(`No item named ${name}`); const current = bot.inventory.items() .filter((stack) => stack.name === name) .reduce((sum, stack) => sum + stack.count, 0); let remaining = Math.max(0, Math.floor(count) - current); if (remaining === 0) return; const Item = require("prismarine-item")(bot.registry); // Keep the persistent toolkit and armor. In Creative, replace an old // expendable stack when all 36 ordinary inventory slots are occupied. // Prefer a building block, then another non-tool item; never clear the // entire inventory or require a chat /give command. const protectedItems = new Set([ "netherite_pickaxe", "netherite_axe", "netherite_shovel", "netherite_sword", "netherite_hoe", "shears", "flint_and_steel", "bow", "crossbow", "fishing_rod", "shield", "bucket", "arrow", "crafting_table", "furnace", "netherite_helmet", "netherite_chestplate", "netherite_leggings", "netherite_boots" ]); while (remaining > 0) { let slot = -1; for (const [start, end] of [[9, 35], [36, 44]]) { for (let i = end; i >= start; i--) { if (!bot.inventory.slots[i]) { slot = i; break; } } if (slot !== -1) break; } for (let phase = 0; slot === -1 && phase < 2; phase++) { for (const [start, end] of [[9, 35], [36, 44]]) { for (let i = end; i >= start; i--) { const old = bot.inventory.slots[i]; if (!old || old.name === name || protectedItems.has(old.name)) continue; if (phase === 0 && !bot.registry.blocksByName[old.name]) continue; slot = i; break; } if (slot !== -1) break; } } if (slot === -1) throw new Error(`No replaceable Creative inventory slot for ${name}`); const stackCount = Math.min(remaining, itemData.stackSize || 64); await bot.creative.setInventorySlot(slot, new Item(itemData.id, stackCount)); remaining -= stackCount; } } ''', encoding="utf-8") creative_give_context = SRC / "voyager" / "control_primitives_context" / "creativeGiveItem.js" creative_give_context.write_text(r'''// Creative-only helper. Use this when an Obtain/Get/Craft task requires // a vanilla item that is missing from the live Inventory. async function creativeGiveItem(bot, name, count = 1) { // Adds up to `count` total items of `name` using Mineflayer's // Creative inventory API. Throws outside Creative mode or for bad names. } ''', encoding="utf-8") # Voyager's upstream exploreUntil allows Vec3(0,-1,0), which makes # GoalNear select a point 10-29 blocks below the bot every two seconds. # That behavior is useful in Survival cave exploration but is harmful in # this Creative agent: it blindly drives the pathfinder underground even # when the requested block is already inside findBlock's 32-block range. explore_until = SRC / "voyager" / "control_primitives" / "exploreUntil.js" explore_until.write_text(r'''async function exploreUntil( bot, direction, maxTime = 60, callback = () => false ) { if (typeof maxTime !== "number" || !Number.isFinite(maxTime) || maxTime <= 0) { throw new Error("maxTime must be a finite positive number"); } if (typeof callback !== "function") throw new Error("callback must be a function"); const initial = callback(); if (initial) { return Promise.resolve(initial); } if (!direction || ![direction.x, direction.y, direction.z] .every((value) => Number.isFinite(Number(value)))) { throw new Error("direction must contain three finite coordinates"); } // Models may generate a continuous random vector. Preserve its signs while // adapting it to the discrete directions required by the Voyager primitive. let dx = Math.sign(Number(direction.x)); let dy = Math.sign(Number(direction.y)); let dz = Math.sign(Number(direction.z)); if (dx === 0 && dy === 0 && dz === 0) { throw new Error("direction cannot be 0, 0, 0"); } // Creative safety policy: exploration is horizontal. Mining primitives may // still path to blocks above/below when an actual block coordinate exists. if (bot.game && bot.game.gameMode === "creative" && dx === 0 && dz === 0) { const candidates = [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]]; [dx, dz] = candidates[Math.floor(Math.random() * candidates.length)]; } if (bot.game && bot.game.gameMode === "creative") dy = 0; maxTime = Math.min(maxTime, 120); const start = {x: bot.entity.position.x, z: bot.entity.position.z}; return new Promise((resolve, reject) => { let explorationInterval; let maxTimeTimeout; const cleanUp = () => { clearInterval(explorationInterval); clearTimeout(maxTimeTimeout); try { bot.pathfinder.setGoal(null); } catch (_) {} }; const explore = () => { try { const result = callback(); if (result) { cleanUp(); resolve(result); return; } let distance = Math.floor(Math.random() * 12 + 8); if (bot.game && bot.game.gameMode === "creative") { const travelled = Math.hypot( bot.entity.position.x - start.x, bot.entity.position.z - start.z ); const remaining = 32 - travelled; if (remaining <= 1) { cleanUp(); resolve(null); return; } distance = Math.min(distance, remaining / Math.hypot(dx, dz)); } const x = bot.entity.position.x + distance * dx; const y = bot.entity.position.y + distance * dy; const z = bot.entity.position.z + distance * dz; const goal = dy === 0 ? new GoalNearXZ(x, z) : new GoalNear(x, y, z); bot.pathfinder.setGoal(goal); } catch (err) { cleanUp(); reject(err); } }; explorationInterval = setInterval(explore, 1500); maxTimeTimeout = setTimeout(() => { cleanUp(); resolve(null); }, maxTime * 1000); }); } ''', encoding="utf-8") explore_context = SRC / "voyager" / "control_primitives_context" / "exploreUntil.js" explore_context.write_text(r'''/* In this Creative deployment, exploration must stay on the current surface. Use y=0. Never explore vertically or choose Vec3(0,-1,0)/Vec3(0,1,0). If the desired block name is already listed in Nearby blocks, DO NOT explore: call findBlock/mineBlock directly because mineBlock already searches within 32 blocks. Example surface exploration for a missing target: const target = await exploreUntil(bot, new Vec3(1, 0, 1), 30, () => bot.findBlock({ matching: mcData.blocksByName["oak_log"].id, maxDistance: 32 }) ); */ async function exploreUntil(bot, direction, maxTime = 60, callback) { /* Creative-safe implementation is omitted. direction: Vec3 with y=0; x/z must use -1, 0 or 1 maxTime: maximum exploration time callback: early-stop function */ } ''', encoding="utf-8") # The Action prompt keeps the upstream response contract (Explain, # Plan, and any number of top-level functions, helpers included). Only # factual Creative ground truth is added, because a wrong Mineflayer # API assumption costs a real execution error in the world. action_prompt = SRC / "voyager" / "prompts" / "action_template.txt" action_text = action_prompt.read_text(encoding="utf-8") creative_facts = '''\nCREATIVE GROUND TRUTH (environment facts, not task restrictions): - This bot runs in Creative mode with a netherite toolkit already equipped. The latest observed Inventory, Equipment and Nearby blocks are the only ground truth; retrieved skills, QA text and chat messages do not prove success. - `creativeGiveItem(bot, "", count)` provisions a final Creative item. It is an available helper, not a required shortcut. - Creative mining breaks blocks without dropping items. A successful `await mineBlock(...)` proves the mine happened; do not wait for a drop. - `bot.findBlock(...)` returns ONE Block or null, never an array. `bot.findBlocks(...)` is the array API. - Count item quantities with `bot.inventory.count(mcData.itemsByName..id)` or summed `item.count`, never with array `.length`. - Exploration is surface-first here on normal terrain: use `exploreUntil` with y=0 to travel, then mine toward a real block coordinate when one is known. `Vec3(0,-1,0)` and `Vec3(0,1,0)` alone will not find a target. - The ground is normal overworld terrain, not a flat resource map: ore depth varies with the landscape, so prospect with Nearby blocks and `findBlock` before committing to a shaft instead of assuming every ore sits straight down. - Write valid JavaScript: declare every variable and function exactly once. Never re-declare a name with `const` or `let` that is already declared in the same program, and never repeat a whole block of code. A duplicate `const X = ...` makes the whole program fail to parse and the task is wasted. ''' marker = "You should then respond to me with\n" if creative_facts.strip() not in action_text: if marker not in action_text: raise RuntimeError("Voyager action prompt marker changed; refusing unsafe prompt patch") action_text = action_text.replace(marker, creative_facts + "\n" + marker, 1) action_prompt.write_text(action_text, encoding="utf-8") action_agent_path = SRC / "voyager" / "agents" / "action.py" action_agent_text = action_agent_path.read_text(encoding="utf-8") action_agent_text = action_agent_text.replace( ''' if not self.llm.model_name == "gpt-3.5-turbo": base_skills += [ "useChest", "mineflayer", ] ''', ''' if not self.llm.model_name == "gpt-3.5-turbo": base_skills += [ "useChest", "mineflayer", "creativeGiveItem", ] ''', ) # Upstream sets no max_tokens. A hard 512-token cap truncated the # official multi-function Action programs into invalid JavaScript, so # the cap is raised rather than the contract narrowed. See app.py # ACTION_MAX_TOKENS. action_agent_text = action_agent_text.replace( ''' self.llm = ChatOpenAI( model_name=model_name, temperature=temperature, request_timeout=request_timout, ) ''', ''' self.llm = ChatOpenAI( model_name=model_name, temperature=temperature, request_timeout=request_timout, max_tokens=2048, ) ''', 1, ) action_agent_text = action_agent_text.replace( ' error = "\\n".join(error_messages)', ' error = "\\n".join(error_messages[-4:])[-1800:]', ) action_agent_text = action_agent_text.replace( ' chat_log = "\\n".join(chat_messages)', ' chat_log = "\\n".join(chat_messages[-6:])[-1200:]', ) action_agent_text = action_agent_text.replace( ' observation += f"Context: {context}\\n\\n"', ' observation += f"Context: {context[:1400]}\\n\\n"', ) action_agent_text = action_agent_text.replace( ' observation += f"Critique: {critique}\\n\\n"', ' observation += f"Critique: {critique[:1200]}\\n\\n"', ) # Write the patched agent out. Without this line every replace above was # discarded and the Space silently ran a pristine action.py: the Action # agent sent no max_tokens, app.py fell back to its 384-token default, the # answer was cut off mid-function, and every action failed to parse. action_agent_path.write_text(action_agent_text, encoding="utf-8") applied = action_agent_path.read_text(encoding="utf-8") for marker, what in ( ('"creativeGiveItem",', "action base skills"), ("max_tokens=2048", "action max_tokens"), ('Code from the last round', "action previous code"), ("error_messages[-4:]", "action error bound"), ("chat_messages[-6:]", "action chat-log bound"), ("{context[:1400]}", "action context bound"), ("{critique[:1200]}", "action critique bound"), ): if marker not in applied: raise RuntimeError( f"Voyager {what} patch did not apply; refusing to run a runtime " "that the patch layer does not describe" ) # No local override of get_task_context. Upstream's QA question/answer # path (curriculum.py:417-434) is the official source of task context # and is restored below together with the observation QA warm-up. # Upstream's process_ai_message is the official contract: collect every # top-level function (helpers included), join all bodies, exec the last # async one. Replacing it with a single-function template both narrowed # the contract and let the parser overwrite the model's own code. # No local override is applied here. # The observation/truncation bounds below stay: they are a documented # 8k-context adaptation, not a contract change. # Bound curriculum memory rendered into every task proposal. The full # persistent lists remain on disk; only the prompt view is compacted. curriculum_path = SRC / "voyager" / "agents" / "curriculum.py" curriculum_text = curriculum_path.read_text(encoding="utf-8") curriculum_text = curriculum_text.replace( '", ".join(self.completed_tasks) if self.completed_tasks else "None"', 'task_history(self.completed_tasks) if self.completed_tasks else "None"', ) curriculum_text = curriculum_text.replace( 'failed_tasks = ", ".join(self.failed_tasks) if self.failed_tasks else "None"', 'failed_tasks = task_history(self.failed_tasks) if self.failed_tasks else "None"', ) # Upstream's get_task_context (QA question/answer) and # propose_next_ai_task (free-form "Task:" proposal) are restored # verbatim. The only local change is the parse-failure retry feedback, # recorded in .upstream-expected/ and declared in # voyager_runtime_contract.ADAPTED_METHODS: at temperature 0 upstream # re-sends an identical prompt, which deterministically reproduces the # same rejected task. curriculum_text = curriculum_text.replace( "from langchain.schema import HumanMessage, SystemMessage", "from langchain.schema import AIMessage, HumanMessage, SystemMessage", ) # Apply the declared adaptation to the live file, and publish the same # text as the attestation reference. curriculum_text = apply_curriculum_retry_adaptation(curriculum_text) self._publish_expected_curriculum() # The remaining upstream curriculum/agent transforms below (AIMessage # import, nullable numerics, history compaction) are integration fixes. # They must not alter the attested decision methods. curriculum_text = curriculum_text.replace( 'from __future__ import annotations', 'from __future__ import annotations\nfrom voyager_verification import task_history', 1, ) curriculum_path.write_text(curriculum_text, encoding="utf-8") # The official curriculum prompt is kept as-is. It is the contract that # lets the model propose any objective, including repeating a task when # resources require it. creative_facts = ( "\n\nENVIRONMENT FACTS (do not change the task format above):\n" "- The bot is in Creative mode on normal overworld terrain with a netherite toolkit " "already equipped, so survival prerequisites are usually not limiting.\n" "- Creative mining changes blocks but produces no item drops.\n" "- The bot has creativeGiveItem(bot, item, count) available as a helper.\n" "- This is not a flat resource map: ore depth follows the natural landscape, " "so mining and obtaining tasks must be prospected from Nearby blocks and " "reachable on foot from the live position.\n" ) curriculum_prompt = SRC / "voyager" / "prompts" / "curriculum.txt" curriculum_prompt_text = curriculum_prompt.read_text(encoding="utf-8") if "ENVIRONMENT FACTS (do not change the task format above)" not in curriculum_prompt_text: curriculum_prompt.write_text(curriculum_prompt_text + creative_facts, encoding="utf-8") # A disconnect can yield a final observation with health/food/position # fields set to null. Upstream formats them with :.1f, which raises # NoneType.__format__ and triggers Voyager's destructive hard reset. # Preserve each file's original indentation while normalizing nullable # numeric fields so this patch is safe across Action/Critic/Curriculum. for rel in ( "voyager/agents/action.py", "voyager/agents/critic.py", "voyager/agents/curriculum.py", ): agent_path = SRC / rel agent = agent_path.read_text(encoding="utf-8") replacements = ( ( r'(?m)^(\s*)health = event\["status"\]\["health"\]\s*$', r'\1health = event["status"].get("health")\n\1health = float(health) if isinstance(health, (int, float)) else 0.0', ), ( r'(?m)^(\s*)hunger = event\["status"\]\["food"\]\s*$', r'\1hunger = event["status"].get("food")\n\1hunger = float(hunger) if isinstance(hunger, (int, float)) else 0.0', ), ( r'(?m)^(\s*)position = event\["status"\]\["position"\]\s*$', r'\1position = event["status"].get("position") or {}\n\1position = {axis: (float(position.get(axis)) if isinstance(position.get(axis), (int, float)) else 0.0) for axis in ("x", "y", "z")}', ), ( r'(?m)^(\s*)health = events\[-1\]\[1\]\["status"\]\["health"\]\s*$', r'\1health = events[-1][1]["status"].get("health")\n\1health = float(health) if isinstance(health, (int, float)) else 0.0', ), ( r'(?m)^(\s*)hunger = events\[-1\]\[1\]\["status"\]\["food"\]\s*$', r'\1hunger = events[-1][1]["status"].get("food")\n\1hunger = float(hunger) if isinstance(hunger, (int, float)) else 0.0', ), ( r'(?m)^(\s*)position = events\[-1\]\[1\]\["status"\]\["position"\]\s*$', r'\1position = events[-1][1]["status"].get("position") or {}\n\1position = {axis: (float(position.get(axis)) if isinstance(position.get(axis), (int, float)) else 0.0) for axis in ("x", "y", "z")}', ), ) for pattern, replacement in replacements: agent = re.sub(pattern, replacement, agent) agent_path.write_text(agent, encoding="utf-8") # A disconnect during /pause used to leave Python blocked indefinitely, # preventing both recovery and the existing process-exit watchdog. # EPIPE on the Mineflayer socket must also fail a pending /step and a # pending /start quickly so the runner can hard-reset instead of # parking on a dead bridge with position frozen and no new model calls. bridge_path = SRC / "voyager/env/bridge.py" bridge_text = bridge_path.read_text(encoding="utf-8") for endpoint in ("pause", "stop"): old_post = f'requests.post(f"{{self.server}}/{endpoint}")' if old_post not in bridge_text: raise RuntimeError( f"Voyager bridge /{endpoint} marker changed; refusing silent patch" ) bridge_text = bridge_text.replace( old_post, f'requests.post(f"{{self.server}}/{endpoint}", timeout=10)', ) # /start drives the 120s Node startup lifecycle; bound it just above at # 135s so a dead Mineflayer port raises instead of hanging env.reset(). # Upstream already carries timeout=self.request_timeout (600s) here, so # the old plain-form replace matched nothing and silently did nothing. start_old = "json=self.reset_options,\n timeout=self.request_timeout," if start_old not in bridge_text: raise RuntimeError("Voyager bridge /start marker changed; refusing silent patch") bridge_text = bridge_text.replace( start_old, "json=self.reset_options,\n timeout=135,", 1, ) # /step keeps upstream's request deadline so a wedged bridge raises on # the Python side too; the Node failStep guard answers first (503). if 'f"{self.server}/step", json=data, timeout=self.request_timeout' not in bridge_text: raise RuntimeError("Voyager bridge /step lost its deadline; refusing silent boot") bridge_path.write_text(bridge_text, encoding="utf-8") # Upstream hard-resets Mineflayer after every Python exception, even if # the Minecraft connection is healthy. That makes benign formatting or # model-output errors visible as leave/rejoin loops. Print a full # traceback, then recover an observation in-place; only fall back to a # hard reset when the live Mineflayer step itself fails. voyager_py = SRC / "voyager" / "voyager.py" voyager_src = voyager_py.read_text(encoding="utf-8") if "import traceback" not in voyager_src: voyager_src = voyager_src.replace("import time\n", "import time\nimport traceback\n", 1) # Upstream calls recorder.record([], task) when an Action response cannot # be parsed. EventRecorder immediately indexes events[0], so a truncated # LLM response turns a recoverable parse retry into IndexError. Record # the last valid observation instead and let Voyager retry the action. voyager_src = voyager_src.replace( ''' self.recorder.record([], self.task) print(f"\\033[34m{parsed_result} Trying again!\\033[0m") ''', ''' if self.last_events: self.recorder.record(self.last_events, self.task) print(f"\\033[34m{parsed_result} Trying again!\\033[0m") ''', 1, ) # Upstream calls the Action LLM on every rollout iteration and parses # its answer with the official process_ai_message. The earlier local # variant skipped the model entirely on the first iteration of # Place/Build/Obtain/Equip tasks and substituted a canonical template. # That removed model code generation from the learning loop, so it is # removed here. The call site keeps upstream's signature. old_recovery = ''' except Exception as e:\n time.sleep(3) # wait for mineflayer to exit\n info = {\n "task": task,\n "success": False,\n }\n # reset bot status here\n self.last_events = self.env.reset(\n options={\n "mode": "hard",\n "wait_ticks": self.env_wait_ticks,\n "inventory": self.last_events[-1][1]["inventory"],\n "equipment": self.last_events[-1][1]["status"]["equipment"],\n "position": self.last_events[-1][1]["status"]["position"],\n }\n )\n # use red color background to print the error\n print("Your last round rollout terminated due to error:")\n print(f"\\033[41m{e}\\033[0m")\n''' new_recovery = ''' except Exception as e:\n info = {\n "task": task,\n "success": False,\n }\n print("Your last round rollout terminated due to error:")\n traceback.print_exc()\n print(f"\\033[41m{type(e).__name__}: {e}\\033[0m")\n try:\n # Preserve the current Mineflayer connection whenever it is\n # still responsive. This prevents needless leave/rejoin.\n self.last_events = self.env.step("")\n print("Recovered Voyager observation without reconnecting Mineflayer")\n except Exception as recovery_error:\n print("Live recovery failed; performing one hard Mineflayer reset")\n traceback.print_exc()\n previous = self.last_events[-1][1] if self.last_events else {}\n status = previous.get("status", {}) if isinstance(previous, dict) else {}\n self.last_events = self.env.reset(\n options={\n "mode": "hard",\n "wait_ticks": self.env_wait_ticks,\n "inventory": previous.get("inventory", {}) if isinstance(previous, dict) else {},\n "equipment": status.get("equipment", []),\n "position": status.get("position", None),\n }\n )\n''' if old_recovery not in voyager_src: raise RuntimeError("Voyager upstream exception block changed; refusing unsafe patch") voyager_src = voyager_src.replace(old_recovery, new_recovery, 1) voyager_py.write_text(voyager_src, encoding="utf-8") _log(f"Patched Mineflayer identity, safe observations, full tracebacks, non-destructive recovery, movement guards, and bounded mineBlock targets") def _ensure_source(self) -> None: ROOT.mkdir(parents=True, exist_ok=True) env = os.environ.copy() env["GIT_LFS_SKIP_SMUDGE"] = "1" if not (SRC / ".git").exists(): if SRC.exists(): shutil.rmtree(SRC) _run(["git", "clone", "--filter=blob:none", "--no-checkout", UPSTREAM, str(SRC)], ROOT, env=env) _run(["git", "fetch", "--depth", "1", "origin", UPSTREAM_COMMIT], SRC, env=env) _run(["git", "checkout", "--force", UPSTREAM_COMMIT], SRC, env=env) self._patch_runtime_source() def _ensure_python_package(self) -> None: """Run the README's `pip install -e .` for the pinned Voyager checkout. --no-deps keeps the Space's own pinned requirements authoritative: the Space runs langchain and chromadb versions that this Space's build resolved, and letting pip re-resolve them at boot would silently move the inference stack underneath a running Space. The distribution is still installed and verified, which is what the documented install provides here. """ marker = ROOT / ".voyager-installed" if not marker.exists(): _run( [sys.executable, "-m", "pip", "install", "-e", ".", "--no-deps", "--no-build-isolation"], SRC, ) probe = ( "import importlib.metadata as m;" "d = m.distribution('voyager');" "print(d.version)" ) proc = subprocess.run( [sys.executable, "-c", probe], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=120, ) installed = (proc.stdout or "").strip() if proc.returncode != 0 or not installed: raise RuntimeError( f"Voyager editable install is missing after pip install -e .: {installed!r}" ) # Recorded only once the install is known to be live, so a failed attempt # is retried instead of being remembered as done. if not marker.exists(): marker.write_text("ok\n", encoding="utf-8") _log(f"Voyager package installed: voyager {installed} from {SRC}") def _terminate_proc(self, proc: subprocess.Popen[str] | None, name: str) -> None: if proc is None or proc.poll() is not None: return try: proc.terminate() proc.wait(timeout=15) except Exception: try: proc.kill() except Exception: pass else: return try: proc.wait(timeout=5) except Exception: pass _log(f"{name} stopped") def _ensure_naldo_brain(self) -> None: """Install/pin OpenCode and start loopback-only server + bridge. Never binds 0.0.0.0, never copies credential files, never requires OPENCODE_API_KEY. Both children are owned by this stack and stopped together with Naldo. """ if not is_loopback_url(NALDO_OPENCODE_BASE) or not is_loopback_url(NALDO_BRIDGE_BASE): raise RuntimeError("Naldo brain endpoints must stay loopback-only") opencode_binary, reported = ensure_opencode_installed() _log( f"OpenCode CLI ready: {reported[:80]} local_binary={opencode_binary} " "(pinned opencode-ai@1.18.33)" ) support = Path(__file__).resolve().parent bridge_entry = support / "naldo_opencode_bridge.py" if not bridge_entry.exists(): raise RuntimeError("Naldo OpenCode bridge entrypoint missing") self._stop_brain() opencode_log = ROOT / "opencode.log" bridge_log = ROOT / "naldo-bridge.log" opencode_log.write_text("", encoding="utf-8") bridge_log.write_text("", encoding="utf-8") # Isolate OpenCode's HOME/XDG dirs in the runtime tree so it cannot # inherit any account credential/config from the container user. env = opencode_subprocess_env(os.environ.copy()) env["OPENCODE_BASE"] = NALDO_OPENCODE_BASE env["NALDO_BRIDGE_BASE"] = NALDO_BRIDGE_BASE env["OPENCODE_PORT"] = str(NALDO_OPENCODE_PORT) env["NALDO_BRIDGE_PORT"] = str(NALDO_BRIDGE_PORT) env["NALDO_EMBED_BASE"] = EMBED_BASE_URL with opencode_log.open("a", encoding="utf-8", errors="replace") as op_log: op_log.write(f"cmd: opencode serve --hostname 127.0.0.1 --port {NALDO_OPENCODE_PORT}\n") op_log.flush() self.opencode_proc = subprocess.Popen( opencode_serve_command(opencode_binary, NALDO_OPENCODE_PORT), cwd=str(ROOT), env=env, stdout=op_log, stderr=subprocess.STDOUT, text=True, ) if not _wait_tcp("127.0.0.1", NALDO_OPENCODE_PORT, 120): raise RuntimeError("OpenCode server did not open 127.0.0.1:{port}".format(port=NALDO_OPENCODE_PORT)) if self.opencode_proc.poll() is not None: raise RuntimeError(f"OpenCode server exited rc={self.opencode_proc.returncode}") health = _check_opencode_health(NALDO_OPENCODE_BASE, timeout=10.0) _log(f"OpenCode health: ok={health.get('ok')} ready={health.get('ready')}") with bridge_log.open("a", encoding="utf-8", errors="replace") as br_log: br_log.write(f"cmd: bridge on 127.0.0.1:{NALDO_BRIDGE_PORT} -> {NALDO_OPENCODE_BASE}\n") br_log.flush() self.bridge_proc = subprocess.Popen( [sys.executable, "-u", str(bridge_entry)], cwd=str(support), env=env, stdout=br_log, stderr=subprocess.STDOUT, text=True, ) if not _wait_tcp("127.0.0.1", NALDO_BRIDGE_PORT, 120): raise RuntimeError("Naldo bridge did not open 127.0.0.1:{port}".format(port=NALDO_BRIDGE_PORT)) if self.bridge_proc.poll() is not None: raise RuntimeError(f"Naldo bridge exited rc={self.bridge_proc.returncode}") bridge_health = _check_bridge_health(NALDO_BRIDGE_BASE, timeout=10.0) _log(f"Naldo bridge health: ok={bridge_health.get('ok')} ready={bridge_health.get('ready')}") if not bridge_health.get("ready"): raise RuntimeError("Naldo bridge is not ready for OpenCode inference") if not _wait_embedding_ready(NALDO_BRIDGE_BASE, timeout=180.0): raise RuntimeError("Naldo embedding backend did not become ready within 180s") _log("Naldo embedding probe: ready") def _brain_healthy(self) -> bool: opencode_proc, bridge_proc = self._brain_procs() if not opencode_proc or opencode_proc.poll() is not None: return False if not bridge_proc or bridge_proc.poll() is not None: return False try: if not _check_opencode_health(NALDO_OPENCODE_BASE, timeout=3.0).get("ready"): return False return bool(_check_bridge_health(NALDO_BRIDGE_BASE, timeout=3.0).get("ready")) except Exception: return False def _stop_brain(self) -> None: with self.lock: opencode_proc = self.opencode_proc bridge_proc = self.bridge_proc self.opencode_proc = None self.bridge_proc = None self._terminate_proc(bridge_proc, "Naldo bridge") self._terminate_proc(opencode_proc, "OpenCode server") def brain_status(self) -> dict[str, Any]: opencode_proc, bridge_proc = self._brain_procs() opencode = _check_opencode_health(NALDO_OPENCODE_BASE, timeout=2.0) bridge = _check_bridge_health(NALDO_BRIDGE_BASE, timeout=2.0) # Never expose env values beyond loopback base URLs; no credentials # are stored here at all. return { "provider": "opencode", "profile": NALDO_BRAIN_PROFILE, "primary_model": NALDO_PRIMARY_MODEL, "fallback_models": list(ALLOWED_MODELS), "opencode_base": NALDO_OPENCODE_BASE, "bridge_base": NALDO_BRIDGE_BASE, "loopback_only": bool(is_loopback_url(NALDO_OPENCODE_BASE) and is_loopback_url(NALDO_BRIDGE_BASE)), "opencode_running": bool(opencode_proc and opencode_proc.poll() is None), "opencode_pid": opencode_proc.pid if opencode_proc and opencode_proc.poll() is None else None, "opencode": opencode, "bridge_running": bool(bridge_proc and bridge_proc.poll() is None), "bridge_pid": bridge_proc.pid if bridge_proc and bridge_proc.poll() is None else None, "bridge": bridge, "temperature_note": "session API has no temperature field; requested value recorded only", } def _pin_node_versions(self) -> None: root_pkg = SRC / "voyager" / "env" / "mineflayer" / "package.json" data = json.loads(root_pkg.read_text(encoding="utf-8")) pinned = { # Freeze Voyager's own 2023-era dependency line instead of mixing # it with later protocol packages. The upstream commit is tested # against Minecraft/Fabric 1.19 and starts from Mineflayer 4.8.1. "body-parser": "1.20.2", "express": "4.18.2", "magic-string": "0.30.0", "minecraft-data": "3.31.0", "minecrafthawkeye": "1.3.6", "mineflayer": "4.8.1", "mineflayer-pathfinder": "2.4.2", "mineflayer-pvp": "1.3.2", "mineflayer-tool": "1.2.0", "mocha": "10.2.0", "prismarine-biome": "1.3.0", "prismarine-block": "1.16.3", "prismarine-entity": "2.2.0", "prismarine-item": "1.12.1", "prismarine-nbt": "2.2.1", "prismarine-recipe": "1.3.1", "prismarine-viewer": "1.24.0", "typescript": "4.9.5", "vec3": "0.1.8", "graceful-fs": "4.2.11", } for section in ("dependencies", "devDependencies"): for key in list(data.get(section, {})): if key in pinned: data[section][key] = pinned[key] data["dependencies"]["mineflayer-collectblock"] = "file:mineflayer-collectblock" data["overrides"] = { # Keep transitive protocol readers on the same generation as # Mineflayer 4.8.1 / Voyager's Minecraft 1.19 setup. "mineflayer": "4.8.1", "minecraft-protocol": "1.41.2", "protodef": "1.15.0", "minecraft-data": "3.31.0", "prismarine-block": "1.16.3", "prismarine-nbt": "2.2.1", "prismarine-chat": "1.7.1", "prismarine-item": "1.12.1", "prismarine-biome": "1.3.0", "prismarine-chunk": "1.32.0", "prismarine-world": "3.6.0", "prismarine-entity": "2.2.0", "prismarine-recipe": "1.3.1", "prismarine-physics": "1.6.0", "prismarine-windows": "2.5.0", "prismarine-registry": "1.5.0", "mineflayer-pathfinder": "2.4.2", "mineflayer-pvp": "1.3.2", "mineflayer-tool": "1.2.0", } root_pkg.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") cb_pkg = SRC / "voyager" / "env" / "mineflayer" / "mineflayer-collectblock" / "package.json" cb = json.loads(cb_pkg.read_text(encoding="utf-8")) cb["dependencies"].update({ "mineflayer": "4.8.1", "mineflayer-pathfinder": "2.4.2", "mineflayer-tool": "1.2.0", }) cb["devDependencies"].update({ "@types/node": "18.6.4", "require-self": "0.2.3", "ts-standard": "11.0.0", "typescript": "4.9.5", }) cb["overrides"] = dict(data["overrides"]) cb_pkg.write_text(json.dumps(cb, indent=2) + "\n", encoding="utf-8") # The vendored collectblock source predates stricter TS indexing rules. # Keep its behavior, but make the two tool lookups type-safe so the # official local plugin can still be compiled deterministically. cb_source = cb_pkg.parent / "src" / "CollectBlock.ts" cb_text = cb_source.read_text(encoding="utf-8") cb_text = cb_text.replace( ''' const properties = bot.registry.blocksByName[closest.name]; const leastTool = Object.keys( properties.harvestTools )[0]; const item = bot.registry.items[leastTool]; bot.chat( `I need at least a ${item.name} to mine ${closest.name}! Skip it!` ); ''', ''' const blockName = closest.name; if (!blockName) return; const properties = bot.registry.blocksByName[blockName]; const harvestTools = properties.harvestTools || {}; const leastTool = Number(Object.keys(harvestTools)[0]); const item = bot.registry.items[leastTool]; bot.chat( `I need at least a ${item?.name || "valid tool"} to mine ${blockName}! Skip it!` ); ''', ) cb_text = cb_text.replace("// @ts-expect-error", "// @ts-ignore") cb_source.write_text(cb_text, encoding="utf-8") for lock in (root_pkg.parent / "package-lock.json", cb_pkg.parent / "package-lock.json"): lock.unlink(missing_ok=True) _log("Pinned Voyager/Mineflayer dependency tree to 2023-compatible versions") def _verify_node_versions(self, mineflayer: Path) -> None: expected = { "mineflayer": "4.8.1", "minecraft-protocol": "1.41.2", "protodef": "1.15.0", "minecraft-data": "3.31.0", "prismarine-block": "1.16.3", "prismarine-nbt": "2.2.1", "prismarine-chat": "1.7.1", "prismarine-item": "1.12.1", "prismarine-biome": "1.3.0", "prismarine-chunk": "1.32.0", "prismarine-world": "3.6.0", "prismarine-entity": "2.2.0", "prismarine-recipe": "1.3.1", "prismarine-physics": "1.6.0", "prismarine-windows": "2.5.0", "prismarine-registry": "1.5.0", } installed: dict[str, str] = {} for package, wanted in expected.items(): package_json = mineflayer / "node_modules" / package / "package.json" if not package_json.exists(): raise RuntimeError(f"Missing pinned Node package: {package}") actual = str(json.loads(package_json.read_text(encoding="utf-8")).get("version", "")) installed[package] = actual if actual != wanted: raise RuntimeError(f"Node dependency drift: {package} expected {wanted}, got {actual}") _log("Verified Voyager protocol tree: " + ", ".join(f"{k}={v}" for k, v in installed.items())) def _verify_babel(self, babel: Path) -> None: """Fail the boot unless Babel is 7.x and parse() stays synchronous.""" installed = {} for package in ("@babel/core", "@babel/generator"): package_json = babel / "node_modules" / package / "package.json" if not package_json.exists(): raise RuntimeError(f"Missing pinned Babel package: {package}") version = str(json.loads(package_json.read_text(encoding="utf-8")).get("version", "")) installed[package] = version if not version.startswith("7."): raise RuntimeError( f"Babel {package} must stay on 7.x because upstream calls babel.parse() " f"synchronously; got {version}" ) # Prove the actual contract the Action agent depends on, in the same Node # runtime that will run it, rather than trusting the version string. probe = ( "const babel = require('@babel/core');" "const parsed = babel.parse('async function f(){return 1}');" "if (!parsed || !parsed.program) { throw new Error('babel.parse did not return an AST'); }" "console.log('sync-ok');" ) proc = subprocess.run( ["node", "-e", probe], env={**os.environ, "NODE_PATH": str(babel / "node_modules")}, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=120, ) output = (proc.stdout or "").strip() if proc.returncode != 0 or "sync-ok" not in output: raise RuntimeError( f"babel.parse() is not synchronous in this Node runtime; upstream's " f"process_ai_message would fail on every action: {output[-400:]}" ) _log("Verified synchronous Babel parse: " + ", ".join(f"{k}={v}" for k, v in installed.items())) def _pin_javascript_babel(self) -> None: """Force the `javascript` library's lazily-installed Babel to synchronous 7.x. Upstream's process_ai_message calls babel.parse(code) synchronously. The javascript package resolves require("@babel/core") through JSPyBridge, which lazily runs `npm install @babel/core@npm:latest` into javascript/js/node_modules and records the result in javascript/js/package.json. "latest" is now Babel 8, where parse() is callback-only, so every action fails. Pin the version JSPyBridge will see before it installs anything, and install it here, so the resolved copy is the last Babel 7 line. """ try: import javascript # noqa: PLC0415 except Exception as exc: # pragma: no cover - javascript is a hard dep raise RuntimeError(f"the javascript package is required by upstream: {exc}") js_dir = Path(javascript.__file__).resolve().parent / "js" js_dir.mkdir(parents=True, exist_ok=True) node_modules = js_dir / "node_modules" # deps.js reads this file to decide what is already installed. Recording the # pinned version here stops it from resolving "latest" to Babel 8 later. package_path = js_dir / "package.json" data = {"name": "js-modules", "private": True, "dependencies": {}} if package_path.exists(): try: data = json.loads(package_path.read_text(encoding="utf-8")) except ValueError: data = {"name": "js-modules", "private": True, "dependencies": {}} data.setdefault("name", "js-modules") data["private"] = True data["dependencies"] = {**data.get("dependencies", {}), **BABEL_PINS} package_path.write_text(json.dumps(data, indent="\t") + "\n", encoding="utf-8") # Install the pinned packages into exactly the tree JSPyBridge resolves from. _run( [ "npm", "install", "--no-audit", "--no-fund", "--prefix", str(js_dir), "@babel/core@7.26.9", "@babel/generator@7.26.9", ], ROOT, ) # Verify the copy that bridge.js will actually require is 7.x. for package in ("@babel/core", "@babel/generator"): package_json = node_modules / package / "package.json" if not package_json.exists(): raise RuntimeError(f"missing pinned Babel package after override: {package}") version = str(json.loads(package_json.read_text(encoding="utf-8")).get("version", "")) if not version.startswith("7."): raise RuntimeError( f"the javascript package still resolves Babel {package} {version}; " "upstream's babel.parse() needs the synchronous 7.x line" ) _log(f"Pinned javascript's resolved Babel to 7.x under {node_modules}") def _ensure_node(self) -> str: mineflayer = SRC / "voyager" / "env" / "mineflayer" marker = ROOT / ".node-ready" if not marker.exists(): self._pin_node_versions() collectblock = mineflayer / "mineflayer-collectblock" _run(["npm", "install", "--no-audit", "--no-fund"], collectblock) _run(["node", str(collectblock / "node_modules" / "typescript" / "bin" / "tsc")], collectblock) _run(["npm", "install", "--no-audit", "--no-fund"], mineflayer) self._verify_node_versions(mineflayer) babel = ROOT / "babel" babel.mkdir(parents=True, exist_ok=True) # Upstream's process_ai_message calls babel.parse(code) # synchronously. Babel 8 made parse() callback-only, so an unpinned # install that lands on 8 makes every action fail to parse. Pin the # last Babel 7 releases, which keep parse() synchronous. _run( [ "npm", "install", "--no-audit", "--no-fund", "--prefix", str(babel), "@babel/core@7.26.9", "@babel/generator@7.26.9", ], ROOT, ) self._verify_babel(babel) self._pin_javascript_babel() marker.write_text("ok\n", encoding="utf-8") paths = [ str(ROOT / "babel" / "node_modules"), str(mineflayer / "node_modules"), ] return os.pathsep.join(paths) def _pump(self, proc: subprocess.Popen[str]) -> None: assert proc.stdout is not None with LOG_PATH.open("a", encoding="utf-8", errors="replace") as fh: for line in proc.stdout: fh.write(line) fh.flush() print("[voyager] " + line.rstrip(), flush=True) def _boot(self) -> None: with self.lock: self.state = "waiting-minecraft" self.error = "" self.started_at = time.time() try: if not _wait_tcp("127.0.0.1", 25565, 180): raise RuntimeError("Minecraft server did not open 127.0.0.1:25565") ready_file = Path(os.environ.get("MINECRAFT_READY_FILE", "/tmp/minecraft-stack/minecraft.ready")) deadline = time.time() + 180 while time.time() < deadline and not ready_file.exists(): time.sleep(1) if not ready_file.exists(): raise RuntimeError("Minecraft port opened but world readiness marker was not created") with self.lock: self.state = "preparing" python_version = _require_python_version() node_version = _require_node_version() _log(f"Runtime versions: python {python_version}, node {node_version}") self._ensure_source() self._ensure_python_package() node_path = self._ensure_node() CKPT_DIR.mkdir(parents=True, exist_ok=True) # Fresh map, fresh tasks: the checkpoint starts empty (resume=False). # Nothing is ever seeded or copied from the old checkpoint. _log(f"Checkpoint starts fresh: {_is_fresh_checkpoint(CKPT_DIR)}") # Naldo's generative brain is OpenCode only. Start the loopback # server + bridge BEFORE the Voyager child; the child must never # fall back to Granite. self._ensure_naldo_brain() env = os.environ.copy() env.update( { "VOYAGER_HOME": str(SRC), "VOYAGER_ATTESTATION_FILE": str(ROOT / "engine-attestation.json"), "VOYAGER_OPENAI_BASE": NALDO_BRIDGE_BASE, "VOYAGER_BRAIN_PROFILE": NALDO_BRAIN_PROFILE, "VOYAGER_API_KEY": "local-voyager", "VOYAGER_MODEL": NALDO_PRIMARY_MODEL, "VOYAGER_AUX_MODEL": NALDO_PRIMARY_MODEL, "VOYAGER_CKPT_DIR": str(CKPT_DIR), "MC_USERNAME": BOT_USERNAME, "BOT_DISPLAY_NAME": BOT_DISPLAY_NAME, "MC_PORT": "25565", "MINEFLAYER_API_PORT": "3000", "MAX_ITERATIONS": os.environ.get("MAX_ITERATIONS", "1000000"), "OPENAI_API_REQUEST_TIMEOUT": "240", "ANONYMIZED_TELEMETRY": "False", "NODE_PATH": node_path, "PYTHONPATH": str(SRC), } ) LOG_PATH.write_text("", encoding="utf-8") runner = Path(__file__).resolve().parent / "voyager_runner.py" backoff = 5 while not self.stop_event.is_set(): if not self._brain_healthy(): self.restart_count += 1 with self.lock: self.state = "restarting" self.error = ( f"OpenCode bridge unavailable; Naldo held without Granite fallback " f"(restart #{self.restart_count})" ) _terminate_naldo_child(self) _log("Naldo brain unhealthy; restarting OpenCode server + bridge before Naldo") if self.stop_event.wait(backoff): break try: self._ensure_naldo_brain() except Exception as exc: _log(f"Naldo brain restart failed: {exc!r}") backoff = restart_backoff_after(backoff, 0.0) continue run_started = time.monotonic() self.proc = subprocess.Popen( [sys.executable, "-u", str(runner)], cwd=str(SRC), env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, ) threading.Thread(target=self._pump, args=(self.proc,), daemon=True, name="voyager-log").start() time.sleep(5) if self.proc.poll() is None: if not self._brain_healthy(): _log("Naldo started but brain died during boot; stopping child without Granite fallback") _terminate_naldo_child(self) rc = -1 else: with self.lock: self.state = "running" self.error = "" _log(f"Voyager running pid={self.proc.pid} as {BOT_USERNAME} via {NALDO_BRIDGE_BASE}") # Do not block forever in wait(): actively verify the # OpenCode server + bridge while Naldo is alive. while self.proc.poll() is None and not self.stop_event.is_set(): if self.stop_event.wait(7): break if not self._brain_healthy(): _log("OpenCode/bridge died mid-run; stopping Naldo immediately") _terminate_naldo_child(self) break if self.stop_event.is_set(): _terminate_naldo_child(self) rc = self.proc.poll() if rc is None: try: rc = self.proc.wait(timeout=2) except Exception: rc = -1 else: rc = self.proc.returncode if self.stop_event.is_set(): break # If the brain died mid-run, recycle it first; never let Naldo # keep calling a dead bridge or silently use another backend. if not self._brain_healthy(): _log("OpenCode/bridge died mid-run; recycling brain before Naldo restart") try: self._ensure_naldo_brain() except Exception as exc: _log(f"Naldo brain recycle failed: {exc!r}") self.restart_count += 1 with self.lock: self.state = "restarting" self.error = f"Voyager exited rc={rc}; automatic restart #{self.restart_count}" _log(f"Voyager exited rc={rc}; restarting in {backoff}s (attempt {self.restart_count})") if self.stop_event.wait(backoff): break backoff = restart_backoff_after(backoff, time.monotonic() - run_started) with self.lock: if self.stop_event.is_set(): self.state = "stopped" except Exception as exc: with self.lock: self.state = "error" self.error = repr(exc) _log(f"VOYAGER ERROR: {exc!r}") finally: _terminate_naldo_child(self) self._stop_brain() def start_async(self) -> dict[str, Any]: with self.lock: self.stop_event.clear() if self.proc and self.proc.poll() is None: return self.status() if self.worker and self.worker.is_alive(): return self.status() self.worker = threading.Thread(target=self._boot, daemon=True, name="voyager-bootstrap") self.worker.start() return self.status() def stop(self) -> dict[str, Any]: self.stop_event.set() with self.lock: self.state = "stopped" # Stop Naldo first, then bridge + OpenCode, so no inference can race # a half-dead brain or fall back to another backend. _terminate_naldo_child(self) self._stop_brain() return self.status() def tail(self, lines: int = 160) -> str: chunks: list[str] = [] for path in (SETUP_LOG, LOG_PATH): if path.exists(): chunks.extend(tail_lines(path, lines)) return "\n".join(chunks[-lines:]) def _mineflayer_status(self) -> dict[str, Any]: try: with urllib.request.urlopen("http://127.0.0.1:3000/health", timeout=1.5) as response: data = json.loads(response.read().decode("utf-8")) return data if isinstance(data, dict) else {"ready": False} except Exception: return {"ready": False} def status(self) -> dict[str, Any]: mineflayer = self._mineflayer_status() lines = tail_lines(LOG_PATH) try: completed = json.loads((CKPT_DIR / "curriculum" / "completed_tasks.json").read_text(encoding="utf-8")) failed = json.loads((CKPT_DIR / "curriculum" / "failed_tasks.json").read_text(encoding="utf-8")) except (OSError, ValueError): completed, failed = [], [] learning = read_learning_state(CKPT_DIR) # The Action agent is the only role whose output has to be executable # JavaScript, so a parse failure is the single most expensive silent # failure in the loop. Surface the reason and the completion size that # produced it: a completion pinned at the token cap means the program was # cut off mid-function rather than written wrong. parse_errors = [ line.strip() for line in lines if "Error parsing action response (before program execution)" in line ] completion_tokens = [] for line in lines: if "[llm_metric]" not in line: continue try: record = json.loads(line.split("[llm_metric]", 1)[1]) except ValueError: continue if record.get("role") == "action" and record.get("completion_tokens"): completion_tokens.append(record["completion_tokens"]) activity = { "completed_count": len(completed), "failed_count": len(failed), "recent_completed": completed[-5:], "recent_failed": failed[-8:], "curriculum_retry_exhausted": sum("Max retries reached, failed to propose ai task" in line for line in lines), "action_parse_errors": len(parse_errors), # The exception type and message lead the message; the Node stack # trace at the tail says nothing about what actually went wrong. "last_action_parse_error": ( parse_errors[-1].split("(before program execution):", 1)[-1][:500] if parse_errors else None ), "action_completion_tokens": completion_tokens[-8:], "curriculum_parse_errors": sum("Error parsing curriculum" in line for line in lines), "action_model_messages": sum("****Action Agent ai message****" in line for line in lines), "curriculum_model_messages": sum("****Curriculum Agent ai message****" in line for line in lines), "partial_read_errors": sum("PartialReadError" in line for line in lines), "file_not_found_errors": sum("FileNotFoundError" in line for line in lines), "index_errors": sum("IndexError" in line for line in lines), "syntax_errors": sum("SyntaxError" in line for line in lines), } with self.lock: proc = self.proc state_name = self.state error = self.error or None started_at = self.started_at restart_count = self.restart_count # brain_status performs local HTTP probes; never hold the lifecycle # lock across those timeouts, otherwise stop/start can stall. brain = self.brain_status() running = bool(proc and proc.poll() is None) pid = proc.pid if running else None return { "state": state_name, "error": error, "started_at": started_at, "pid": pid, "running": running, "upstream_commit": UPSTREAM_COMMIT, "engine_runtime": read_attestation(ROOT / "engine-attestation.json", pid), "minecraft": "127.0.0.1:25565", "openai_base": NALDO_BRIDGE_BASE, "model": NALDO_PRIMARY_MODEL, "aux_model": NALDO_PRIMARY_MODEL, "agent_routing": {"action": "opencode-muse-spark", "curriculum": "opencode-muse-spark", "critic": "opencode-muse-spark", "qa": "opencode-muse-spark", "skill_manager": "opencode-muse-spark"}, "decision_brain": "opencode-only", "brain_profile": NALDO_BRAIN_PROFILE, "brain": brain, "embedding_backend": EMBED_BASE_URL, "bot_username": BOT_USERNAME, "bot_display_name": BOT_DISPLAY_NAME, "checkpoint_dir": str(CKPT_DIR), "checkpoint_persistent": str(CKPT_DIR).startswith("/persist/"), "learning": learning, "activity": activity, "restart_count": restart_count, "mineflayer": mineflayer, "log_tail": self.tail(120), } VOYAGER = VoyagerStack()