from __future__ import annotations import os import json import logging import shutil import sys import threading import time from pathlib import Path VOYAGER_HOME = Path(os.environ.get("VOYAGER_HOME", "/tmp/voyager-runtime/Voyager")) if str(VOYAGER_HOME) not in sys.path: sys.path.insert(0, str(VOYAGER_HOME)) from voyager import Voyager # noqa: E402 from voyager_verification import ( quarantine_invalid_skills, quarantine_template_skills, task_spec, ) from voyager_creative_critic import VerifiedCreativeCritic from voyager_runtime_contract import attest_original_runtime, write_attestation from voyager_metrics import attach_role_metrics def env(name: str, default: str) -> str: return os.environ.get(name, default) def main() -> None: from voyager_learning import refresh_learning_state, start_learning_watcher # Surface Mineflayer's subprocess logs as well as Python tracebacks. logging.basicConfig(level=logging.INFO, format="[%(name)s] %(levelname)s %(message)s") model = env("VOYAGER_MODEL", "muse-spark-1.3-contributor-free") aux_model = env("VOYAGER_AUX_MODEL", "muse-spark-1.3-contributor-free") base_url = env("VOYAGER_OPENAI_BASE", "http://127.0.0.1:7864/v1") api_key = env("VOYAGER_API_KEY", "local-voyager") ckpt_dir = env("VOYAGER_CKPT_DIR", "/persist/minecraft-friends/voyager/ckpt-20261003-normal-v1") bot_username = env("MC_USERNAME", "Naldo_Bot") brain_profile = env("VOYAGER_BRAIN_PROFILE", "") if bot_username == "Naldo_Bot": try: from naldo_opencode import ( ALLOWED_MODELS as NALDO_ALLOWED_MODELS, BRAIN_PROFILE as NALDO_BRAIN_PROFILE, BRIDGE_BASE as NALDO_BRIDGE_BASE, is_allowed_model as _naldo_model_allowed, ) except Exception as exc: raise RuntimeError(f"Naldo OpenCode brain module unavailable: {exc}") from exc if ( brain_profile != NALDO_BRAIN_PROFILE or base_url != NALDO_BRIDGE_BASE or not _naldo_model_allowed(model) or not _naldo_model_allowed(aux_model) ): raise RuntimeError( f"Brain lock rejected {bot_username}: profile={brain_profile!r}, base={base_url!r}, " f"model={model!r}, aux={aux_model!r}; Naldo requires profile={NALDO_BRAIN_PROFILE!r}, " f"base={NALDO_BRIDGE_BASE!r}, models in {sorted(NALDO_ALLOWED_MODELS)} (opencode provider only)" ) expected = None elif bot_username == "Gemini_Bot": expected = ("gemini-cli", "http://127.0.0.1:7863/v1", "gemini-3.5-flash-lite", "gemini-3.5-flash-lite") else: expected = None if expected and (brain_profile, base_url, model, aux_model) != expected: raise RuntimeError( f"Brain lock rejected {bot_username}: profile={brain_profile!r}, base={base_url!r}, " f"model={model!r}, aux={aux_model!r}" ) ckpt = Path(ckpt_dir) required_resume_defaults = { ckpt / "action" / "chest_memory.json": {}, ckpt / "curriculum" / "completed_tasks.json": [], ckpt / "curriculum" / "failed_tasks.json": [], ckpt / "curriculum" / "qa_cache.json": {}, ckpt / "skill" / "skills.json": {}, } core_state = [ ckpt / "curriculum" / "completed_tasks.json", ckpt / "curriculum" / "failed_tasks.json", ckpt / "skill" / "skills.json", ] has_persisted_state = any(path.exists() for path in core_state) if has_persisted_state: skills_json = ckpt / "skill" / "skills.json" vector_dir = ckpt / "skill" / "vectordb" if not skills_json.exists() and vector_dir.exists(): shutil.rmtree(vector_dir, ignore_errors=True) print("[voyager] removed orphaned derived skill vectordb", flush=True) for path, default in required_resume_defaults.items(): if not path.exists(): path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(default, indent=2) + "\n", encoding="utf-8") print(f"[voyager] repaired missing checkpoint file: {path.name}", flush=True) required_resume_files = list(required_resume_defaults) resume = all(path.exists() for path in required_resume_files) # Keep long-lived curriculum memory compact and consistent. Old retries can # leave the same task in failed_tasks many times, including tasks that were # later completed under a spelling variant such as copper ore/copper_ore. if resume: def _norm_task(value: str): return task_spec(value) or " ".join(str(value).lower().replace("_", " ").split()) completed_path = ckpt / "curriculum" / "completed_tasks.json" failed_path = ckpt / "curriculum" / "failed_tasks.json" try: original_completed = json.loads(completed_path.read_text(encoding="utf-8")) original_failed = json.loads(failed_path.read_text(encoding="utf-8")) archive = ckpt / "quarantine" / "curriculum_history_before_semantic_compaction_v1.json" if not archive.exists(): archive.parent.mkdir(parents=True, exist_ok=True) archive.write_text(json.dumps({"completed": original_completed, "failed": original_failed}, indent=2) + "\n", encoding="utf-8") def unique(values): seen, result = set(), [] for value in values: key = _norm_task(value) if key not in seen: seen.add(key) result.append(str(value)) return result completed = unique(original_completed) completed_norm = {_norm_task(item) for item in completed} failed = [item for item in unique(original_failed) if _norm_task(item) not in completed_norm] # One-time repair for failures recorded while the Creative helper # context file was missing. Those attempts never reached the model # or Minecraft action, so they are not genuine task failures. repair_marker = ckpt / ".creative_context_repair_v1" if not repair_marker.exists(): failed = [item for item in failed if _norm_task(item) != "mine 1 wood log"] repair_marker.write_text("ok\n", encoding="utf-8") print("[voyager] removed false starter-task failure from missing Creative context", flush=True) # Granite called placeBlocks("brick") / ("brick_block") for the # legitimate "Place 1 bricks" task. Both failed before changing # the world. Archive the failure before allowing one fresh attempt # with the exact task ID bound by the Action parser. bricks_marker = ckpt / ".bricks_block_id_repair_v1" if bot_username == "Naldo_Bot" and not bricks_marker.exists(): if "Place 1 bricks" in failed and "Place 1 bricks" not in completed: bricks_archive = ckpt / "quarantine" / "bricks_block_id_failure_v1.json" bricks_archive.parent.mkdir(parents=True, exist_ok=True) bricks_archive.write_text(json.dumps({"failed": original_failed}, indent=2) + "\n", encoding="utf-8") failed = [item for item in failed if item != "Place 1 bricks"] print("[voyager] archived invalid brick-ID failure for one retry", flush=True) bricks_marker.write_text("ok\n", encoding="utf-8") # The Babel bridge briefly rejected a Python len() call on its # JavaScript Proxy, so these two logged tasks never executed. # Keep the original failure records before allowing one retry. proxy_marker = ckpt / ".action_proxy_length_repair_v1" if not proxy_marker.exists(): retryable = { "Naldo_Bot": {"Place 2 red_concrete"}, "Gemini_Bot": {"Place 1 emerald_block"}, }.get(bot_username, set()) removed = [item for item in failed if item in retryable and item not in completed] if removed: proxy_archive = ckpt / "quarantine" / "action_proxy_length_failures_v1.json" proxy_archive.parent.mkdir(parents=True, exist_ok=True) proxy_archive.write_text(json.dumps({"failed": original_failed, "retryable": removed}, indent=2) + "\n", encoding="utf-8") failed = [item for item in failed if item not in removed] print(f"[voyager] archived Action Proxy parser failures: {', '.join(removed)}", flush=True) proxy_marker.write_text("ok\n", encoding="utf-8") for path, values, original in ((completed_path, completed, original_completed), (failed_path, failed, original_failed)): if values != original: temporary = path.with_suffix(".json.tmp") temporary.write_text(json.dumps(values, indent=2) + "\n", encoding="utf-8") temporary.replace(path) except Exception as exc: print(f"[voyager] checkpoint curriculum cleanup skipped: {exc}", flush=True) refresh_learning_state(ckpt, bot_username) start_learning_watcher(ckpt, bot_username) os.environ["OPENAI_API_BASE"] = base_url os.environ["OPENAI_API_KEY"] = api_key print(f"[voyager] upstream={VOYAGER_HOME}", flush=True) print(f"[voyager] openai_base={base_url}", flush=True) print(f"[voyager] minecraft=127.0.0.1:{env('MC_PORT', '25565')}", flush=True) print(f"[voyager] model={model}", flush=True) print(f"[voyager] auxiliary_roles_model={aux_model}", flush=True) print(f"[voyager] bot_username={bot_username}", flush=True) print(f"[voyager] brain_profile={brain_profile}", flush=True) print(f"[voyager] checkpoint={ckpt_dir} resume={resume}", flush=True) voyager = Voyager( mc_port=int(env("MC_PORT", "25565")), openai_api_key=api_key, server_port=int(env("MINEFLAYER_API_PORT", "3000")), env_wait_ticks=int(env("ENV_WAIT_TICKS", "20")), env_request_timeout=int(env("ENV_REQUEST_TIMEOUT", "600")), max_iterations=int(env("MAX_ITERATIONS", "160")), action_agent_model_name=model, action_agent_temperature=0, action_agent_task_max_retries=4, action_agent_show_chat_log=True, curriculum_agent_model_name=model, curriculum_agent_temperature=0, curriculum_agent_qa_model_name=aux_model, curriculum_agent_qa_temperature=0, critic_agent_model_name=model, critic_agent_temperature=0, skill_manager_model_name=aux_model, skill_manager_temperature=0, skill_manager_retrieval_top_k=5, openai_api_request_timeout=int(env("OPENAI_API_REQUEST_TIMEOUT", "240")), ckpt_dir=ckpt_dir, resume=resume, ) attestation = attest_original_runtime(voyager, VOYAGER_HOME) attach_role_metrics(voyager, bot_username) voyager.critic_agent = VerifiedCreativeCritic(voyager.critic_agent) write_attestation(env("VOYAGER_ATTESTATION_FILE", ""), attestation, bot_username) print( f"[voyager] verified original engine {attestation['upstream_commit']} for " f"{bot_username}; decision methods attested: " f"{', '.join(attestation['decision_methods_verified'])}; official Critic with " f"independent world evidence", flush=True, ) # Remove the known stale Creative-mode mining skill produced before the # mineBlock postcondition was fixed. In Creative, breaking copper ore does # not imply a copper_ingot drop, so that cached skill teaches a false check. stale = voyager.skill_manager.skills.get("mineCopperOre") stale_code = str(stale.get("code", "")) if isinstance(stale, dict) else "" if stale and ("copper_ingot" in stale_code or "raw_copper" in stale_code): try: voyager.skill_manager.vectordb._collection.delete(ids=["mineCopperOre"]) except Exception: pass voyager.skill_manager.skills.pop("mineCopperOre", None) skills_path = ckpt / "skill" / "skills.json" skills_path.write_text( json.dumps(voyager.skill_manager.skills, indent=2) + "\n", encoding="utf-8", ) for folder, suffix in (("code", "*.js"), ("description", "*.txt")): for path in (ckpt / "skill" / folder).glob("mineCopperOre" + suffix[1:]): try: path.unlink() except OSError: pass try: voyager.skill_manager.vectordb.persist() except Exception: pass print("[voyager] removed stale Creative mineCopperOre skill", flush=True) def _progress_fingerprint(directory: Path) -> str: # Process-alive is not progress: readiness stayed true while position # froze and no model calls advanced. Fingerprint the checkpoint that # only moves when tasks complete/fail plus the newest curriculum file # mtime, so a wedged bridge/Mineflayer is distinguishable from slow # inference. try: completed = (directory / "curriculum" / "completed_tasks.json").read_text(encoding="utf-8") except OSError: completed = "" try: failed = (directory / "curriculum" / "failed_tasks.json").read_text(encoding="utf-8") except OSError: failed = "" mtimes: list[str] = [] for relative in ("curriculum/completed_tasks.json", "curriculum/failed_tasks.json", "skill/skills.json"): try: mtimes.append(str((directory / relative).stat().st_mtime_ns)) except OSError: mtimes.append("-") return f"{len(completed)}:{len(failed)}:{'|'.join(mtimes)}" stall_seconds = int(float(env("VOYAGER_STALL_SECONDS", "1800"))) _stall_state: dict[str, object] = {"fingerprint": _progress_fingerprint(ckpt), "at": time.monotonic()} def _stall_watch() -> None: while True: time.sleep(60) try: current = _progress_fingerprint(ckpt) except Exception: continue now = time.monotonic() if current != _stall_state["fingerprint"]: _stall_state["fingerprint"] = current _stall_state["at"] = now continue if now - float(_stall_state["at"]) >= stall_seconds: # type: ignore[arg-type] print( f"[voyager] STALL WATCHDOG: no checkpoint progress for {stall_seconds}s; " "exiting so the stack watchdog can hard-reset Mineflayer and resume", flush=True, ) os._exit(75) threading.Thread(target=_stall_watch, daemon=True, name="voyager-stall-watch").start() # Supported upstream option: retain the live connection and observe every step. # The existing disconnect recovery and process watchdog still reconnect on failure. try: quarantine_invalid_skills(voyager, ckpt) quarantine_template_skills(voyager, ckpt) voyager.learn(reset_env=False) finally: # Upstream's non-daemon log-monitor thread keeps Python alive after a # fatal curriculum error. Stop only this runner's Mineflayer child so # the existing watchdog can observe exit and resume the checkpoint. process = voyager.env.mineflayer.process if process is not None and process.poll() is None: try: process.terminate() process.wait(timeout=10) except Exception: process.kill() process.wait(timeout=5) if __name__ == "__main__": main()