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