diff --git "a/voyager_stack.py" "b/voyager_stack.py" --- "a/voyager_stack.py" +++ "b/voyager_stack.py" @@ -1,2175 +1,2281 @@ -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, 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]\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. - # - # 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, - ) - 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 _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. - """ - import concurrent.futures as _cf - - def _open(): - return Chroma( - collection_name="skill_vectordb", - embedding_function=self._embedding_function(), - persist_directory=f"{ckpt_dir}/skill/vectordb", - ) - - with _cf.ThreadPoolExecutor(max_workers=1) as pool: - future = pool.submit(_open) - try: - return future.result(timeout=self.VECTORDB_OPEN_SECONDS) - except _cf.TimeoutError as exc: - raise RuntimeError( - "vector store did not open within " - f"{self.VECTORDB_OPEN_SECONDS}s; the embedding endpoint is " - "not answering" - ) from exc - - 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.vectordb.add_texts( - texts=[f"Skill: {name}\\n{entry.get('description', '')}"], - metadatas=[{"name": name}], - ids=[name], - ) - 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 _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. - """ - import concurrent.futures as _cf - - def _open(): - return Chroma( - collection_name="qa_cache_questions_vectordb", - embedding_function=self._embedding_function(), - persist_directory=f"{ckpt_dir}/curriculum/vectordb", - ) - - with _cf.ThreadPoolExecutor(max_workers=1) as pool: - future = pool.submit(_open) - try: - return future.result(timeout=self.VECTORDB_OPEN_SECONDS) - except _cf.TimeoutError as exc: - raise RuntimeError( - "qa_cache vector store did not open within " - f"{self.VECTORDB_OPEN_SECONDS}s; the embedding endpoint is " - "not answering" - ) from exc - - 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. - """ - import concurrent.futures as _cf - - try: - if self.qa_cache_questions_vectordb._collection.count() <= 0: - return None - except Exception: - return None - - try: - with _cf.ThreadPoolExecutor(max_workers=1) as pool: - future = pool.submit( - self.qa_cache_questions_vectordb.similarity_search_with_score, - question, - 1, - ) - docs_and_scores = future.result(timeout=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" - bridge_text = bridge_path.read_text(encoding="utf-8") - 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 _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. - """ - import concurrent.futures as _cf - - with _cf.ThreadPoolExecutor(max_workers=1) as pool: - future = pool.submit(self.env.step, code) - try: - return future.result(timeout=timeout) - except _cf.TimeoutError: - raise RuntimeError( - f"Mineflayer step did not answer within {timeout}s; " - "the bridge is wedged" - ) - - 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 - - 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 _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, - 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: - 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 - 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 - if proc and proc.poll() is None: - try: - proc.terminate() - proc.wait(timeout=20) - except Exception: - try: - proc.kill() - except Exception: - pass - 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 _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, - # 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() +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, 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]\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. + # + # 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, + ) + 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.vectordb.add_texts( + texts=[f"Skill: {name}\\n{entry.get('description', '')}"], + metadatas=[{"name": name}], + ids=[name], + ) + 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" + bridge_text = bridge_path.read_text(encoding="utf-8") + 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 _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 + + 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 _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, + 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: + 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 + 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 + if proc and proc.poll() is None: + try: + proc.terminate() + proc.wait(timeout=20) + except Exception: + try: + proc.kill() + except Exception: + pass + 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 _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, + # 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()