diff --git "a/voyager_stack.py" "b/voyager_stack.py" --- "a/voyager_stack.py" +++ "b/voyager_stack.py" @@ -1,2460 +1,1588 @@ -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 __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_processes import stop_runner -from voyager_runtime_contract import ( - EXPECTED_DIR, REFERENCE_DIR, UPSTREAM_COMMIT, apply_curriculum_retry_adaptation, - read_attestation, read_exit_report, verify_divergence, write_upstream_manifest, -) - -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-20260927-flat-y64-v1"))) -BOT_USERNAME = os.environ.get("MC_USERNAME", "Naldo_Bot") -BOT_DISPLAY_NAME = os.environ.get("BOT_DISPLAY_NAME", "Naldo Bot") -SRC = ROOT / "Voyager" -LOG_PATH = ROOT / "voyager.log" -SETUP_LOG = ROOT / "setup.log" -UPSTREAM = "https://github.com/MineDojo/Voyager.git" - -# --------------------------------------------------------------------------- -# Onde fica o cérebro -# --------------------------------------------------------------------------- -# A inferência saiu deste Space. Rodar o modelo aqui disputava CPU e RAM com o -# servidor Java, o ViaProxy, o relay Go e dois bots — e o próprio modelo era o -# maior consumidor dos três (18,56 GB de pesos mais o KV cache). Agora este -# Space só hospeda o jogo; o modelo vive no Space `Erinaldordso/Mine`, que não -# tem mais nada para atrapalhar. -# -# Por que uma URL pública e não `127.0.0.1`: o Mine é um Space separado, logo -# não é alcançável por loopback daqui. O brain lock continua existindo para o -# caso de um deploy apontar para um provedor errado sem ninguém notar. -BRAIN_BASE_URL = os.environ.get( - "VOYAGER_BRAIN_BASE_URL", "https://erinaldordso-mine.hf.space/v1" -) -BRAIN_PROFILE = os.environ.get("VOYAGER_BRAIN_PROFILE_NAME", "naldo-qwen3-coder") -BRAIN_MODEL = os.environ.get("VOYAGER_BRAIN_MODEL", "qwen3-coder-30b-a3b-instruct") -# O Voyager usa um modelo SEPARADO para o QA do curriculum e a descrição de -# skill. Antes ele era o granite local; agora o Mine também o serve, para que -# nenhum peso de modelo sobre no Space do jogo. -BRAIN_AUX_MODEL = os.environ.get("VOYAGER_BRAIN_AUX_MODEL", "granite-4.0-h-tiny") - -# Escape hatch. O brain lock do runner derruba o processo se a tupla não bater, -# e o watchdog reinicia em loop — ou seja, um deploy ruim deixa o bot parado. -# Com isto ligado, os valores do lock voltam a ser os antigos (cérebro local) e -# o bot sobe mesmo se o Space do Mine estiver fora. -BRAIN_LOCK_ENFORCED = os.environ.get("VOYAGER_BRAIN_LOCK", "enforce") != "off" - - -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 _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 - - -class VoyagerStack: - def __init__(self) -> None: - self.lock = threading.RLock() - self.worker: threading.Thread | None = None - self.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 _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) - # Hash the whole pristine tree now, while it still matches the pinned - # commit. The runner re-measures it after every patch step and refuses to - # attest a divergence the deployment does not declare. - write_upstream_manifest(SRC) - - 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",', - ) - 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], ["cobblestone", 64]\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''' - creative_spawn = ' if (bot.game.gameMode === "creative") {\n' + creative_spawn + ' }\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 = text.replace( - ' 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 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 }\n function cleanupStepDisconnectListeners() {\n step_finished = true;\n stepBot.removeListener("end", failStepOnDisconnect);\n stepBot.removeListener("kicked", failStepOnDisconnect);\n }\n stepBot.once("end", failStepOnDisconnect);\n stepBot.once("kicked", failStepOnDisconnect);\n function otherError(err) {''', - ) - text = text.replace( - ' bot.removeListener("physicTick", onTick);', - ' bot.removeListener("physicTick", onTick);\n cleanupStepDisconnectListeners();', - ) - text = text.replace( - ' bot.once("error", onConnectionFailed);', - ''' bot.once("error", onConnectionFailed);\n\n // 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 }''', - ) - 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: !!(bot && bot.entity),\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. - # - # allow1by1towers was false while exploration was surface-only, but - # with the descent policy the bot now mines a column and must be - # able to climb back out of it. A one-block tower is the way a - # pathfinder leaves a 1x1 shaft, so blocking it here would trap the - # bot at the bottom of its own staircase. - ' movements.allow1by1towers = true;\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, - ) - # Bind each route to its own bot. An old socket's delayed end event - # must not erase the newly connected player or leave /start pending. +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-20260927-flat-y64-v1"))) +BOT_USERNAME = os.environ.get("MC_USERNAME", "Naldo_Bot") +BOT_DISPLAY_NAME = os.environ.get("BOT_DISPLAY_NAME", "Naldo Bot") +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 _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 + + +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( - "let bot = null;\n", - 'const { installConnectionGuards, isReady, startLifecycle, restoreCreativeInventory, safeRoute } = require("./lib/voyager_connection");\n' - 'let bot = null;\nlet lastDisconnect = null;\nfunction currentBot() { return bot; }\n', 1, + ' 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",', + ) + 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 = text.replace( + ' 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 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 }\n function cleanupStepDisconnectListeners() {\n step_finished = true;\n stepBot.removeListener("end", failStepOnDisconnect);\n stepBot.removeListener("kicked", failStepOnDisconnect);\n }\n stepBot.once("end", failStepOnDisconnect);\n stepBot.once("kicked", failStepOnDisconnect);\n function otherError(err) {''', + ) + text = text.replace( + ' bot.removeListener("physicTick", onTick);', + ' bot.removeListener("physicTick", onTick);\n cleanupStepDisconnectListeners();', + ) + text = text.replace( + ' bot.once("error", onConnectionFailed);', + ''' bot.once("error", onConnectionFailed);\n\n // 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 }''', + ) + 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: !!(bot && bot.entity),\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, ) - text = text.replace('if (bot) onDisconnect("Restarting bot");', - 'if (bot) bot._voyagerLifecycle.disconnect("Restarting bot");', 1) - text = text.replace(' bot.once("error", onConnectionFailed);', ''' const startBot = bot; - const lifecycle = startLifecycle(startBot, res, (reason) => { - if (bot === startBot) { lastDisconnect = reason; bot = null; } - }); - startBot._voyagerLifecycle = lifecycle;''', 1) - text = text.replace(' bot.on("kicked", onDisconnect);', '', 1) - text = text.replace(' bot.once("spawn", async () => {\n bot.removeListener("error", onConnectionFailed);', ''' bot.once("spawn", async () => { - const bot = startBot; - installConnectionGuards(bot); - try {''', 1) - text = text.replace(' if (req.body.reset === "hard") {', ''' if (req.body.reset === "hard" && bot.game.gameMode === "creative") { - await restoreCreativeInventory(bot, req.body.inventory || {}, req.body.equipment || []); - } else if (req.body.reset === "hard") {''', 1) - text = text.replace(' await bot.waitForTicks(bot.waitTicks * itemTicks);\n res.json(bot.observe());\n\n initCounter(bot);\n bot.chat("/gamerule keepInventory true");\n bot.chat("/gamerule doDaylightCycle false");\n });', ''' initCounter(bot); - bot.chat("/gamerule keepInventory true"); - bot.chat("/gamerule doDaylightCycle false"); - await bot._voyagerFlushChat(); - await bot.waitForTicks(bot.waitTicks * itemTicks); - lifecycle.ready(); - res.json(bot.observe()); - } catch (error) { lifecycle.disconnect(error); } - });''', 1) - old_handlers = text.index(' function onConnectionFailed(e) {') - handler_end = text.index('\n});\n\napp.post("/step"', old_handlers) - text = text[:old_handlers] + text[handler_end:] - step_start = text.index('app.post("/step", async (req, res) => {') - step_end = text.index('app.get("/health"', step_start) - step_text = text[step_start:step_end] - step_text = step_text.replace('app.post("/step", async (req, res) => {', - 'app.post("/step", safeRoute(async (req, res) => {\n const bot = currentBot();', 1) - step_text = step_text.replace('if (!bot || !bot.entity)', 'if (!isReady(bot))', 1) - step_text = step_text.replace(' step_finished = true;', ''' step_finished = true; - process.off("uncaughtException", otherError); - stepBot.removeListener("physicTick", onTick);''', 1) - step_text = step_text.replace(' stepBot.once("end", failStepOnDisconnect);', - ' res.once("close", cleanupStepDisconnectListeners);\n stepBot.once("end", failStepOnDisconnect);', 1) - step_text = step_text.replace(' await returnItems();', - ' await returnItems();\n await bot._voyagerFlushChat();', 1) - if not step_text.endswith('});\n\n'): - raise RuntimeError("Voyager step boundary changed; refusing connection patch") - step_text = step_text[:-5] + '}));\n\n' - text = text[:step_start] + step_text + text[step_end:] - text = text.replace('ready: !!(bot && bot.entity),', 'ready: isReady(bot),\n last_disconnect: lastDisconnect,', 1) - # Unlike /step, /start does not run a long generated program. - bridge_helper = Path(__file__).resolve().parent / "voyager_connection.js" - shutil.copyfile(bridge_helper, index_js.parent / "lib/voyager_connection.js") 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") - # 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 = []; - - // The world is a superflat layered cake (minecraft_stack.WORLD_FLAT_LAYERS): - // every ore is a continuous horizontal LAYER at a fixed height, with stone - // separating the shallow band from the deepslate band. Iron sits at y=48-51 - // and coal at y=40-43 while the bot stands at y=63, so the target is 12-23 - // blocks straight down -- always reachable through solid, walkable material. - // - // The old search had a floor of `Math.floor(bot.entity.position.y) - 2`. That - // floor MOVED with the bot, so it permanently excluded the layer underneath: - // the bot could see the iron at y=48 and was forbidden from touching it. It - // then threw "use exploreUntil to descend", the model descended one step, and - // the floor descended by one with it. The bot could never arrive. - // - // Two changes fix that without letting the bot chase a seam to the world - // floor, which is what the original floor was protecting against: - // 1. the depth is ABSOLUTE (a fraction of the map, not of the bot), so it - // grants a real budget to dig down instead of a window that retreats; - // 2. when nothing is in range, the primitive descends toward the target - // layer itself instead of returning an error the model cannot act on. - const MAX_DIG_DEPTH = 28; - const searchFloorY = Math.max( - -63, - Math.floor(bot.entity.position.y) - MAX_DIG_DEPTH - ); - - const findTarget = (floorY) => 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 >= floorY, - maxDistance: 32, - }); - - // Walk a staircase the bot can always climb back out of. Mirrors the - // descent policy in exploreUntil: dig the block underfoot, settle, repeat. - // Never asks the pathfinder for a lower goal, which is what used to make it - // drive itself off a ledge. - const descendToward = async (targetY) => { - let steps = 0; - while (steps < MAX_DIG_DEPTH) { - const pos = bot.entity.position; - if (pos.y <= targetY) return true; - const under = bot.entity.blockAt(bot.entity.position.offset(0, -1, 0)); - if (!under) return false; - if (under.boundingBox !== "empty" && under.name !== "bedrock") { - try { - await bot.dig(under, true); - } catch (_) { - return false; - } - } - await bot.waitForTicks(3); - steps++; - } - return false; - }; - - // The bot can end up ABOVE the layer it is looking for -- it dug down - // looking for gold, passed through y=52-53 and stalled at y=51 inside the - // iron band, with the gold now 1-2 blocks over its head. descendToward() - // returns true immediately in that case (pos.y <= targetY is already true), - // so the caller searched again, found nothing new, and threw the same error - // on every cycle while the observation kept listing gold_ore as nearby. - // - // When the target layer is above the bot, mine the block overhead and step - // up, which is the mirror of the descent and works in a layered world where - // the column is solid all the way to the surface. - const ascendToward = async (targetY) => { - let steps = 0; - while (steps < MAX_DIG_DEPTH) { - const pos = bot.entity.position; - if (Math.floor(pos.y) >= targetY) return true; - const above = bot.entity.blockAt(bot.entity.position.offset(0, 2, 0)); - if (!above) return false; - if (above.boundingBox !== "empty" && above.name !== "bedrock") { - try { - // Stand still and dig straight up; the block falls into the - // space, then the bot steps up into it. - await bot.dig(above, true); - } catch (_) { - return false; - } - } - await bot.waitForTicks(3); - try { - await bot.pathfinder.goto(new GoalBlock( - Math.floor(pos.x), Math.floor(pos.y) + 1, Math.floor(pos.z) - )); - } catch (_) { - return false; - } - steps++; - } - return false; - }; - - // Reach a target layer that is either above or below the bot, then hand - // back a fresh search. Returns null when the layer cannot be reached, so - // the caller still reports a real "not here" instead of looping. - const approachLayer = async (targetY) => { - const botY = Math.floor(bot.entity.position.y); - if (targetY < botY) { - const reached = await descendToward(targetY); - return reached ? findTarget(searchFloorY) : null; - } - if (targetY > botY) { - const reached = await ascendToward(targetY); - return reached ? findTarget(searchFloorY) : null; - } - return null; // same level and still not found: genuinely elsewhere - }; - - while (mined < wanted) { - let block = findTarget(searchFloorY); - - if (!block) { - // Nothing inside the dig budget at this X/Z. If the target is ores - // this map stacks as a layer, walk to that layer; the layer is - // continuous, so a straight approach reaches it. Anything else means - // the block genuinely is not here and the model must travel. - const probe = 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, - maxDistance: 64, - }); - const knownOre = /(^|_)ore$/.test(name); - if (probe && knownOre) { - block = await approachLayer(probe.position.y); - } - } - - if (!block) { - bot.chat(`No ${name} nearby, please explore first`); - throw new Error(`No ${name} within 32 blocks; use exploreUntil to travel 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. - # - # History, because the fix looks like a reversal of the old one. - # The previous version forced dy = 0 (surface-only) to stop that blind - # diving. That was correct for the failure it addressed but it created a - # worse one: the Action prompt says the ground is a vertical resource map - # and that mining straight down is the normal way to reach the ores. The - # model obeys that line and asks to explore downward, the primitive then - # silently rewrites 'down' into a horizontal walk, and every underground - # ore task fails with "No within 32 blocks". Measured on the live - # bot: 8 of the last 12 failures were underground ores (iron, gold, - # lapis, redstone, copper, diamond, emerald, quartz), while the tasks - # that did complete were all surface-reachable (obsidian, diamond - # pickaxe, quartz block). - # - # So vertical exploration is allowed again, but not as upstream does it. - # The bot descends one controlled step at a time and always keeps a - # walkable column behind it, which is what upstream's raw GoalNear never - # guaranteed. The three constraints that made the old ban necessary are - # all preserved: - # 1. the callback is checked before any movement, so a target already - # inside findBlock's 32-block range returns immediately; - # 2. descent is depth-capped (MAX_DESCENT) and never free-falls -- the - # bot mines each step, so `maxDropDown` is never exercised; - # 3. the bot never descends without a path back up, because each mined - # step leaves a climbable column at the bot's own X/Z. - 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); - } - - // A model may phrase a vertical request three ways: a signed y in the - // vector, one of the compass words below, or a purely horizontal vector - // while the target is actually underground. Normalise the word form first, - // because a string has no .x/.y/.z and would fail the vector check above. - let dx = 0; - let dy = 0; - let dz = 0; - if (typeof direction === "string") { - const word = direction.trim().toLowerCase(); - if (["down", "downward", "below", "under", "underground"].includes(word)) { dy = -1; } - else if (["up", "upward", "above", "over"].includes(word)) { dy = 1; } - else if (["north"].includes(word)) { dz = -1; } - else if (["south"].includes(word)) { dz = 1; } - else if (["east"].includes(word)) { dx = 1; } - else if (["west"].includes(word)) { dx = -1; } - else { - throw new Error( - `direction must be a Vec3 or one of down/up/north/south/east/west, got ${direction}` - ); - } - } else { - if (![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 this primitive accepts. - dx = Math.sign(Number(direction.x)); - dy = Math.sign(Number(direction.y)); - 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. Upstream's raw Vec3(0,-1,0) made GoalNear pick a - // point 10-29 blocks below the bot, which the pathfinder answered by - // driving it off a ledge. A vertical request is therefore converted into a - // controlled descent: the bot mines the block under itself, drops one - // block, and repeats. It never falls, and the column it mines stays - // walkable, so the surface is always reachable again. - const creative = Boolean(bot.game && bot.game.gameMode === "creative"); - const descending = creative && dy < 0 && dx === 0 && dz === 0; - if (creative && dy < 0 && (dx !== 0 || dz !== 0)) { - // A diagonal downward vector is a surface walk that happens to be near - // a slope; treat it as horizontal so the pathfinder stays in control. - dy = 0; - } - if (creative && dy > 0) dy = 0; - // A purely horizontal request with no direction is meaningless; pick one so - // the callback loop still makes progress instead of standing still. - if (creative && dx === 0 && dy === 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)]; - } - - maxTime = Math.min(maxTime, 120); - // Depth budget for one call. The ores sit within about 24 blocks of the - // surface, so 32 leaves margin; a deeper budget would let one call dig a - // shaft the bot cannot see the end of. - const MAX_DESCENT = 32; - const start = {x: bot.entity.position.x, z: bot.entity.position.z}; - const startY = bot.entity.position.y; - - return new Promise((resolve, reject) => { - let explorationInterval; - let maxTimeTimeout; - const cleanUp = () => { - clearInterval(explorationInterval); - clearTimeout(maxTimeTimeout); - try { bot.pathfinder.setGoal(null); } catch (_) {} - }; - const finish = (value) => { cleanUp(); resolve(value); }; - - const descendingExplore = () => { - // Dig one controlled step down, then settle onto the freshly - // exposed floor. Digging the block underfoot -- rather than asking - // the pathfinder for a lower GoalNear -- is what makes this a - // walkable staircase instead of a shaft: the bot stays in its own - // X/Z column, so every layer it mines is a cell it can climb back - // through. That is the property upstream's raw Vec3(0,-1,0) never - // had, and the reason the old version banned descent outright. - const at = (dy) => bot.entity.blockAt(bot.entity.position.offset(0, dy, 0)); - const floor = at(-1); - if (!floor) { finish(null); return; } - - const stepDown = () => { - const under = at(-1); - if (!under || under.boundingBox === "empty") { - // Nothing left to dig here: gravity may still be settling - // the bot onto the floor it just uncovered. - return bot.waitForTicks(4).then(descendingExplore).catch(() => finish(null)); - } - return bot.dig(under, true) - .then(() => bot.waitForTicks(4)) - .then(descendingExplore) - .catch(() => finish(null)); - }; - - const headroom = at(0); - if (headroom && headroom.boundingBox !== "empty") { - // The bot's own cell is solid -- it would suffocate before it - // could dig downward. Clear it first. - return bot.dig(headroom, true) - .then(stepDown) - .catch(() => finish(null)); - } - return stepDown(); - }; - - const explore = () => { - try { - const result = callback(); - if (result) { finish(result); return; } - - if (descending) { - if (Math.abs(startY - bot.entity.position.y) >= MAX_DESCENT) { - finish(null); - return; - } - descendingExplore(); - return; - } - - let distance = Math.floor(Math.random() * 12 + 8); - if (creative) { - const travelled = Math.hypot( - bot.entity.position.x - start.x, bot.entity.position.z - start.z - ); - const remaining = 32 - travelled; - if (remaining <= 1) { - finish(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); - } - }; - // A descending step takes as long as a dig, not a walk, so the tick is - // slower than the horizontal one; polling faster would stack digs. - explorationInterval = setInterval(explore, descending ? 600 : 1500); - maxTimeTimeout = setTimeout(() => { finish(null); }, maxTime * 1000); - }); -} -''', encoding="utf-8") - - explore_context = SRC / "voyager" / "control_primitives_context" / "exploreUntil.js" - explore_context.write_text(r'''/* -Exploration has two modes in this Creative deployment. Choose by where the -target actually is, then read the block names in Nearby blocks to decide. - -HORIZONTAL -- the target is a surface block (logs, grass, sand, a biome). -Use y=0 and a sign vector. This is the default. - -VERTICAL -- the target is an ore or any block that lives underground -(coal_ore, copper_ore, iron_ore, gold_ore, redstone_ore, lapis_ore, -diamond_ore, emerald_ore, deepslate_bricks, obsidian). The ground here is a -vertical resource map, so descending is the correct move, not a last resort. -Pass y=-1 (or the string "down"); the primitive mines a walkable staircase -down one block at a time and stops at 32 blocks. It never drops the bot into a -hole it cannot leave. - -If the desired block is already listed in Nearby blocks, DO NOT explore at all: -call mineBlock directly, because it already searches within 32 blocks. - -Example, ore underground: -const target = await exploreUntil(bot, new Vec3(0, -1, 0), 60, () => - bot.findBlock({ matching: mcData.blocksByName["diamond_ore"].id, maxDistance: 32 }) -); - -Example, surface block: -const log = 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 for a surface walk, or y=-1 to descend for an ore - 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 has two modes; pick by where the target actually is. For a SURFACE block (logs, grass, sand, a biome) use `exploreUntil` with y=0. For an ORE or anything underground (coal_ore, copper_ore, iron_ore, gold_ore, redstone_ore, lapis_ore, diamond_ore, emerald_ore, obsidian, deepslate) descend: pass `new Vec3(0, -1, 0)` or the string `"down"`. The primitive mines a walkable staircase down to a 32-block depth and never drops the bot into a hole it cannot climb out of, so descending is safe and expected here. -- The ground is a flat LAYERED resource map, and every ore is a continuous horizontal layer at a FIXED height. The bot stands on grass at y=63. Going down: gravel at y=59, then diamond_ore y=56, lapis_ore y=57, emerald_ore y=58, redstone_ore y=54-55, gold_ore y=52-53, iron_ore y=48-51, copper_ore y=44-47, coal_ore y=40-43. Deeper still, below 81 layers of stone, the same ores repeat as deepslate_* variants from y=-42 up to y=-57. - Because each ore is a full layer, an ore you can see is directly below you and the search range already reaches it: call `mineBlock` and it digs the shaft itself. Do NOT explore horizontally for an ore -- walking never changes your y, so a horizontal walk cannot reach a layer that is 4 to 23 blocks straight down. -- When an ore is below the bot, `mineBlock` descends to it on its own, digging a staircase it can always climb back out of. You do not need to call `exploreUntil` for an ore that is within range; only use `exploreUntil` with a downward direction when no ore of that kind is in range at all. -- 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") - - # SkillManager.__init__ built Chroma() with no deadline at all, and - # Chroma embeds through OpenAIEmbeddings(), which in langchain 0.0.350 - # calls openai==0.28.1 with no request timeout. A single stalled - # /v1/embeddings response therefore parked the constructor forever: - # the log stopped at "Loading Skill Manager" and the loop never reached - # learn(), so nothing failed, nothing retried and no counter moved. - # The stall watchdog then fired every 900s and looked like the cause - # instead of the symptom. - # - # The same constructor ended with a hard assert. A vectordb that is - # one document out of sync is recoverable -- the skills.json on disk is - # the source of truth -- but the assert turned that into a dead process. - # Rebuild the index from skills.json instead, which is what the comment - # in the failure message already told the operator to do by hand. - skill_path = SRC / "voyager" / "agents" / "skill.py" - skill_text = skill_path.read_text(encoding="utf-8") - old_vectordb = ''' self.vectordb = Chroma( - collection_name="skill_vectordb", - embedding_function=OpenAIEmbeddings(), - persist_directory=f"{ckpt_dir}/skill/vectordb", - )''' - new_vectordb = ''' self.vectordb = self._open_vectordb(ckpt_dir)''' - if old_vectordb in skill_text: - skill_text = skill_text.replace(old_vectordb, new_vectordb, 1) - elif new_vectordb.strip() not in skill_text: - # The anchor is gone and the replacement is not already in place: - # the file changed underneath this patch and the unbounded - # constructor would survive. Refuse rather than boot a tree that - # can still hang. - raise RuntimeError("SkillManager patch anchor missing; refusing unbounded-Chroma patch") - - old_assert = ''' assert self.vectordb._collection.count() == len(self.skills), ( - f"Skill Manager's vectordb is not synced with skills.json.\\n" - f"There are {self.vectordb._collection.count()} skills in vectordb but {len(self.skills)} skills in skills.json.\\n" - f"Did you set resume=False when initializing the manager?\\n" - f"You may need to manually delete the vectordb directory for running from scratch." - )''' - new_resync = ''' self._resync_vectordb()''' - if old_assert in skill_text: - skill_text = skill_text.replace(old_assert, new_resync, 1) - elif "self._resync_vectordb()" not in skill_text: - raise RuntimeError("SkillManager sync-assert anchor missing; refusing patch") - - skill_helpers = ''' # Every embedding this class makes goes through here, so one stalled - # response cannot freeze the constructor, add_new_skill or retrieve_skills. - EMBED_DEADLINE_SECONDS = 60 - VECTORDB_OPEN_SECONDS = 180 - - def _embedding_function(self): - """OpenAIEmbeddings with an explicit timeout. - - langchain 0.0.350 builds the openai 0.28.1 client, whose - Embedding.create has no default request timeout. The base URL is the - remote brain Space, so a slow or wedged response is a real possibility - and must surface as an exception the runner can retry, not as a hang. - - request_timeout is a declared pydantic field in this version, so pass it - to the constructor. Some versions renamed it to timeout, and a pydantic - model raises on an unknown keyword, so fall back rather than crash the - constructor over an observability nicety. - """ - try: - return OpenAIEmbeddings(request_timeout=self.EMBED_DEADLINE_SECONDS) - except Exception: - try: - return OpenAIEmbeddings(timeout=self.EMBED_DEADLINE_SECONDS) - except Exception: - return OpenAIEmbeddings() - - def _with_deadline(self, fn, label, seconds): - """Run `fn()` on a daemon thread; raise if it outlives `seconds`. - - Generic form of _open_with_deadline. Never joins on timeout: a join is - the bug this exists to avoid. See _open_with_deadline for the measured - reproduction. - """ - import threading as _th - - box = {} - - def _run(): - try: - box["value"] = fn() - except BaseException as exc: # noqa: BLE001 - reported, not raised - box["error"] = f"{type(exc).__name__}: {exc}" - - worker = _th.Thread(target=_run, name=f"deadline-{label}", daemon=True) - worker.start() - worker.join(seconds) - if worker.is_alive(): - raise RuntimeError(f"{label} did not return within {seconds}s") - if "error" in box: - raise RuntimeError(f"{label} failed: {box['error']}") - return box["value"] - - def _open_with_deadline(self, opener, label, seconds): - """Run `opener()` on a daemon thread and give up after `seconds`. - - Returns the opened object, or raises RuntimeError naming `label`. - - Do NOT rewrite this as `with ThreadPoolExecutor(...)`: that context - manager calls shutdown(wait=True) on exit, which joins the worker -- - the same call that is stalled. The deadline would fire and the join - would block forever right behind it, which is how this Space hung for - 343s with a 180s deadline already in place. A daemon thread is - abandoned on timeout instead: it cannot keep the interpreter alive and - nothing waits on it. - - Raising is deliberate rather than degrading to a broken index: the - runner supervisor restarts the process with backoff, so a genuinely - unreachable embedding endpoint becomes a retry instead of a silent - half-working loop. - """ - import threading as _th - - box = {} - - def _run(): - try: - box["value"] = opener() - except BaseException as exc: # noqa: BLE001 - reported, not raised - box["error"] = f"{type(exc).__name__}: {exc}" - - worker = _th.Thread(target=_run, name=f"open-{label}", daemon=True) - worker.start() - worker.join(seconds) - if worker.is_alive(): - raise RuntimeError( - f"{label} did not open within {seconds}s; the embedding " - "endpoint is not answering" - ) - if "error" in box: - raise RuntimeError(f"{label} failed to open: {box['error']}") - return box["value"] - - def _open_vectordb(self, ckpt_dir): - """Construct Chroma under a wall-clock deadline. - - Chroma can touch the embedding function while opening (and will, when - it has to write a missing index). Running the open in a worker gives - the constructor a deadline it did not have; the caller follows up with - _resync_vectordb, which repairs any index that is out of date. - """ - def _open(): - return Chroma( - collection_name="skill_vectordb", - embedding_function=self._embedding_function(), - persist_directory=f"{ckpt_dir}/skill/vectordb", - ) - - return self._open_with_deadline( - _open, "skill vectordb", self.VECTORDB_OPEN_SECONDS - ) - - def _resync_vectordb(self): - """Make the index match skills.json, which is the source of truth. - - Replaces a hard assert that killed the process when the two counts - disagreed by one document. Rebuilding is safe: every skill is present - in skills.json with its code and description. - """ - expected = set(self.skills) - try: - stored = set(self.vectordb._collection.get()["ids"]) - except Exception: - stored = set() - for stale in stored - expected: - try: - self.vectordb._collection.delete(ids=[stale]) - except Exception: - pass - missing = expected - stored - if missing: - print( - f"\\033[33mSkill Manager resyncing {len(missing)} skill(s) into the vectordb\\033[0m", - flush=True, - ) - for name in sorted(missing): - entry = self.skills.get(name) or {} - try: - self._with_deadline( - lambda: self.vectordb.add_texts( - texts=[f"Skill: {name}\\n{entry.get('description', '')}"], - metadatas=[{"name": name}], - ids=[name], - ), - f"index skill {name}", - self.EMBED_DEADLINE_SECONDS, - ) - except Exception as exc: - print( - f"\\033[33mSkill Manager could not index {name}: " - f"{type(exc).__name__}: {exc}\\033[0m", - flush=True, - ) - try: - self.vectordb.persist() - except Exception: - pass - -''' - if "def _open_vectordb" not in skill_text: - marker_after = " @property\n def programs(self):" - if marker_after not in skill_text: - raise RuntimeError("SkillManager property anchor missing; refusing patch") - skill_text = skill_text.replace(marker_after, skill_helpers + marker_after, 1) - skill_path.write_text(skill_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. - # - # The publish must happen here, after the snapshot has copied the - # pristine file into both directories and after the declared adaptation - # has been applied -- and before the run_qa patch below, which is NOT a - # declared adaptation. Publishing later would fold an undeclared change - # into the reference and make the reference agree with the live file by - # construction, which is the opposite of an attestation. - curriculum_text = apply_curriculum_retry_adaptation(curriculum_text) - self._publish_expected_curriculum() - - # run_qa looked a question up in the qa_cache vectordb and then asserted - # that the retrieved text was a key in qa_cache.json: - # - # if docs_and_scores and docs_and_scores[0][1] < 0.05: - # question_cached = docs_and_scores[0][0].page_content - # assert question_cached in self.qa_cache # bare assert - # - # Vector store and JSON drift apart as soon as one of them is rebuilt or - # partially written, and then the search happily returns a question that - # no longer exists in the JSON. That raised AssertionError with an empty - # message, escaped learn() (the inner try only wraps rollout()) and - # reached the runner as "learning loop raised AssertionError: " on every - # cycle -- the "nothing progresses but nothing errors" signature. - # - # A cache miss is not a corruption: recompute the answer and move on. - # Also bound the lookup, because similarity_search embeds the question - # and that call had no deadline at all. - old_cache_hit = ''' if self.qa_cache_questions_vectordb._collection.count() > 0: - docs_and_scores = ( - self.qa_cache_questions_vectordb.similarity_search_with_score( - question, k=1 - ) - ) - if docs_and_scores and docs_and_scores[0][1] < 0.05: - question_cached = docs_and_scores[0][0].page_content - assert question_cached in self.qa_cache - answer_cached = self.qa_cache[question_cached] - questions.append(question_cached) - answers.append(answer_cached) - continue''' - new_cache_hit = ''' cached = self._cached_answer(question) - if cached is not None: - question_cached, answer_cached = cached - questions.append(question_cached) - answers.append(answer_cached) - continue''' - if old_cache_hit in curriculum_text: - curriculum_text = curriculum_text.replace(old_cache_hit, new_cache_hit, 1) - elif "self._cached_answer(question)" not in curriculum_text: - raise RuntimeError("Curriculum qa_cache anchor missing; refusing patch") - - # The second bare assert on the same path: a question already present in - # the cache used to abort the loop instead of being answered. - old_dedup = ''' answer = self.run_qa_step2_answer_questions(question=question) - assert question not in self.qa_cache - self.qa_cache[question] = answer''' - new_dedup = ''' answer = self.run_qa_step2_answer_questions(question=question) - self.qa_cache[question] = answer''' - if old_dedup in curriculum_text: - curriculum_text = curriculum_text.replace(old_dedup, new_dedup, 1) - elif new_dedup not in curriculum_text: - raise RuntimeError("Curriculum qa_cache dedup anchor missing; refusing patch") - - # The third one, in __init__, and the one that actually killed the boot. - # It has a message, so the empty-message heuristic did not find it: - # - # assert self.qa_cache_questions_vectordb._collection.count() == len( - # self.qa_cache - # ), ( - # f"Curriculum Agent's qa cache question vectordb is not synced with " - # f"qa_cache.json.\n There are ... questions in vectordb but ... - # questions in qa_cache.json.\n" - # f"Did you set resume=False when initializing the agent?\n" - # f"You may need to manually delete the qa cache question vectordb " - # f"directory for running from scratch.\n" - # ) - # - # Measured live: 58 vectors against 55 JSON entries → AssertionError, - # rc=1, and the runner restart-looped (7 restarts, zero model calls). - # This is the same drift as the run_qa assert, one layer earlier: the - # JSON is the source of truth and the index is a cache, so the right - # move is to reconcile them, not to refuse to boot. The upstream message - # even names the assertion as the problem by telling the operator to - # delete state by hand -- a manual step that a resync does for them. - old_init_assert = ''' assert self.qa_cache_questions_vectordb._collection.count() == len( - self.qa_cache - ), ( - f"Curriculum Agent's qa cache question vectordb is not synced with qa_cache.json.\\n" - f"There are {self.qa_cache_questions_vectordb._collection.count()} questions in vectordb " - f"but {len(self.qa_cache)} questions in qa_cache.json.\\n" - f"Did you set resume=False when initializing the agent?\\n" - f"You may need to manually delete the qa cache question vectordb directory for running from scratch.\\n" - )''' - new_init_resync = ''' self._resync_qa_cache()''' - if old_init_assert in curriculum_text: - curriculum_text = curriculum_text.replace(old_init_assert, new_init_resync, 1) - elif new_init_resync.strip() not in curriculum_text: - raise RuntimeError( - "Curriculum qa_cache __init__ assert anchor missing; refusing patch" - ) - - # Same unbounded constructor as SkillManager, same file, a few lines - # above the assert: this Chroma call embeds through OpenAIEmbeddings(), - # whose openai 0.28.1 client has no request timeout. Left alone it is a - # second way for this Space to hang before learn() is ever reached. - old_qa_vectordb = ''' self.qa_cache_questions_vectordb = Chroma( - collection_name="qa_cache_questions_vectordb", - embedding_function=OpenAIEmbeddings(), - persist_directory=f"{ckpt_dir}/curriculum/vectordb", - )''' - new_qa_vectordb = ''' self.qa_cache_questions_vectordb = self._open_qa_vectordb(ckpt_dir)''' - if old_qa_vectordb in curriculum_text: - curriculum_text = curriculum_text.replace(old_qa_vectordb, new_qa_vectordb, 1) - elif new_qa_vectordb.strip() not in curriculum_text: - raise RuntimeError( - "Curriculum qa_cache Chroma anchor missing; refusing unbounded-Chroma patch" - ) - - qa_helpers = ''' # Bounded, self-healing QA cache lookup. See the patch note in - # voyager_stack._patch_runtime_source for why this replaced an assert. - QA_LOOKUP_SECONDS = 45 - VECTORDB_OPEN_SECONDS = 180 - - def _embedding_function(self): - """OpenAIEmbeddings with an explicit timeout. - - Same problem as SkillManager: langchain 0.0.350 wraps the openai 0.28.1 - client, whose Embedding.create has no default request timeout, and the - base URL is the remote brain Space. - """ - try: - return OpenAIEmbeddings(request_timeout=self.QA_LOOKUP_SECONDS) - except Exception: - try: - return OpenAIEmbeddings(timeout=self.QA_LOOKUP_SECONDS) - except Exception: - return OpenAIEmbeddings() - - def _with_deadline(self, fn, label, seconds): - """Run `fn()` on a daemon thread; raise if it outlives `seconds`. - - Generic form of _open_with_deadline. Never joins on timeout: a join is - the bug this exists to avoid. See _open_with_deadline for the measured - reproduction. - """ - import threading as _th - - box = {} - - def _run(): - try: - box["value"] = fn() - except BaseException as exc: # noqa: BLE001 - reported, not raised - box["error"] = f"{type(exc).__name__}: {exc}" - - worker = _th.Thread(target=_run, name=f"deadline-{label}", daemon=True) - worker.start() - worker.join(seconds) - if worker.is_alive(): - raise RuntimeError(f"{label} did not return within {seconds}s") - if "error" in box: - raise RuntimeError(f"{label} failed: {box['error']}") - return box["value"] - - def _open_with_deadline(self, opener, label, seconds): - """Run `opener()` on a daemon thread and give up after `seconds`. - - Returns the opened object, or raises RuntimeError naming `label`. - - Do NOT rewrite this as `with ThreadPoolExecutor(...)`: that context - manager calls shutdown(wait=True) on exit, which joins the worker -- - the same call that is stalled. The deadline would fire and the join - would block forever right behind it, which is how this Space hung for - 343s with a 180s deadline already in place. A daemon thread is - abandoned on timeout instead: it cannot keep the interpreter alive and - nothing waits on it. - - Raising is deliberate rather than degrading to a broken index: the - runner supervisor restarts the process with backoff, so a genuinely - unreachable embedding endpoint becomes a retry instead of a silent - half-working loop. - """ - import threading as _th - - box = {} - - def _run(): - try: - box["value"] = opener() - except BaseException as exc: # noqa: BLE001 - reported, not raised - box["error"] = f"{type(exc).__name__}: {exc}" - - worker = _th.Thread(target=_run, name=f"open-{label}", daemon=True) - worker.start() - worker.join(seconds) - if worker.is_alive(): - raise RuntimeError( - f"{label} did not open within {seconds}s; the embedding " - "endpoint is not answering" - ) - if "error" in box: - raise RuntimeError(f"{label} failed to open: {box['error']}") - return box["value"] - - def _open_qa_vectordb(self, ckpt_dir): - """Open the qa_cache Chroma under a wall-clock deadline. - - Identical reasoning to SkillManager._open_vectordb: Chroma embeds during - construction, that embedding goes through a client with no default - timeout, and the endpoint is remote. Without a deadline a stalled - response parks __init__ forever and learn() is never reached. - """ - def _open(): - return Chroma( - collection_name="qa_cache_questions_vectordb", - embedding_function=self._embedding_function(), - persist_directory=f"{ckpt_dir}/curriculum/vectordb", - ) - - return self._open_with_deadline( - _open, "qa_cache vectordb", self.VECTORDB_OPEN_SECONDS - ) - - def _resync_qa_cache(self): - """Make the qa_cache vector index agree with qa_cache.json. - - Replaces the upstream assertion that refused to boot when the two - disagreed. qa_cache.json is the source of truth -- it is what the agent - actually reads answers from -- and the vector index is a cache that can - drift by one or a few entries whenever a write is interrupted. Refusing - to start over a cache that can be rebuilt from the truth is the failure - mode this codebase keeps re-learning: the assert turned a recoverable - mismatch into a dead process, and the process restarted in a loop - instead of learning. - """ - try: - stored = set(self.qa_cache_questions_vectordb._collection.get()["ids"]) - except Exception as exc: - print( - f"\\033[33mCurriculum qa cache: could not read the index " - f"({type(exc).__name__}: {exc}); leaving it alone\\033[0m", - flush=True, - ) - return - expected = set(self.qa_cache) - stale = stored - expected - missing = expected - stored - if not stale and not missing: - return - print( - f"\\033[33mCurriculum qa cache drifted: {len(stale)} stale vector(s), " - f"{len(missing)} missing. Reconciling against qa_cache.json.\\033[0m", - flush=True, - ) - for stale_id in sorted(stale): - try: - self.qa_cache_questions_vectordb._collection.delete(ids=[stale_id]) - except Exception: - pass - for question in sorted(missing): - try: - self.qa_cache_questions_vectordb.add_texts( - texts=[question], - metadatas=[{"id": question}], - ids=[question], - ) - except Exception as exc: - print( - f"\\033[33mCurriculum qa cache: could not index a question " - f"({type(exc).__name__}: {exc})\\033[0m", - flush=True, - ) - break - try: - self.qa_cache_questions_vectordb.persist() - except Exception: - pass - - def _cached_answer(self, question): - """Return (cached_question, answer) or None. - - A vector hit whose text is absent from qa_cache.json is treated as a - miss, not as a fatal inconsistency: the JSON is the source of truth and - the stale vector is dropped so it stops shadowing the lookup. - """ - try: - if self.qa_cache_questions_vectordb._collection.count() <= 0: - return None - except Exception: - return None - - try: - docs_and_scores = self._with_deadline( - lambda: self.qa_cache_questions_vectordb.similarity_search_with_score( - question, 1 - ), - "qa cache lookup", - self.QA_LOOKUP_SECONDS, - ) - except Exception as exc: - print( - f"\\033[33mCurriculum qa cache lookup skipped " - f"({type(exc).__name__}: {exc})\\033[0m", - flush=True, - ) - return None - - if not docs_and_scores: - return None - document, score = docs_and_scores[0] - if score >= 0.05: - return None - cached_question = document.page_content - if cached_question not in self.qa_cache: - # Stale vector. Drop it so the next lookup does not keep finding it. - print( - "\\033[33mCurriculum qa cache: dropping a stale vector not present " - f"in qa_cache.json: {cached_question[:80]!r}\\033[0m", - flush=True, - ) - try: - stale_id = str(document.metadata.get("id") or "") - if stale_id: - self.qa_cache_questions_vectordb._collection.delete(ids=[stale_id]) - except Exception: - pass - return None - return cached_question, self.qa_cache[cached_question] - -''' - if "def _cached_answer" not in curriculum_text: - anchor = " def run_qa(self, *, events, chest_observation):" - if anchor not in curriculum_text: - raise RuntimeError("Curriculum run_qa anchor missing; refusing patch") - curriculum_text = curriculum_text.replace(anchor, qa_helpers + anchor, 1) - # 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 a flat world 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" - "- The world is a resource map: grass on top, dirt, gravel, then a band " - "of every ore from coal and copper up to diamond and emerald, all within " - "about 24 blocks of the surface, so mining and obtaining tasks are always " - "reachable on foot. The ores are underground, not on the surface: the bot " - "reaches them by descending, which its exploreUntil primitive supports.\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. - bridge_path = SRC / "voyager/env/bridge.py" + 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") + # 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-only here: use `exploreUntil` with y=0. `Vec3(0,-1,0)` and `Vec3(0,1,0)` will not find a target in this world. +- The ground is a flat resource map: grass on top, three layers of dirt, then gravel and a band of every ore from coal and copper up to diamond and emerald within about 24 blocks down. Mining straight down from the surface is the normal way to reach them. +- 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 a flat world 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" + "- The world is a resource map: grass on top, dirt, gravel, then a band " + "of every ore from coal and copper up to diamond and emerald, all within " + "about 24 blocks of the surface, so mining and obtaining tasks are always " + "reachable on foot.\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. + bridge_path = SRC / "voyager/env/bridge.py" bridge_text = bridge_path.read_text(encoding="utf-8") - bridge_text = bridge_text.replace( - 'json=self.reset_options,\n timeout=self.request_timeout,', - 'json=self.reset_options,\n timeout=min(self.request_timeout, 135),', 1, + for endpoint in ("pause", "stop"): + bridge_text = bridge_text.replace( + f'requests.post(f"{{self.server}}/{endpoint}")', + f'requests.post(f"{{self.server}}/{endpoint}", timeout=10)', + ) + 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, ) - for endpoint in ("pause", "stop"): - bridge_text = bridge_text.replace( - f'requests.post(f"{{self.server}}/{endpoint}")', - f'requests.post(f"{{self.server}}/{endpoint}", timeout=10)', - ) - bridge_path.write_text(bridge_text, encoding="utf-8") - - # The same class of bug, one layer down: SubprocessMonitor waits for the - # Mineflayer node process with no timeout, in both directions. - # - # run(): self.ready_event.wait() -- blocks forever if node never - # prints its ready line (e.g. it is alive but wedged) - # stop(): self.process.wait() -- blocks forever if node ignores - # SIGTERM (a wedged node with a stuck event loop never exits) - # - # bridge.reset() calls mineflayer.stop() on its recovery path, so a - # wedged node turns the recovery itself into the hang: no exception is - # raised, nothing is logged, and learn() never returns. Because the - # runner retries learn() forever (VOYAGER_LEARN_MAX_ATTEMPTS=0), the - # watchdog never observes an exit either. Measured: the bot sat in this - # state for 45+ minutes with every error counter at zero and zero model - # calls. Bound both waits and escalate to SIGKILL so the failure becomes - # an exception the runner can act on. - monitor_path = SRC / "voyager/env/process_monitor.py" - monitor_text = monitor_path.read_text(encoding="utf-8") - old_monitor_run = " self.thread.start()\n self.ready_event.wait()\n" - new_monitor_run = ( - " self.thread.start()\n" - " # A wedged node process never prints its ready line, so an\n" - " # unbounded wait here parks the whole learning loop. Fail\n" - " # loudly instead: check_process() can then restart it.\n" - " if not self.ready_event.wait(timeout=60):\n" - " raise RuntimeError(\n" - " f\"Subprocess {self.name} did not report ready within 60s\"\n" - " )\n" - ) - if old_monitor_run not in monitor_text: - raise RuntimeError("SubprocessMonitor.run changed; refusing unsafe timeout patch") - monitor_text = monitor_text.replace(old_monitor_run, new_monitor_run, 1) - old_monitor_stop = ( - " if self.process and self.process.is_running():\n" - " self.process.terminate()\n" - " self.process.wait()\n" - ) - new_monitor_stop = ( - " if self.process and self.process.is_running():\n" - " self.process.terminate()\n" - " try:\n" - " # SIGTERM first; a wedged event loop can ignore it.\n" - " self.process.wait(timeout=10)\n" - " except Exception:\n" - " self.logger.info(f\"Subprocess {self.name} ignored SIGTERM; killing\")\n" - " try:\n" - " self.process.kill()\n" - " self.process.wait(timeout=10)\n" - " except Exception:\n" - " self.logger.warning(\n" - " f\"Subprocess {self.name} did not exit after SIGKILL; continuing\"\n" - " )\n" - ) - if old_monitor_stop not in monitor_text: - raise RuntimeError("SubprocessMonitor.stop changed; refusing unsafe timeout patch") - monitor_text = monitor_text.replace(old_monitor_stop, new_monitor_stop, 1) - monitor_path.write_text(monitor_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 #\n # Bounded on purpose: a wedged bridge can accept the TCP\n # connection and then never answer, which used to park the\n # learning loop forever with no exception and no log. A\n # recovery that can hang is worse than no recovery at all,\n # so give it a deadline and fall through to the hard reset.\n self.last_events = self._bounded_step("", timeout=60)\n print("Recovered Voyager observation without reconnecting Mineflayer")\n except Exception as recovery_error:\n print(f"Live recovery failed ({type(recovery_error).__name__}: {recovery_error}); performing one hard Mineflayer reset")\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) - - # learn() and inference() both start with a bare self.env.step(""). If the - # bridge is wedged, that call never returns: the process stays alive, the - # watchdog sees no exit, and the loop makes zero model calls. Give every - # unconditional boot-step a deadline so a wedged bridge surfaces as an - # exception instead of a silent freeze. - old_boot_step = ''' def decompose_task(self, task): - if not self.last_events: - self.last_events = self.env.reset( -''' - new_boot_step = ''' def _with_deadline(self, fn, label, seconds): - """A real wall-clock limit on this engine's own bridge calls.""" - import queue - import threading - - result = queue.Queue(maxsize=1) - - def invoke(): - try: - result.put((True, fn())) - except BaseException as exc: - result.put((False, exc)) - - worker = threading.Thread(target=invoke, daemon=True, name="voyager-deadline") - worker.start() - worker.join(seconds) - if worker.is_alive(): - raise RuntimeError(f"{label} exceeded {seconds}s") - ok, value = result.get_nowait() - if not ok: - raise value - return value - - def _bounded_step(self, code, timeout=60): - """Run env.step() with a wall-clock deadline. - - The bridge's own request_timeout only covers the HTTP call it makes - itself; a node process that accepts the connection and then stops - answering leaves it blocked. Run the step in a worker thread and give - up waiting after `timeout` seconds so the caller can recover. - """ - return self._with_deadline( - lambda: self.env.step(code), - "mineflayer step", - timeout, - ) - - def decompose_task(self, task): - if not self.last_events: - self.last_events = self.env.reset( -''' - if old_boot_step not in voyager_src: - raise RuntimeError("Voyager decompose_task anchor changed; refusing unsafe patch") - voyager_src = voyager_src.replace(old_boot_step, new_boot_step, 1) - - # learn(): the unconditional boot step. - old_learn_step = " self.resume = True\n self.last_events = self.env.step(\"\")\n" - new_learn_step = ( - " self.resume = True\n" - " self.last_events = self._bounded_step(\"\", timeout=120)\n" - ) - if old_learn_step not in voyager_src: - raise RuntimeError("Voyager learn() boot step changed; refusing unsafe patch") - voyager_src = voyager_src.replace(old_learn_step, new_learn_step, 1) - - # inference(): the same boot step on the rollout path. - old_inference_step = " def inference(self, task=None, sub_goals=[], reset_mode=\"hard\", reset_env=True):\n" - i = voyager_src.find(old_inference_step) - if i == -1: - raise RuntimeError("Voyager inference anchor changed; refusing unsafe patch") - tail = voyager_src[i:] - if 'self.last_events = self.env.step("")' not in tail: - raise RuntimeError("Voyager inference boot step changed; refusing unsafe patch") - tail = tail.replace('self.last_events = self.env.step("")', 'self.last_events = self._bounded_step("", timeout=120)', 1) - voyager_src = voyager_src[:i] + tail - + # 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") - # Preserve the upstream task selection and all saved skills. Calling - # upstream inference(task=...) would clear completed/failed curriculum - # lists, so the permanent objective belongs in the model prompts. - goal = os.environ.get("MINECRAFT_GAME_GOAL", "").strip()[:1400] - if goal: - goal = goal.replace("{", "{{").replace("}", "}}") - directive = ( - "\n\nPERMANENT PLAYER OBJECTIVE:\n" + goal + "\n" - "This objective replaces open-ended discovery and resource novelty. " - "Goal-related portal construction and activation are allowed. " - "Choose concrete, immediately executable tasks that advance this goal " - "from the observed game state. Do not fill time with decoration, unrelated " - "item collection, or repeated failed tasks. Coordinate Naldo_Bot and " - "Gemini_Bot only using observed chat, inventories and positions. " - "Use existing equipment and the current game mode. Creative-assisted " - "progress must never be described as an empty-inventory Survival speedrun. " - "Do not invent structures, coordinates, model capabilities or a victory. " - "The dragon objective is complete only after server-confirmed dragon " - "death and entry through the return portal. Preserve the response format " - "required above; select the next task yourself.\n" + _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}" ) - for name in ("action_template.txt", "curriculum.txt"): - prompt = SRC / "voyager" / "prompts" / name - text = prompt.read_text(encoding="utf-8") - if name == "curriculum.txt": - # The original exploration objective and novelty rule - # compete with a speedrun goal even when one is appended. - text = text.replace( - "My ultimate goal is to discover as many diverse things as possible, accomplish as many diverse tasks as possible and become the best Minecraft player in the world.", - "My ultimate goal is: " + goal, - 1, - ) - text = text.replace( - "5) The next task should be novel and interesting. I should look for rare resources, upgrade my equipment and tools using better materials, and discover new things. I should not be doing the same thing over and over again.", - "5) The next task must advance my ultimate goal. Collect or upgrade only what is needed for the next progression step; use equipment already available and avoid unrelated rare-resource collection.", - 1, - ) - text = text.replace( - "All the placing, building, planting, and trading tasks should be avoided. Do not propose task starting with these keywords.", - "Goal-related portal construction and activation are allowed when their effects can be verified from server observations.", - 1, - ) - prompt.write_text(text + directive, encoding="utf-8") - _log("Permanent player objective applied to both bot prompts") + # 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}") - _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 _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() - # Last source-mutating step is done. Prove the tree differs from the - # pinned commit only where this deployment declares it, and do it - # before npm install adds generated files that would drown the - # measurement. - verify_divergence(SRC) - 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) - env = os.environ.copy() - env.update( - { - "VOYAGER_HOME": str(SRC), - "VOYAGER_ATTESTATION_FILE": str(ROOT / "engine-attestation.json"), - # O cérebro mora no Space `Erinaldordso/Mine`. A URL é - # pública porque os dois Spaces não compartilham rede. - "VOYAGER_OPENAI_BASE": BRAIN_BASE_URL, - "VOYAGER_BRAIN_PROFILE": BRAIN_PROFILE, - "VOYAGER_BRAIN_LOCK": "enforce" if BRAIN_LOCK_ENFORCED else "off", - "VOYAGER_API_KEY": "local-voyager", - "VOYAGER_MODEL": BRAIN_MODEL, - # Os papéis auxiliares (QA do curriculum e descrição de - # skill) continuam num modelo pequeno: são curtas e - # frequentes, e uma segunda cópia do cérebro só somaria - # latência. O que mudou é que ele também é servido pelo - # Mine, então nenhum peso de modelo fica neste Space. - "VOYAGER_AUX_MODEL": BRAIN_AUX_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(): - self.proc = subprocess.Popen( - [sys.executable, "-u", str(runner)], - cwd=str(SRC), - env=env, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, + 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) + 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") + + 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) + # 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, - start_new_session=(os.name == "posix"), - ) - threading.Thread(target=self._pump, args=(self.proc,), daemon=True, name="voyager-log").start() - time.sleep(5) - if self.proc.poll() is None: - with self.lock: - self.state = "running" - self.error = "" - _log(f"Voyager running pid={self.proc.pid} as {BOT_USERNAME}") - rc = self.proc.wait() - else: - rc = self.proc.returncode - stop_runner(self.proc) + ) + 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(): - break - 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 = min(backoff * 2, 120) - 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}") - - 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" - proc = self.proc - stop_runner(proc) - return self.status() - - def tail(self, lines: int = 160) -> str: - chunks: list[str] = [] - for path in (SETUP_LOG, LOG_PATH): - if path.exists(): - content = path.read_text(encoding="utf-8", errors="replace").splitlines() - chunks.extend(content[-lines:]) - return "\n".join(chunks[-lines:]) - - def probe_runner(self, seconds: int = 6) -> dict[str, Any]: - """Sample the runner child's CPU counters to tell wedged from busy. - - The live log freezes at "Loading Skill Manager" whether the child is - blocked on a syscall or merely slow, and the runner never exits in - either case, so `state=running` cannot distinguish them. utime/stime - can: a process that consumes no CPU at all across several seconds while - emitting no output is parked in a syscall. - - Read from /proc so this needs no cooperation from the child. - """ - proc = self.proc - result: dict[str, Any] = { - "pid": getattr(proc, "pid", None), - "alive": bool(proc is not None and proc.poll() is None), - "seconds": seconds, - } - if not result["alive"]: - result["returncode"] = getattr(proc, "returncode", None) - return result - - def _ticks() -> dict[str, int] | None: - try: - raw = Path(f"/proc/{proc.pid}/stat").read_text(encoding="utf-8") - except OSError: - return None - # Field 14 is utime, 15 is stime. The comm field is parenthesised - # and may contain spaces, so split after the LAST ')'. - tail_fields = raw.rsplit(")", 1)[-1].split() - return {"utime": int(tail_fields[11]), "stime": int(tail_fields[12])} - - first = _ticks() - time.sleep(seconds) - second = _ticks() - if first is None or second is None: - result["cpu"] = "unreadable" - return result - delta = (second["utime"] - first["utime"]) + (second["stime"] - first["stime"]) - result["cpu_ticks_delta"] = delta - result["cpu_percent"] = round(100.0 * delta / (os.sysconf("SC_CLK_TCK") * seconds), 2) - result["verdict"] = ( - "blocked on a syscall (no CPU consumed, no output written)" - if delta == 0 - else "running (CPU is being consumed)" - ) - try: - result["log_bytes"] = LOG_PATH.stat().st_size - except OSError: - result["log_bytes"] = None - return result - - 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() - try: - lines = LOG_PATH.read_text(encoding="utf-8", errors="replace").splitlines() - except OSError: - lines = [] - 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 - return { - "state": self.state, - "error": self.error or None, - "started_at": self.started_at, - "pid": proc.pid if proc and proc.poll() is None else None, - "running": bool(proc and proc.poll() is None), - "upstream_commit": UPSTREAM_COMMIT, - "engine_runtime": read_attestation(ROOT / "engine-attestation.json", proc.pid if proc and proc.poll() is None else None), - "minecraft": "127.0.0.1:25565", - "openai_base": BRAIN_BASE_URL, - "model": BRAIN_MODEL, - "aux_model": BRAIN_AUX_MODEL, - "goal": os.environ.get("MINECRAFT_GAME_GOAL") or None, - # Os papéis são todos servidos pelo mesmo par de modelos, mas - # Action/Curriculum/Critic vão para o cérebro e QA/SkillManager - # para o auxiliar. - "agent_routing": { - "action": BRAIN_MODEL, - "curriculum": BRAIN_MODEL, - "critic": BRAIN_MODEL, - "qa": BRAIN_AUX_MODEL, - "skill_manager": BRAIN_AUX_MODEL, - }, - "decision_brain": "remote-mine-space", - "brain_profile": BRAIN_PROFILE, - "brain_lock_enforced": BRAIN_LOCK_ENFORCED, - "brain_external": True, - "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": self.restart_count, - "last_exit": read_exit_report(ROOT, BOT_USERNAME), - "mineflayer": mineflayer, - "log_tail": self.tail(120), - } - - -VOYAGER = VoyagerStack() + 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": "local-7860-only", + "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()