Minecraft-Friends-AI-ZeroGPU / voyager_runtime_contract.py
Erinaldordso's picture
Fix Gemini spam kicks and stalled Minecraft reconnects
68500e7 verified
Raw History Blame Contribute Delete
26.7 kB
"""Check that both bot processes run the pinned MineDojo Voyager engine."""
from __future__ import annotations
import inspect
import json
import os
import subprocess
import time
from pathlib import Path
UPSTREAM_COMMIT = "55e45a880755d0c8c66ca7fb5fe7962ac8974f89"
# Methods that decide the next task, the generated program, and the success
# verdict. A boot-time patch can rewrite these while leaving the class objects
# pointing at the upstream files, so class identity alone is not proof.
#
# Curriculum.propose_next_ai_task is compared against .upstream-expected/, which
# is the pristine upstream file plus the one declared recovery hunk recorded in
# ADAPTED_METHODS. Everything else must be byte-identical to upstream.
DECISION_METHODS = {
"curriculum": ("propose_next_ai_task", "get_task_context", "parse_ai_message"),
"action": ("process_ai_message",),
"critic": ("check_task_success",),
}
# (role, method) -> why the local file differs from pristine upstream.
ADAPTED_METHODS = {
("curriculum", "propose_next_ai_task"): (
"parse-failure retry feeds the rejected proposal back to the model; at "
"temperature 0 upstream re-sends an identical prompt and deterministically "
"reproduces the same rejected task"
),
}
# Local classes allowed to sit between the learning loop and a pinned upstream
# decision agent, with the attribute that holds the upstream delegate.
#
# The attestation resolves the delegate and attests that object, so the report
# describes the code that actually answers the decision call instead of an
# object that was replaced after the check. Nothing else may wrap a decision
# agent: an undeclared wrapper fails the boot.
DECLARED_WRAPPERS = {
"critic": {
"class_name": "VerifiedCreativeCritic",
"delegate_attribute": "upstream",
"reason": (
"two deny-only guards around the pinned upstream CriticAgent, whose verdict "
"is otherwise unchanged. (1) It returns False when the attempt ended in a "
"Mineflayer onError event, which upstream render_human_message cannot "
"evaluate (it asserts the last event is 'observe' and dereferences status "
"first). (2) It returns False when the model reported success but the live "
"world contradicts it on an objective with a countable postcondition; "
"mechanical_verdict() is three-valued and returns None whenever this "
"deployment cannot judge the objective, so the upstream Critic keeps the "
"verdict everywhere else. Measured need for (2): the Critic returned "
"success: true with the reasoning 'having a netherite_pickaxe in their "
"inventory means they have successfully mined gold, stone bricks, bricks and "
"polished andesite', and separately returned success: true while its own "
"evidence line read 'Live Inventory has 0/1 ...'. Those reached "
"completed_tasks.json. The wrapper can only deny a success, never create one."
),
},
}
REFERENCE_DIR = ".upstream-reference"
EXPECTED_DIR = ".upstream-expected"
MANIFEST_FILE = "MANIFEST.json"
DIVERGENCE_FILE = "DIVERGENCE.json"
# Every upstream path this deployment rewrites at boot, with the reason. The
# attestation attests five decision methods; those are not the whole difference
# between this tree and the pinned commit. Without this list, `checkout_verified`
# reads as "the tree is pristine", which it is not.
#
# The set below was measured by running the real boot patcher
# (_patch_runtime_source + _pin_node_versions) against a clean checkout of the
# pinned commit and hashing the tree before and after. verify_divergence()
# re-measures it at boot and fails if the real set is not exactly this one, so a
# future edit to the patcher cannot silently widen the divergence.
ADAPTED_UPSTREAM_FILES = {
"voyager/agents/action.py": (
"max_tokens=2048 on the Action LLM (upstream sets none, so app.py's 384-token "
"default truncated the official multi-function programs), creativeGiveItem in "
"base_skills, nullable status numerics, and 8k-context observation bounds"
),
"voyager/agents/critic.py": "nullable status numerics so a disconnect cannot raise",
"voyager/agents/skill.py": (
"SkillManager built Chroma() with no deadline, and that call embeds through "
"OpenAIEmbeddings(), which in langchain 0.0.350 uses the openai 0.28.1 client "
"with no request timeout. One stalled /v1/embeddings response parked the "
"constructor forever: the log stopped at 'Loading Skill Manager' and learn() "
"was never reached, so nothing failed and no counter moved. The open now runs "
"under a wall-clock deadline with a bounded embedding timeout. The closing "
"assert is replaced by a resync, because an index one document out of sync is "
"recoverable from skills.json and the assert turned that into a dead process"
),
"voyager/agents/curriculum.py": (
"the one declared decision adaptation (parse-failure retry feedback), nullable "
"status numerics, AIMessage import, and task_history() compaction of the prompt. "
"run_qa (not an attested decision method) no longer asserts that a qa_cache "
"vector hit is present in qa_cache.json: the two drift apart, and the bare "
"assert raised AssertionError with an empty message outside the try that wraps "
"rollout(), so learn() died every cycle with nothing in the error counters. A "
"hit whose text is missing from the JSON is now a miss, and the lookup is bounded. "
"__init__ no longer asserts that the qa_cache vector count equals the JSON "
"count either: measured live at 58 vectors against 55 entries, that assert "
"raised with rc=1 and the runner restart-looped with zero model calls, and it "
"is the same drift one layer earlier. The JSON is the source of truth and the "
"index is a cache, so __init__ now reconciles the two by dropping stale "
"vectors and indexing missing questions, which is the manual step the "
"upstream failure message asked the operator to perform. The qa_cache Chroma "
"call is also opened under a wall-clock deadline with a bounded embedding "
"client, for the same reason as SkillManager: it embeds during construction "
"through a client that has no default request timeout"
),
"voyager/control_primitives/exploreUntil.js": (
"two exploration modes chosen by the target: y=0 walks the surface, a purely "
"vertical downward vector mines a controlled staircase instead of asking the "
"pathfinder for a lower goal. The upstream surface-only veto (dy = 0) is gone "
"because it silently rewrote every underground ore request into a walk"
),
"voyager/control_primitives/mineBlock.js": (
"upstream collectBlock waits for a drop, which Creative never produces; the "
"Creative branch uses bot.dig and verifies the block actually changed. The "
"search floor is ABSOLUTE (MAX_DIG_DEPTH below the bot) instead of the old "
"relative -2, and the primitive digs a staircase toward a known ore layer "
"when nothing is in range. The relative floor moved down with the bot, so it "
"permanently excluded the layer underneath: iron at y=48 was visible and "
"rejected forever, and every ore task looped until the stall watchdog fired. "
"The approach is bidirectional: measured live, the bot dug past the gold "
"layer looking for gold and stalled at y=51 with gold at y=52-53 above it, "
"where the descent-only helper returned success without digging and the same "
"'No gold_ore within 32 blocks' error repeated with the bot motionless. An "
"ore layer can be above or below, so the primitive now mines overhead and "
"steps up as well as digging down"
),
"voyager/control_primitives/placeItem.js": (
"replaced by the local voyager_place.js: verified surface placement for Creative"
),
"voyager/control_primitives_context/exploreUntil.js": (
"documents the horizontal and vertical descent modes to the Action Agent"
),
"voyager/env/bridge.py": (
"10s timeout on the /pause and /stop posts so a disconnect cannot hang Python; "
"135s limit on /start, whose Node lifecycle has a 120s startup deadline"
),
"voyager/env/process_monitor.py": (
"bounded waits: stop() gives the node 10s after SIGTERM then escalates to "
"kill(), and run() gives up on the ready line after 60s. reset() calls "
"mineflayer.stop() on the recovery path, so an unbounded wait there parked "
"the whole learn loop with no exception and no log"
),
"voyager/env/mineflayer/index.js": (
"identity from env, pinned protocol 1.19, Creative toolkit at spawn, step "
"disconnect handling, NaN movement guard, /health route, /pause neutralised, "
"pathfinder bounds, inventory-quantity guard in evaluateCode, paced chat packets, "
"persistent Creative inventory restoration, and per-connection lifecycle guards"
),
"voyager/env/mineflayer/lib/observation/base.js": "exposes verified_actions and step_start_position for the advisory evidence",
"voyager/env/mineflayer/mineflayer-collectblock/package.json": "pinned to the 2023-compatible dependency tree",
"voyager/env/mineflayer/mineflayer-collectblock/src/CollectBlock.ts": "@ts-expect-error -> @ts-ignore so the pinned TypeScript build succeeds",
"voyager/env/mineflayer/package.json": "pinned to the 2023-compatible dependency tree",
"voyager/prompts/action_template.txt": (
"adds Creative ground truth facts, including the exact y of every ore layer "
"in the superflat map, so the Action Agent knows an ore is straight down "
"instead of exploring horizontally for it; the response contract is unchanged"
),
"voyager/prompts/curriculum.txt": "appends environment facts; the task format is unchanged",
"voyager/voyager.py": (
"records the last valid observation instead of an empty one on a parse failure; "
"recovers the connection in place instead of a hard Mineflayer reset; and runs "
"every boot step through _bounded_step so a wedged bridge raises instead of "
"parking the loop forever. _bounded_step itself delegates to _with_deadline, "
"which runs the call on a daemon thread and abandons it on timeout: the earlier "
"`with ThreadPoolExecutor(...)` form raised TimeoutError on schedule and then "
"blocked forever in its own shutdown(wait=True) join, so the deadline was "
"decorative and the runner sat 343s past a 180s limit. The engine owns its "
"own deadline method; helpers on CurriculumAgent/SkillManager cannot satisfy it"
),
}
ADDED_UPSTREAM_FILES = {
"voyager/env/mineflayer/lib/voyager_connection.js": (
"per-connection chat queue, disconnect/startup deadlines, truthful readiness "
"and Creative recovery that preserves existing stacks and equipment"
),
"minecraft_ids_1_19.json": "minecraft-data@3.31.0/pc/1.19 registry used to reject non-existent block names",
"voyager/control_primitives/creativeGiveItem.js": (
"Creative-only helper so an Obtain task has a path at all; upstream has no "
"equivalent because upstream assumes Survival drops"
),
"voyager/control_primitives_context/creativeGiveItem.js": "documents that helper to the Action Agent",
"voyager_verification.py": "advisory world evidence handed to the official Critic; never decides",
}
# Directories the boot legitimately creates that are not upstream content.
GENERATED_PREFIXES = (
"node_modules/",
".git/",
f"{REFERENCE_DIR}/",
f"{EXPECTED_DIR}/",
)
GENERATED_PARTS = frozenset({"node_modules", "__pycache__"})
# npm rewrites a lock file next to every package.json. Upstream does not track
# them (a fresh checkout has none), so one appearing is a build artifact, not a
# divergence. Applied only to the added-file scan: a *tracked* file is always
# hashed against the manifest, so this cannot hide a change to upstream content.
NPM_ARTIFACT_NAMES = frozenset({"package-lock.json", ".package-lock.json"})
def _is_npm_artifact(relative: str) -> bool:
return relative.rsplit("/", 1)[-1] in NPM_ARTIFACT_NAMES
def _digest(path) -> str:
import hashlib
return hashlib.sha256(path.read_bytes()).hexdigest()
def _is_generated(relative: str) -> bool:
if relative.startswith(GENERATED_PREFIXES):
return True
parts = relative.split("/")
if any(part in GENERATED_PARTS for part in parts):
return True
return any(part.endswith(".egg-info") for part in parts)
def write_upstream_manifest(home) -> None:
"""Record the pristine tree, before the patcher touches anything.
Called right after the checkout, when the tree still matches the pinned
commit. Only written once, so a reused checkout cannot overwrite it.
"""
from pathlib import Path
home = Path(home)
destination = home / REFERENCE_DIR / MANIFEST_FILE
if destination.exists():
return
destination.parent.mkdir(parents=True, exist_ok=True)
entries = {
path.relative_to(home).as_posix(): _digest(path)
for path in sorted(home.rglob("*"))
if path.is_file() and not _is_generated(path.relative_to(home).as_posix())
}
destination.write_text(json.dumps(entries, indent=1, sort_keys=True) + "\n", encoding="utf-8")
def divergence_report(home) -> dict:
"""Measure the real divergence between the live tree and the pristine one."""
from pathlib import Path
home = Path(home)
manifest_path = home / REFERENCE_DIR / MANIFEST_FILE
if not manifest_path.exists():
raise RuntimeError(
"Missing upstream manifest; cannot prove which files this boot rewrote"
)
pristine = json.loads(manifest_path.read_text(encoding="utf-8"))
modified, removed = [], []
for relative, digest in pristine.items():
path = home / relative
if not path.exists():
removed.append(relative)
elif _digest(path) != digest:
modified.append(relative)
added = []
for path in sorted(home.rglob("*")):
if not path.is_file():
continue
relative = path.relative_to(home).as_posix()
if relative in pristine or _is_generated(relative) or _is_npm_artifact(relative):
continue
added.append(relative)
return {"modified": sorted(modified), "removed": sorted(removed), "added": sorted(added)}
def verify_divergence(home) -> dict:
"""Fail the boot if the tree differs from the pinned commit in an undeclared way.
Called by the boot right after the last source-mutating step and before npm
install, so the measurement sees only tracked upstream content -- no
node_modules, no generated package-lock.json. The report is published next to
the manifest for the runner to put into the attestation.
"""
from pathlib import Path
home = Path(home)
report = divergence_report(home)
declared = {
"modified": set(ADAPTED_UPSTREAM_FILES),
"removed": set(),
"added": set(ADDED_UPSTREAM_FILES),
}
undeclared = {
kind: sorted(set(report[kind]) - declared[kind]) for kind in ("modified", "removed", "added")
}
missing = {
kind: sorted(declared[kind] - set(report[kind])) for kind in ("modified", "removed", "added")
}
problems = []
for kind, names in undeclared.items():
if names:
problems.append(f"undeclared {kind}: {', '.join(names)}")
for kind, names in missing.items():
if names:
problems.append(f"declared but not actually {kind}: {', '.join(names)}")
if problems:
raise RuntimeError(
"Upstream divergence does not match the declared contract; refusing to "
"attest a tree this deployment does not describe: " + "; ".join(problems)
)
destination = home / REFERENCE_DIR / DIVERGENCE_FILE
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_text(json.dumps(report, indent=1, sort_keys=True) + "\n", encoding="utf-8")
return report
def read_divergence(home) -> dict:
"""Read the report published by verify_divergence() during the boot."""
from pathlib import Path
try:
value = json.loads(
(Path(home) / REFERENCE_DIR / DIVERGENCE_FILE).read_text(encoding="utf-8")
)
except (OSError, ValueError):
return {}
return value if isinstance(value, dict) else {}
def exit_report_path(home, bot_username: str):
"""Where a runner records why it died.
Deliberately outside the pinned checkout: the divergence check compares that
tree against the upstream commit, so a runtime file written inside it would
look like an undeclared addition and fail the next boot. Each bot gets its
own file because both runners share one source root.
"""
from pathlib import Path
return Path(home).resolve().parent / f"last-exit-{bot_username}.json"
def read_exit_report(home, bot_username: str) -> dict:
"""Read the last recorded runner failure, if any.
Measured need: Gemini_Bot reached restart_count 9 with the only visible
symptom being 'runner=1'. The HF log stream served a frozen window and the app
routes no runner log, so the cause was invisible from outside.
"""
try:
value = json.loads(
exit_report_path(home, bot_username).read_text(encoding="utf-8")
)
except (OSError, ValueError):
return {}
return value if isinstance(value, dict) else {}
# Anything matching these is replaced before a traceback is written out. The
# report is meant to be read from the status endpoint, so it must not be a way
# to leak a credential.
SECRET_PATTERNS = (
r"AIza[0-9A-Za-z_\-]{10,}",
r"hf_[0-9A-Za-z]{10,}",
r"sk-[0-9A-Za-z]{10,}",
)
def _redact(text: str) -> str:
import re
for pattern in SECRET_PATTERNS:
text = re.sub(pattern, "<redacted>", text)
return text
def record_exit(home, bot_username: str, exc: BaseException) -> dict:
"""Publish the failure that ended a runner, so the next restart is diagnosable.
Lives here rather than in voyager_runner.py so it can be exercised directly:
that module imports the pinned upstream package, which a unit test should not
need. Returns the record it wrote, or {} if it could not write one.
"""
import traceback
frames = [
line for line in traceback.format_exception(type(exc), exc, exc.__traceback__) if line.strip()
]
record = {
"bot_username": bot_username,
"at": time.time(),
"pid": os.getpid(),
"error": _redact(f"{type(exc).__name__}: {exc}"),
"traceback": [_redact(line.rstrip()) for line in frames[-30:]],
}
try:
path = exit_report_path(home, bot_username)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(record, indent=1) + "\n", encoding="utf-8")
return record
except OSError as write_error:
# Never mask the original failure.
print(f"[voyager] could not write the exit report: {write_error}", flush=True)
return {}
# The single declared source adaptation. Kept here so the boot patcher and the
# attestation compare against the same text.
CURRICULUM_RETRY_MARKER = " messages=messages,\n max_retries=max_retries - 1,"
CURRICULUM_RETRY_ADAPTATION = (
' messages=messages + [\n'
' AIMessage(content=curriculum[:1200]),\n'
' HumanMessage(content=(\n'
' f"The previous proposal was rejected: {e}. "\n'
' "Propose a different objective in the required format."\n'
' )),\n'
' ],\n'
' max_retries=max_retries - 1,'
)
def apply_curriculum_retry_adaptation(text: str) -> str:
if text.count(CURRICULUM_RETRY_MARKER) != 1:
raise RuntimeError("Voyager curriculum retry marker changed")
return text.replace(CURRICULUM_RETRY_MARKER, CURRICULUM_RETRY_ADAPTATION, 1)
def _reference_source(home: Path, relative: str) -> str:
"""Read the pristine upstream file snapshotted before any boot patch."""
reference = home / REFERENCE_DIR / relative
if not reference.exists():
raise RuntimeError(
f"Missing pristine upstream snapshot for {relative}; cannot attest the runtime"
)
return reference.read_text(encoding="utf-8")
def _expected_source(home: Path, relative: str) -> str:
reference = home / EXPECTED_DIR / relative
if not reference.exists():
raise RuntimeError(
f"Missing adapted upstream reference for {relative}; cannot attest the runtime"
)
return reference.read_text(encoding="utf-8")
def _class_method(text: str, class_name: str, method_name: str) -> str | None:
"""Extract one method's source from a module's text."""
import ast
try:
tree = ast.parse(text)
except SyntaxError:
return None
for node in ast.walk(tree):
if isinstance(node, ast.ClassDef) and node.name == class_name:
for child in node.body:
if isinstance(child, ast.FunctionDef) and child.name == method_name:
return ast.get_source_segment(text, child) or ""
return None
def _method_source(cls, method_name: str) -> str | None:
"""Read the live method source from the file the class was loaded from."""
try:
return _class_method(
Path(inspect.getfile(cls)).read_text(encoding="utf-8"), cls.__name__, method_name
)
except (OSError, TypeError):
return None
def _reference_method(
home: Path, relative: str, class_name: str, method_name: str, expected: bool = False
) -> str | None:
reader = _expected_source if expected else _reference_source
return _class_method(reader(home, relative), class_name, method_name)
def attest_original_runtime(voyager, home: Path) -> dict:
"""Fail before learning if the engine or any of its agents was swapped."""
home = Path(home).resolve()
actual = subprocess.check_output(
["git", "-C", str(home), "rev-parse", "HEAD"],
stderr=subprocess.DEVNULL, timeout=10, text=True,
).strip()
if actual != UPSTREAM_COMMIT:
raise RuntimeError(f"Unexpected Voyager upstream checkout: {actual}")
components = {
"engine": (voyager, "voyager/voyager.py", "Voyager"),
"curriculum": (voyager.curriculum_agent, "voyager/agents/curriculum.py", "CurriculumAgent"),
"action": (voyager.action_agent, "voyager/agents/action.py", "ActionAgent"),
"critic": (voyager.critic_agent, "voyager/agents/critic.py", "CriticAgent"),
"skill_library": (voyager.skill_manager, "voyager/agents/skill.py", "SkillManager"),
}
wrappers: dict[str, str] = {}
for role, (component, path, class_name) in components.items():
declaration = DECLARED_WRAPPERS.get(role)
if declaration is not None:
live_name = type(component).__name__
if live_name == declaration["class_name"]:
delegate = getattr(component, declaration["delegate_attribute"], None)
if delegate is None:
raise RuntimeError(
f"Voyager {role} is wrapped by {live_name} but exposes no "
f"{declaration['delegate_attribute']!r} delegate; refusing to "
"attest a decision method that cannot be reached"
)
component = delegate
wrappers[f"{role}.{live_name}"] = declaration["reason"]
elif live_name != class_name:
raise RuntimeError(
f"Voyager {role} is {live_name}, which is neither the pinned "
f"{class_name} nor the declared wrapper {declaration['class_name']}"
)
cls = type(component)
if cls.__name__ != class_name or Path(inspect.getfile(cls)).resolve() != home / path:
raise RuntimeError(f"Voyager {role} is not the pinned upstream class")
for method_name in DECISION_METHODS.get(role, ()):
live = _method_source(cls, method_name)
adapted = (role, method_name) in ADAPTED_METHODS
reference = _reference_method(
home, path, class_name, method_name, expected=adapted
)
if live is None or reference is None:
raise RuntimeError(
f"Cannot read Voyager {role}.{method_name} for attestation"
)
if live.strip() != reference.strip():
raise RuntimeError(
f"Voyager {role}.{method_name} differs from the pinned upstream "
f"implementation; refusing to learn with a substituted decision method"
)
return {
"engine": "MineDojo/Voyager",
"upstream_commit": actual,
"source_root": str(home),
"checkout_verified": True,
"decision_methods_verified": sorted(
f"{role}.{name}" for role, names in DECISION_METHODS.items() for name in names
),
"declared_adaptations": {
f"{role}.{name}": reason for (role, name), reason in ADAPTED_METHODS.items()
},
"declared_wrappers": wrappers,
"components": list(components),
"creative_adapters": True,
}
def write_attestation(path: str, attestation: dict, bot: str) -> None:
if not path:
return
destination = Path(path)
destination.parent.mkdir(parents=True, exist_ok=True)
record = {**attestation, "bot_username": bot, "pid": os.getpid()}
temporary = destination.with_suffix(destination.suffix + ".tmp")
temporary.write_text(json.dumps(record, sort_keys=True) + "\n", encoding="utf-8")
temporary.replace(destination)
def read_attestation(path: Path, pid: int | None) -> dict | None:
if pid is None:
return None
try:
record = json.loads(Path(path).read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
return record if record.get("pid") == pid and record.get("checkout_verified") else None