naldo commited on
Commit ·
31408fc
1
Parent(s): 24010a2
Bound Creative agent parsing and remove faulty respawn mod
Browse files- minecraft_stack.py +46 -23
- tests/test_player_save_diagnostics.py +21 -0
- tests/test_voyager_patch.py +85 -6
- voyager_action_parse.py +75 -0
- voyager_runner.py +26 -10
- voyager_stack.py +108 -6
- voyager_verification.py +97 -4
minecraft_stack.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
-
import hashlib
|
|
|
|
| 4 |
import json
|
| 5 |
import os
|
| 6 |
import re
|
|
@@ -289,25 +290,22 @@ class MinecraftStack:
|
|
| 289 |
("fabric-api-0.58.0+1.19.jar", FABRIC_API_URL, FABRIC_API_SHA256),
|
| 290 |
("completeconfig-2.0.0.jar", COMPLETE_CONFIG_URL, COMPLETE_CONFIG_SHA256),
|
| 291 |
("Multiplayer-Server-Pause-1.19-Fabric-1.1.0.jar", SERVER_PAUSE_URL, SERVER_PAUSE_SHA256),
|
| 292 |
-
("better-respawn-fabric-1.19-2.0.0.jar", BETTER_RESPAWN_URL, BETTER_RESPAWN_SHA256),
|
| 293 |
)
|
| 294 |
-
for filename, url, sha256 in mods:
|
| 295 |
-
_download(url, mods_dir / filename, sha256)
|
| 296 |
-
|
| 297 |
-
#
|
| 298 |
-
#
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
"
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
"
|
| 309 |
-
"CompleteConfig 2.0.0, Multiplayer Server Pause 1.1.0, "
|
| 310 |
-
"Better Respawn 1.19-2.0.0. Mod Menu is client-only and is "
|
| 311 |
"intentionally omitted on this headless dedicated server."
|
| 312 |
)
|
| 313 |
|
|
@@ -571,13 +569,37 @@ class MinecraftStack:
|
|
| 571 |
items = LOG_PATH.read_text(encoding="utf-8", errors="replace").splitlines()
|
| 572 |
return "\n".join(items[-max(10, min(lines, 500)):])
|
| 573 |
|
| 574 |
-
def status(self) -> dict[str, Any]:
|
| 575 |
-
with self.lock:
|
| 576 |
processes = {
|
| 577 |
name: {"pid": proc.pid, "running": proc.poll() is None, "returncode": proc.poll()}
|
| 578 |
for name, proc in self.processes.items()
|
| 579 |
}
|
| 580 |
-
log = self.tail(120)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 581 |
login_url = None
|
| 582 |
device_code = None
|
| 583 |
url_match = re.findall(r"https://(?:www\.)?microsoft\.com/link", log, flags=re.I)
|
|
@@ -602,7 +624,8 @@ class MinecraftStack:
|
|
| 602 |
"auth_persistence": str(RELAY_DIR / "token.json"),
|
| 603 |
"auth_token_cached": (RELAY_DIR / "token.json").exists(),
|
| 604 |
"persistent_root": str(PERSIST_ROOT),
|
| 605 |
-
"world_persistence": str(WORLD_DIR),
|
|
|
|
| 606 |
"bot_username": BOT_USERNAME,
|
| 607 |
"bot_display_name": "Naldo Bot",
|
| 608 |
"bedrock_protocol": BEDROCK_PROTOCOL,
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
+
import hashlib
|
| 4 |
+
import uuid
|
| 5 |
import json
|
| 6 |
import os
|
| 7 |
import re
|
|
|
|
| 290 |
("fabric-api-0.58.0+1.19.jar", FABRIC_API_URL, FABRIC_API_SHA256),
|
| 291 |
("completeconfig-2.0.0.jar", COMPLETE_CONFIG_URL, COMPLETE_CONFIG_SHA256),
|
| 292 |
("Multiplayer-Server-Pause-1.19-Fabric-1.1.0.jar", SERVER_PAUSE_URL, SERVER_PAUSE_SHA256),
|
|
|
|
| 293 |
)
|
| 294 |
+
for filename, url, sha256 in mods:
|
| 295 |
+
_download(url, mods_dir / filename, sha256)
|
| 296 |
+
|
| 297 |
+
# The pinned Better Respawn 1.19 mixin dereferences an uninitialized
|
| 298 |
+
# respawnDimension while saving a newly connected Creative player.
|
| 299 |
+
# Remove only this faulty mod from the ephemeral Fabric installation;
|
| 300 |
+
# never edit the persistent world, playerdata, or Voyager checkpoint.
|
| 301 |
+
faulty_mod = mods_dir / "better-respawn-fabric-1.19-2.0.0.jar"
|
| 302 |
+
if faulty_mod.exists():
|
| 303 |
+
faulty_mod.unlink()
|
| 304 |
+
_log("Removed faulty Better Respawn 1.19 mod from Fabric mods directory")
|
| 305 |
+
_log(
|
| 306 |
+
"Official Voyager Fabric mods ready: Fabric API 0.58.0+1.19, "
|
| 307 |
+
"CompleteConfig 2.0.0, Multiplayer Server Pause 1.1.0. "
|
| 308 |
+
"Better Respawn 1.19-2.0.0 is disabled due to its player-save NPE. Mod Menu is client-only and is "
|
|
|
|
|
|
|
| 309 |
"intentionally omitted on this headless dedicated server."
|
| 310 |
)
|
| 311 |
|
|
|
|
| 569 |
items = LOG_PATH.read_text(encoding="utf-8", errors="replace").splitlines()
|
| 570 |
return "\n".join(items[-max(10, min(lines, 500)):])
|
| 571 |
|
| 572 |
+
def status(self) -> dict[str, Any]:
|
| 573 |
+
with self.lock:
|
| 574 |
processes = {
|
| 575 |
name: {"pid": proc.pid, "running": proc.poll() is None, "returncode": proc.poll()}
|
| 576 |
for name, proc in self.processes.items()
|
| 577 |
}
|
| 578 |
+
log = self.tail(120)
|
| 579 |
+
try:
|
| 580 |
+
all_lines = LOG_PATH.read_text(encoding="utf-8", errors="replace").splitlines()
|
| 581 |
+
boot = next((i for i in range(len(all_lines) - 1, -1, -1)
|
| 582 |
+
if "=== Minecraft stack bootstrap ===" in all_lines[i]), 0)
|
| 583 |
+
server_lines = [line for line in all_lines[boot:] if "minecraft>" in line]
|
| 584 |
+
except OSError:
|
| 585 |
+
server_lines = []
|
| 586 |
+
offline_uuid = uuid.UUID(bytes=hashlib.md5(
|
| 587 |
+
f"OfflinePlayer:{BOT_USERNAME}".encode("utf-8")
|
| 588 |
+
).digest(), version=3)
|
| 589 |
+
player_file = WORLD_DIR / "playerdata" / f"{offline_uuid}.dat"
|
| 590 |
+
try:
|
| 591 |
+
player_stat = player_file.stat()
|
| 592 |
+
except OSError:
|
| 593 |
+
player_stat = None
|
| 594 |
+
player_save = {
|
| 595 |
+
"better_respawn_jar_present": (JAVA_DIR / "mods" / "better-respawn-fabric-1.19-2.0.0.jar").exists(),
|
| 596 |
+
"failed_saves_since_boot": sum("Failed to save player data" in line for line in server_lines),
|
| 597 |
+
"respawn_npe_since_boot": sum("respawnDimension" in line and "null" in line
|
| 598 |
+
for line in server_lines),
|
| 599 |
+
"player_file_exists": player_stat is not None,
|
| 600 |
+
"player_file_size": player_stat.st_size if player_stat else None,
|
| 601 |
+
"player_file_mtime": player_stat.st_mtime if player_stat else None,
|
| 602 |
+
}
|
| 603 |
login_url = None
|
| 604 |
device_code = None
|
| 605 |
url_match = re.findall(r"https://(?:www\.)?microsoft\.com/link", log, flags=re.I)
|
|
|
|
| 624 |
"auth_persistence": str(RELAY_DIR / "token.json"),
|
| 625 |
"auth_token_cached": (RELAY_DIR / "token.json").exists(),
|
| 626 |
"persistent_root": str(PERSIST_ROOT),
|
| 627 |
+
"world_persistence": str(WORLD_DIR),
|
| 628 |
+
"player_save": player_save,
|
| 629 |
"bot_username": BOT_USERNAME,
|
| 630 |
"bot_display_name": "Naldo Bot",
|
| 631 |
"bedrock_protocol": BEDROCK_PROTOCOL,
|
tests/test_player_save_diagnostics.py
CHANGED
|
@@ -2,6 +2,7 @@
|
|
| 2 |
from pathlib import Path
|
| 3 |
import subprocess
|
| 4 |
import tempfile
|
|
|
|
| 5 |
|
| 6 |
root = Path(__file__).resolve().parents[1]
|
| 7 |
with tempfile.TemporaryDirectory() as directory:
|
|
@@ -29,3 +30,23 @@ public class class_29 {
|
|
| 29 |
assert "exception logging hooks=1" in result.stderr
|
| 30 |
assert result.stdout == "original catch retained\nnormal return retained\n"
|
| 31 |
print("PASS player-save exception visible; original catch and normal return unchanged")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
from pathlib import Path
|
| 3 |
import subprocess
|
| 4 |
import tempfile
|
| 5 |
+
import importlib.util
|
| 6 |
|
| 7 |
root = Path(__file__).resolve().parents[1]
|
| 8 |
with tempfile.TemporaryDirectory() as directory:
|
|
|
|
| 30 |
assert "exception logging hooks=1" in result.stderr
|
| 31 |
assert result.stdout == "original catch retained\nnormal return retained\n"
|
| 32 |
print("PASS player-save exception visible; original catch and normal return unchanged")
|
| 33 |
+
|
| 34 |
+
# Isolate the Fabric mod installer: only the known faulty JAR is removed.
|
| 35 |
+
spec = importlib.util.spec_from_file_location("minecraft_stack_test", root / "minecraft_stack.py")
|
| 36 |
+
module = importlib.util.module_from_spec(spec)
|
| 37 |
+
spec.loader.exec_module(module)
|
| 38 |
+
with tempfile.TemporaryDirectory() as directory:
|
| 39 |
+
module.JAVA_DIR = Path(directory)
|
| 40 |
+
module._download = lambda _url, path, _sha: path.write_bytes(b"unrelated mod")
|
| 41 |
+
module._log = lambda _message: None
|
| 42 |
+
mods = module.JAVA_DIR / "mods"
|
| 43 |
+
mods.mkdir()
|
| 44 |
+
faulty = mods / "better-respawn-fabric-1.19-2.0.0.jar"
|
| 45 |
+
faulty.write_bytes(b"the pinned faulty mod")
|
| 46 |
+
marker = mods / "custom-mod.jar"
|
| 47 |
+
marker.write_bytes(b"preserve")
|
| 48 |
+
module.MinecraftStack()._ensure_voyager_mods()
|
| 49 |
+
assert not faulty.exists()
|
| 50 |
+
assert marker.read_bytes() == b"preserve"
|
| 51 |
+
assert len(list(mods.glob("*.jar"))) == 4 # Three required mods plus the marker.
|
| 52 |
+
print("PASS faulty Better Respawn mixin excluded; other Fabric mods retained")
|
tests/test_voyager_patch.py
CHANGED
|
@@ -21,7 +21,11 @@ from unittest.mock import Mock, patch
|
|
| 21 |
|
| 22 |
ROOT = Path(__file__).resolve().parents[1]
|
| 23 |
sys.path.insert(0, str(ROOT))
|
| 24 |
-
from voyager_verification import
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
|
| 27 |
def functions(filename, names, namespace):
|
|
@@ -80,10 +84,16 @@ def test_resume():
|
|
| 80 |
file = ckpt / rel
|
| 81 |
file.parent.mkdir(parents=True, exist_ok=True)
|
| 82 |
file.write_text("{}")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
voyager = Mock()
|
| 84 |
voyager.return_value.skill_manager.skills = {}
|
| 85 |
voyager.return_value.env.mineflayer.process.poll.return_value = None
|
| 86 |
-
ns = dict(os=os, json=json, logging=logging, Path=Path, Voyager=voyager,
|
|
|
|
|
|
|
| 87 |
functions("voyager_runner.py", {"env", "main"}, ns)
|
| 88 |
with patch.dict(os.environ, {"VOYAGER_CKPT_DIR": folder, "VOYAGER_MODEL": "main",
|
| 89 |
"VOYAGER_AUX_MODEL": "aux"}):
|
|
@@ -98,6 +108,10 @@ def test_resume():
|
|
| 98 |
assert kw[agent + "_temperature"] == 0
|
| 99 |
voyager.return_value.learn.assert_called_once_with(reset_env=False)
|
| 100 |
voyager.return_value.env.mineflayer.process.terminate.assert_called_once_with()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
voyager.return_value.learn.side_effect = RuntimeError("fatal curriculum error")
|
| 102 |
with patch.dict(os.environ, {"VOYAGER_CKPT_DIR": folder}):
|
| 103 |
try:
|
|
@@ -113,7 +127,10 @@ def check_curriculum_behavior(source):
|
|
| 113 |
cls = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == "CurriculumAgent")
|
| 114 |
names = {"get_task_context", "propose_next_ai_task", "parse_ai_message", "render_human_message", "render_observation"}
|
| 115 |
methods = [n for n in cls.body if isinstance(n, ast.FunctionDef) and n.name in names]
|
| 116 |
-
ns = {"AIMessage": SimpleNamespace, "HumanMessage": SimpleNamespace,
|
|
|
|
|
|
|
|
|
|
| 117 |
exec(compile(ast.Module(body=methods, type_ignores=[]), "curriculum.py", "exec"), ns)
|
| 118 |
agent = SimpleNamespace(completed_tasks=["Obtain a spruce log"], failed_tasks=[])
|
| 119 |
for name in names:
|
|
@@ -126,7 +143,7 @@ def check_curriculum_behavior(source):
|
|
| 126 |
task, context = agent.propose_next_ai_task(messages=messages)
|
| 127 |
assert task == "Mine 1 gravel" and "Creative mode" in context
|
| 128 |
retry = agent.llm.call_args_list[1].args[0]
|
| 129 |
-
assert len(retry) == 4 and "
|
| 130 |
assert len(messages) == 2
|
| 131 |
agent.progress = 100
|
| 132 |
agent.warm_up = {"optional_inventory_items": 0}
|
|
@@ -147,6 +164,68 @@ def check_curriculum_behavior(source):
|
|
| 147 |
agent.run_qa.assert_not_called()
|
| 148 |
print("PASS curriculum retry changes input; Creative context and observation bypass Survival QA")
|
| 149 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
|
| 151 |
def check_explore_behavior(source):
|
| 152 |
harness = r'''
|
|
@@ -214,9 +293,9 @@ def check_clean_upstream(repo):
|
|
| 214 |
assert "creativeGiveItem" in rendered_prompt
|
| 215 |
curriculum = (stack.SRC / "voyager/agents/curriculum.py").read_text()
|
| 216 |
assert "Creative deployment: skip Survival QA" in curriculum
|
| 217 |
-
assert "
|
| 218 |
-
assert "Creative mode rejects Survival smelting grind" in curriculum
|
| 219 |
check_curriculum_behavior(curriculum)
|
|
|
|
| 220 |
exploration = (stack.SRC / "voyager/control_primitives/exploreUntil.js").read_text()
|
| 221 |
assert "Math.sign(Number(direction.x))" in exploration
|
| 222 |
assert "dy = 0" in exploration
|
|
|
|
| 21 |
|
| 22 |
ROOT = Path(__file__).resolve().parents[1]
|
| 23 |
sys.path.insert(0, str(ROOT))
|
| 24 |
+
from voyager_verification import (
|
| 25 |
+
choose_curriculum_task, fallback_curriculum_task, task_spec, validate_task,
|
| 26 |
+
verify_task, quarantine_invalid_skills, task_history,
|
| 27 |
+
)
|
| 28 |
+
from voyager_action_parse import extract_action_code
|
| 29 |
|
| 30 |
|
| 31 |
def functions(filename, names, namespace):
|
|
|
|
| 84 |
file = ckpt / rel
|
| 85 |
file.parent.mkdir(parents=True, exist_ok=True)
|
| 86 |
file.write_text("{}")
|
| 87 |
+
original_completed = ["Mine 1 stone block to upgrade tools", "Mine 1 stone", "Place 4 snow_block"]
|
| 88 |
+
original_failed = ["Mine 1 stone", "Craft a stone pickaxe", "Craft a stone pickaxe"]
|
| 89 |
+
(ckpt / "curriculum/completed_tasks.json").write_text(json.dumps(original_completed))
|
| 90 |
+
(ckpt / "curriculum/failed_tasks.json").write_text(json.dumps(original_failed))
|
| 91 |
voyager = Mock()
|
| 92 |
voyager.return_value.skill_manager.skills = {}
|
| 93 |
voyager.return_value.env.mineflayer.process.poll.return_value = None
|
| 94 |
+
ns = dict(os=os, json=json, logging=logging, Path=Path, Voyager=voyager,
|
| 95 |
+
VOYAGER_HOME=Path("upstream"), quarantine_invalid_skills=quarantine_invalid_skills,
|
| 96 |
+
task_spec=task_spec)
|
| 97 |
functions("voyager_runner.py", {"env", "main"}, ns)
|
| 98 |
with patch.dict(os.environ, {"VOYAGER_CKPT_DIR": folder, "VOYAGER_MODEL": "main",
|
| 99 |
"VOYAGER_AUX_MODEL": "aux"}):
|
|
|
|
| 108 |
assert kw[agent + "_temperature"] == 0
|
| 109 |
voyager.return_value.learn.assert_called_once_with(reset_env=False)
|
| 110 |
voyager.return_value.env.mineflayer.process.terminate.assert_called_once_with()
|
| 111 |
+
assert json.loads((ckpt / "curriculum/completed_tasks.json").read_text()) == original_completed[::2]
|
| 112 |
+
assert json.loads((ckpt / "curriculum/failed_tasks.json").read_text()) == ["Craft a stone pickaxe"]
|
| 113 |
+
archive = json.loads((ckpt / "quarantine/curriculum_history_before_semantic_compaction_v1.json").read_text())
|
| 114 |
+
assert archive == {"completed": original_completed, "failed": original_failed}
|
| 115 |
voyager.return_value.learn.side_effect = RuntimeError("fatal curriculum error")
|
| 116 |
with patch.dict(os.environ, {"VOYAGER_CKPT_DIR": folder}):
|
| 117 |
try:
|
|
|
|
| 127 |
cls = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == "CurriculumAgent")
|
| 128 |
names = {"get_task_context", "propose_next_ai_task", "parse_ai_message", "render_human_message", "render_observation"}
|
| 129 |
methods = [n for n in cls.body if isinstance(n, ast.FunctionDef) and n.name in names]
|
| 130 |
+
ns = {"AIMessage": SimpleNamespace, "HumanMessage": SimpleNamespace,
|
| 131 |
+
"choose_curriculum_task": choose_curriculum_task,
|
| 132 |
+
"fallback_curriculum_task": fallback_curriculum_task,
|
| 133 |
+
"task_history": task_history}
|
| 134 |
exec(compile(ast.Module(body=methods, type_ignores=[]), "curriculum.py", "exec"), ns)
|
| 135 |
agent = SimpleNamespace(completed_tasks=["Obtain a spruce log"], failed_tasks=[])
|
| 136 |
for name in names:
|
|
|
|
| 143 |
task, context = agent.propose_next_ai_task(messages=messages)
|
| 144 |
assert task == "Mine 1 gravel" and "Creative mode" in context
|
| 145 |
retry = agent.llm.call_args_list[1].args[0]
|
| 146 |
+
assert len(retry) == 4 and "No new, valid single Creative task" in retry[-1].content
|
| 147 |
assert len(messages) == 2
|
| 148 |
agent.progress = 100
|
| 149 |
agent.warm_up = {"optional_inventory_items": 0}
|
|
|
|
| 164 |
agent.run_qa.assert_not_called()
|
| 165 |
print("PASS curriculum retry changes input; Creative context and observation bypass Survival QA")
|
| 166 |
|
| 167 |
+
agent.completed_tasks, agent.failed_tasks = ["Mine 1 stone block to upgrade tools"], []
|
| 168 |
+
agent.llm = Mock(side_effect=[SimpleNamespace(content="Task: Mine 4 coal_ore, Place 4 coal_block")])
|
| 169 |
+
assert agent.propose_next_ai_task(messages=messages)[0] == "Mine 4 coal_ore"
|
| 170 |
+
agent.completed_tasks.append("Mine 4 coal ore")
|
| 171 |
+
agent.llm = Mock(side_effect=[SimpleNamespace(content="Task: Mine 4 coal_ore, Place 4 coal_block")])
|
| 172 |
+
assert agent.propose_next_ai_task(messages=messages)[0] == "Place 4 coal_block"
|
| 173 |
+
agent.llm = Mock(side_effect=[SimpleNamespace(content="Mine 1 gravel")])
|
| 174 |
+
assert agent.propose_next_ai_task(messages=messages)[0] == "Mine 1 gravel"
|
| 175 |
+
agent.llm = Mock(side_effect=[SimpleNamespace(content="Task: Craft a stone pickaxe"),
|
| 176 |
+
SimpleNamespace(content="Task: Obtain a wood log")])
|
| 177 |
+
task, _ = agent.propose_next_ai_task(messages=messages, max_retries=2)
|
| 178 |
+
assert task == "Explore 16 blocks north"
|
| 179 |
+
agent.llm = Mock(side_effect=[SimpleNamespace(content="Explain only") for _ in range(3)])
|
| 180 |
+
task, _ = agent.propose_next_ai_task(messages=[SimpleNamespace(content="Nearby blocks: dirt, stone")])
|
| 181 |
+
assert task == "Mine 1 dirt" and agent.llm.call_count == 3
|
| 182 |
+
assert task_spec(task) not in {task_spec(old) for old in agent.completed_tasks + agent.failed_tasks}
|
| 183 |
+
print("PASS valid, composite, duplicate, Survival, unlabeled and exhausted Curriculum outputs")
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def check_action_behavior(source):
|
| 187 |
+
tree = ast.parse(source)
|
| 188 |
+
cls = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == "ActionAgent")
|
| 189 |
+
method = next(n for n in cls.body if isinstance(n, ast.FunctionDef) and n.name == "process_ai_message")
|
| 190 |
+
|
| 191 |
+
class Node(dict):
|
| 192 |
+
def __getattr__(self, name):
|
| 193 |
+
return self[name]
|
| 194 |
+
|
| 195 |
+
def parse_js(code):
|
| 196 |
+
subprocess.run(["node", "--check", "-"], input=code, text=True,
|
| 197 |
+
capture_output=True, check=True)
|
| 198 |
+
node = Node(type="FunctionDeclaration", id=SimpleNamespace(name="placeStone"),
|
| 199 |
+
params=[SimpleNamespace(type="Identifier", name="bot")])
|
| 200 |
+
node["async"] = True
|
| 201 |
+
return SimpleNamespace(program=SimpleNamespace(body=[node]))
|
| 202 |
+
|
| 203 |
+
generator = lambda node: SimpleNamespace(code="async function placeStone(bot) { await placeBlocks(bot, 'stone', 1); }")
|
| 204 |
+
require = lambda package: (SimpleNamespace(parse=parse_js) if package == "@babel/core"
|
| 205 |
+
else SimpleNamespace(default=generator))
|
| 206 |
+
ns = {"extract_action_code": extract_action_code, "require": require}
|
| 207 |
+
exec(compile(ast.Module(body=[method], type_ignores=[]), "action.py", "exec"), ns)
|
| 208 |
+
process = MethodType(ns["process_ai_message"], SimpleNamespace())
|
| 209 |
+
samples = [
|
| 210 |
+
("```js\nasync function placeStone(bot) { await placeBlocks(bot, 'stone', 1); }\n```", True),
|
| 211 |
+
("Explain: ready\n```javascript\nasync function placeStone(bot) { await placeBlocks(bot, 'stone', 1); }\n```", True),
|
| 212 |
+
("```js\nExplain: ready\nPlan: place block\nCode:\nasync function placeStone(bot) { await placeBlocks(bot, 'stone', 1); }\n```", True),
|
| 213 |
+
("```js\nExplain: prelude\n```\n```js\nasync function placeStone(bot) { await placeBlocks(bot, 'stone', 1); }\n```", True),
|
| 214 |
+
("```js\nasync function placeStone(bot) { await placeBlocks(bot, 'stone', 1);", False),
|
| 215 |
+
("```js\nasync function placeStone(bot) { Explain: no; await placeBlocks(bot, 'stone', 1); }\n```", False),
|
| 216 |
+
("```js\nasync function placeStone(bot) { await placeBlocks(bot, 'stone', 1); }\nfunction helper() {}\n```", False),
|
| 217 |
+
("```js\nasync function placeStone(bot) {}\n```\n```js\nasync function again(bot) {}\n```", False),
|
| 218 |
+
]
|
| 219 |
+
for content, valid in samples:
|
| 220 |
+
result = process(SimpleNamespace(content=content))
|
| 221 |
+
assert isinstance(result, dict) is valid, (content, result)
|
| 222 |
+
if valid:
|
| 223 |
+
assert result["program_name"] == "placeStone"
|
| 224 |
+
assert "Explain" not in result["program_code"]
|
| 225 |
+
else:
|
| 226 |
+
assert "before program execution" in result
|
| 227 |
+
print("PASS Action parser normalizes prose, rejects ambiguity, truncation and extra functions")
|
| 228 |
+
|
| 229 |
|
| 230 |
def check_explore_behavior(source):
|
| 231 |
harness = r'''
|
|
|
|
| 293 |
assert "creativeGiveItem" in rendered_prompt
|
| 294 |
curriculum = (stack.SRC / "voyager/agents/curriculum.py").read_text()
|
| 295 |
assert "Creative deployment: skip Survival QA" in curriculum
|
| 296 |
+
assert "Curriculum fallback after bounded retries" in curriculum
|
|
|
|
| 297 |
check_curriculum_behavior(curriculum)
|
| 298 |
+
check_action_behavior((stack.SRC / "voyager/agents/action.py").read_text())
|
| 299 |
exploration = (stack.SRC / "voyager/control_primitives/exploreUntil.js").read_text()
|
| 300 |
assert "Math.sign(Number(direction.x))" in exploration
|
| 301 |
assert "dy = 0" in exploration
|
voyager_action_parse.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Extract one executable Action function from small-model responses."""
|
| 2 |
+
|
| 3 |
+
import re
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
_FENCE = re.compile(r"```([^\n`]*)\n(.*?)```", re.DOTALL)
|
| 7 |
+
_FUNCTION = re.compile(
|
| 8 |
+
r"(?m)^\s*async\s+function\s+[A-Za-z_$][\w$]*\s*\(\s*bot\s*\)\s*\{"
|
| 9 |
+
)
|
| 10 |
+
_OTHER_CODE = re.compile(r"\b(?:async\s+)?function\b|(?m:^\s*(?:class|import|export|const|let|var)\b)")
|
| 11 |
+
_PROSE_INSIDE = re.compile(r"\b(?:Explain|Plan|Code)(?:\s*\([^\n]*\))?\s*:", re.IGNORECASE)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _function_end(source: str, opening: int) -> int:
|
| 15 |
+
"""Find the closing brace, ignoring braces inside JS strings and comments."""
|
| 16 |
+
depth = 0
|
| 17 |
+
state = "code"
|
| 18 |
+
index = opening
|
| 19 |
+
while index < len(source):
|
| 20 |
+
char = source[index]
|
| 21 |
+
next_char = source[index + 1] if index + 1 < len(source) else ""
|
| 22 |
+
if state == "code":
|
| 23 |
+
if char in ("'", '"', "`"):
|
| 24 |
+
state = char
|
| 25 |
+
elif char == "/" and next_char in ("/", "*"):
|
| 26 |
+
state = next_char
|
| 27 |
+
index += 1
|
| 28 |
+
elif char == "{":
|
| 29 |
+
depth += 1
|
| 30 |
+
elif char == "}":
|
| 31 |
+
depth -= 1
|
| 32 |
+
if depth == 0:
|
| 33 |
+
return index + 1
|
| 34 |
+
elif state in ("'", '"', "`"):
|
| 35 |
+
if char == "\\":
|
| 36 |
+
index += 1
|
| 37 |
+
elif char == state:
|
| 38 |
+
state = "code"
|
| 39 |
+
elif state == "/" and char == "\n":
|
| 40 |
+
state = "code"
|
| 41 |
+
elif state == "*" and char == "*" and next_char == "/":
|
| 42 |
+
state = "code"
|
| 43 |
+
index += 1
|
| 44 |
+
index += 1
|
| 45 |
+
raise ValueError("Truncated JavaScript function; request a new Action response")
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def extract_action_code(response: str) -> str:
|
| 49 |
+
"""Remove surrounding prose; reject ambiguous code and prose inside the function.
|
| 50 |
+
|
| 51 |
+
Babel validates JavaScript syntax and the exact top-level AST after this step.
|
| 52 |
+
A damaged function body is retried, never executed after deleting its lines.
|
| 53 |
+
"""
|
| 54 |
+
response = str(response or "")
|
| 55 |
+
fences = _FENCE.findall(response)
|
| 56 |
+
if "```" in response and not fences:
|
| 57 |
+
raise ValueError("Unclosed Action code fence")
|
| 58 |
+
candidates = [body for language, body in fences if language.strip().lower() in ("", "js", "javascript")]
|
| 59 |
+
if not fences:
|
| 60 |
+
candidates = [response]
|
| 61 |
+
functions = []
|
| 62 |
+
for candidate in candidates:
|
| 63 |
+
match = _FUNCTION.search(candidate)
|
| 64 |
+
if not match:
|
| 65 |
+
continue
|
| 66 |
+
end = _function_end(candidate, candidate.index("{", match.start()))
|
| 67 |
+
if _OTHER_CODE.search(candidate[end:]):
|
| 68 |
+
raise ValueError("Extra top-level JavaScript after the Action function")
|
| 69 |
+
code = candidate[match.start():end].strip()
|
| 70 |
+
if _PROSE_INSIDE.search(code):
|
| 71 |
+
raise ValueError("Explain/Plan/Code prose inside the Action function")
|
| 72 |
+
functions.append(code)
|
| 73 |
+
if len(functions) != 1:
|
| 74 |
+
raise ValueError(f"Expected exactly one complete Action function; found {len(functions)}")
|
| 75 |
+
return functions[0]
|
voyager_runner.py
CHANGED
|
@@ -11,7 +11,7 @@ if str(VOYAGER_HOME) not in sys.path:
|
|
| 11 |
sys.path.insert(0, str(VOYAGER_HOME))
|
| 12 |
|
| 13 |
from voyager import Voyager # noqa: E402
|
| 14 |
-
from voyager_verification import quarantine_invalid_skills
|
| 15 |
|
| 16 |
|
| 17 |
def env(name: str, default: str) -> str:
|
|
@@ -40,18 +40,31 @@ def main() -> None:
|
|
| 40 |
# leave the same task in failed_tasks many times, including tasks that were
|
| 41 |
# later completed under a spelling variant such as copper ore/copper_ore.
|
| 42 |
if resume:
|
| 43 |
-
def _norm_task(value: str)
|
| 44 |
-
return " ".join(str(value).lower().replace("_", " ").split())
|
| 45 |
|
| 46 |
completed_path = ckpt / "curriculum" / "completed_tasks.json"
|
| 47 |
failed_path = ckpt / "curriculum" / "failed_tasks.json"
|
| 48 |
try:
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
completed_norm = {_norm_task(item) for item in completed}
|
| 53 |
-
failed =
|
| 54 |
-
failed = [item for item in failed if _norm_task(item) not in completed_norm]
|
| 55 |
|
| 56 |
# One-time repair for failures recorded while the Creative helper
|
| 57 |
# context file was missing. Those attempts never reached the model
|
|
@@ -62,8 +75,11 @@ def main() -> None:
|
|
| 62 |
repair_marker.write_text("ok\n", encoding="utf-8")
|
| 63 |
print("[voyager] removed false starter-task failure from missing Creative context", flush=True)
|
| 64 |
|
| 65 |
-
|
| 66 |
-
|
|
|
|
|
|
|
|
|
|
| 67 |
except Exception as exc:
|
| 68 |
print(f"[voyager] checkpoint curriculum cleanup skipped: {exc}", flush=True)
|
| 69 |
|
|
|
|
| 11 |
sys.path.insert(0, str(VOYAGER_HOME))
|
| 12 |
|
| 13 |
from voyager import Voyager # noqa: E402
|
| 14 |
+
from voyager_verification import quarantine_invalid_skills, task_spec
|
| 15 |
|
| 16 |
|
| 17 |
def env(name: str, default: str) -> str:
|
|
|
|
| 40 |
# leave the same task in failed_tasks many times, including tasks that were
|
| 41 |
# later completed under a spelling variant such as copper ore/copper_ore.
|
| 42 |
if resume:
|
| 43 |
+
def _norm_task(value: str):
|
| 44 |
+
return task_spec(value) or " ".join(str(value).lower().replace("_", " ").split())
|
| 45 |
|
| 46 |
completed_path = ckpt / "curriculum" / "completed_tasks.json"
|
| 47 |
failed_path = ckpt / "curriculum" / "failed_tasks.json"
|
| 48 |
try:
|
| 49 |
+
original_completed = json.loads(completed_path.read_text(encoding="utf-8"))
|
| 50 |
+
original_failed = json.loads(failed_path.read_text(encoding="utf-8"))
|
| 51 |
+
archive = ckpt / "quarantine" / "curriculum_history_before_semantic_compaction_v1.json"
|
| 52 |
+
if not archive.exists():
|
| 53 |
+
archive.parent.mkdir(parents=True, exist_ok=True)
|
| 54 |
+
archive.write_text(json.dumps({"completed": original_completed, "failed": original_failed}, indent=2) + "\n", encoding="utf-8")
|
| 55 |
+
|
| 56 |
+
def unique(values):
|
| 57 |
+
seen, result = set(), []
|
| 58 |
+
for value in values:
|
| 59 |
+
key = _norm_task(value)
|
| 60 |
+
if key not in seen:
|
| 61 |
+
seen.add(key)
|
| 62 |
+
result.append(str(value))
|
| 63 |
+
return result
|
| 64 |
+
|
| 65 |
+
completed = unique(original_completed)
|
| 66 |
completed_norm = {_norm_task(item) for item in completed}
|
| 67 |
+
failed = [item for item in unique(original_failed) if _norm_task(item) not in completed_norm]
|
|
|
|
| 68 |
|
| 69 |
# One-time repair for failures recorded while the Creative helper
|
| 70 |
# context file was missing. Those attempts never reached the model
|
|
|
|
| 75 |
repair_marker.write_text("ok\n", encoding="utf-8")
|
| 76 |
print("[voyager] removed false starter-task failure from missing Creative context", flush=True)
|
| 77 |
|
| 78 |
+
for path, values, original in ((completed_path, completed, original_completed), (failed_path, failed, original_failed)):
|
| 79 |
+
if values != original:
|
| 80 |
+
temporary = path.with_suffix(".json.tmp")
|
| 81 |
+
temporary.write_text(json.dumps(values, indent=2) + "\n", encoding="utf-8")
|
| 82 |
+
temporary.replace(path)
|
| 83 |
except Exception as exc:
|
| 84 |
print(f"[voyager] checkpoint curriculum cleanup skipped: {exc}", flush=True)
|
| 85 |
|
voyager_stack.py
CHANGED
|
@@ -507,7 +507,40 @@ async function exploreUntil(bot, direction, maxTime = 60, callback) {
|
|
| 507 |
' observation += f"Critique: {critique}\\n\\n"',
|
| 508 |
' observation += f"Critique: {critique[:1200]}\\n\\n"',
|
| 509 |
)
|
| 510 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 511 |
|
| 512 |
# Bound curriculum memory rendered into every task proposal. The full
|
| 513 |
# persistent lists remain on disk; only the prompt view is compacted.
|
|
@@ -677,7 +710,54 @@ async function exploreUntil(bot, direction, maxTime = 60, callback) {
|
|
| 677 |
curriculum_text = curriculum_text.replace(
|
| 678 |
'", ".join(self.completed_tasks)', 'task_history(self.completed_tasks)',
|
| 679 |
).replace('", ".join(self.failed_tasks)', 'task_history(self.failed_tasks)')
|
| 680 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 681 |
|
| 682 |
# The upstream prompt contains a Survival wood-log example and forbids
|
| 683 |
# building. Appending a Creative rule leaves contradictory instructions.
|
|
@@ -1094,9 +1174,30 @@ async function exploreUntil(bot, direction, maxTime = 60, callback) {
|
|
| 1094 |
except Exception:
|
| 1095 |
return {"ready": False}
|
| 1096 |
|
| 1097 |
-
def status(self) -> dict[str, Any]:
|
| 1098 |
-
mineflayer = self._mineflayer_status()
|
| 1099 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1100 |
proc = self.proc
|
| 1101 |
return {
|
| 1102 |
"state": self.state,
|
|
@@ -1113,7 +1214,8 @@ async function exploreUntil(bot, direction, maxTime = 60, callback) {
|
|
| 1113 |
"bot_username": BOT_USERNAME,
|
| 1114 |
"bot_display_name": BOT_DISPLAY_NAME,
|
| 1115 |
"checkpoint_dir": str(CKPT_DIR),
|
| 1116 |
-
"checkpoint_persistent": str(CKPT_DIR).startswith("/persist/"),
|
|
|
|
| 1117 |
"restart_count": self.restart_count,
|
| 1118 |
"mineflayer": mineflayer,
|
| 1119 |
"log_tail": self.tail(120),
|
|
|
|
| 507 |
' observation += f"Critique: {critique}\\n\\n"',
|
| 508 |
' observation += f"Critique: {critique[:1200]}\\n\\n"',
|
| 509 |
)
|
| 510 |
+
safe_action_parser = ''' def process_ai_message(self, message):
|
| 511 |
+
# Do not concatenate fences or try the same malformed response 3 times.
|
| 512 |
+
# The caller retries with fresh feedback while Mineflayer stays connected.
|
| 513 |
+
try:
|
| 514 |
+
code = extract_action_code(message.content)
|
| 515 |
+
babel = require("@babel/core")
|
| 516 |
+
babel_generator = require("@babel/generator").default
|
| 517 |
+
parsed = babel.parse(code)
|
| 518 |
+
body = list(parsed.program.body)
|
| 519 |
+
if len(body) != 1 or body[0].type != "FunctionDeclaration" or not body[0]["async"]:
|
| 520 |
+
raise ValueError("Action must have exactly one top-level async function")
|
| 521 |
+
node = body[0]
|
| 522 |
+
params = list(node["params"])
|
| 523 |
+
if len(params) != 1 or params[0].type != "Identifier" or params[0].name != "bot":
|
| 524 |
+
raise ValueError("Action function must take exactly one bot argument")
|
| 525 |
+
return {
|
| 526 |
+
"program_code": babel_generator(node).code,
|
| 527 |
+
"program_name": node.id.name,
|
| 528 |
+
"exec_code": f"await {node.id.name}(bot);",
|
| 529 |
+
}
|
| 530 |
+
except Exception as error:
|
| 531 |
+
return f"Error parsing action response (before program execution): {error}"
|
| 532 |
+
|
| 533 |
+
'''
|
| 534 |
+
action_agent_text, action_parser_count = re.subn(
|
| 535 |
+
r'(?ms)^ def process_ai_message\(self, message\):\n.*?(?=^ def summarize_chatlog\(self, events\):)',
|
| 536 |
+
lambda match: safe_action_parser,
|
| 537 |
+
action_agent_text,
|
| 538 |
+
count=1,
|
| 539 |
+
)
|
| 540 |
+
if action_parser_count != 1:
|
| 541 |
+
raise RuntimeError("Voyager Action parser marker changed")
|
| 542 |
+
action_agent_text = 'from voyager_action_parse import extract_action_code\n' + action_agent_text
|
| 543 |
+
action_agent_path.write_text(action_agent_text, encoding="utf-8")
|
| 544 |
|
| 545 |
# Bound curriculum memory rendered into every task proposal. The full
|
| 546 |
# persistent lists remain on disk; only the prompt view is compacted.
|
|
|
|
| 710 |
curriculum_text = curriculum_text.replace(
|
| 711 |
'", ".join(self.completed_tasks)', 'task_history(self.completed_tasks)',
|
| 712 |
).replace('", ".join(self.failed_tasks)', 'task_history(self.failed_tasks)')
|
| 713 |
+
safe_curriculum_method = ''' def propose_next_ai_task(self, *, messages, max_retries=5):
|
| 714 |
+
# A local model at temperature zero can repeat one malformed composite
|
| 715 |
+
# indefinitely. Each attempt gets fresh feedback; exhaustion uses a
|
| 716 |
+
# deterministic, verified Creative task without exiting Voyager.
|
| 717 |
+
conversation = list(messages)
|
| 718 |
+
for attempt in range(max(0, min(int(max_retries), 3))):
|
| 719 |
+
curriculum = ""
|
| 720 |
+
try:
|
| 721 |
+
curriculum = self.llm(conversation).content
|
| 722 |
+
print(f"Curriculum Agent proposal: {curriculum}", flush=True)
|
| 723 |
+
next_task = choose_curriculum_task(
|
| 724 |
+
curriculum, self.completed_tasks, self.failed_tasks
|
| 725 |
+
)
|
| 726 |
+
return next_task, self.get_task_context(next_task)
|
| 727 |
+
except Exception as error:
|
| 728 |
+
print(f"Rejected Curriculum proposal {attempt + 1}: {error}", flush=True)
|
| 729 |
+
if curriculum:
|
| 730 |
+
conversation.append(AIMessage(content=curriculum[:1200]))
|
| 731 |
+
conversation.append(HumanMessage(content=(
|
| 732 |
+
f"Rejected proposal: {error}. Choose one NEW Creative objective. "
|
| 733 |
+
"Use one Task: Mine/Place/Obtain/Equip N minecraft_id or "
|
| 734 |
+
"Explore 16/24/32 blocks north/south/east/west."
|
| 735 |
+
)))
|
| 736 |
+
next_task = fallback_curriculum_task(messages, self.completed_tasks, self.failed_tasks)
|
| 737 |
+
print(f"Curriculum fallback after bounded retries: {next_task}", flush=True)
|
| 738 |
+
return next_task, self.get_task_context(next_task)
|
| 739 |
+
|
| 740 |
+
'''
|
| 741 |
+
curriculum_text, safe_curriculum_count = re.subn(
|
| 742 |
+
r'(?ms)^ def propose_next_ai_task\(self, \*, messages, max_retries=5\):\n.*?(?=^ def parse_ai_message\(self, message\):)',
|
| 743 |
+
lambda match: safe_curriculum_method,
|
| 744 |
+
curriculum_text,
|
| 745 |
+
count=1,
|
| 746 |
+
)
|
| 747 |
+
if safe_curriculum_count != 1:
|
| 748 |
+
raise RuntimeError("Voyager Curriculum method marker changed")
|
| 749 |
+
curriculum_text = curriculum_text.replace(
|
| 750 |
+
'from voyager_verification import validate_task, task_history',
|
| 751 |
+
'from voyager_verification import (choose_curriculum_task, fallback_curriculum_task, '
|
| 752 |
+
'task_history, task_spec, validate_task)',
|
| 753 |
+
1,
|
| 754 |
+
)
|
| 755 |
+
curriculum_text = curriculum_text.replace(
|
| 756 |
+
'return " ".join(str(value).lower().replace("_", " ").split())',
|
| 757 |
+
'return task_spec(value) or " ".join(str(value).lower().replace("_", " ").split())',
|
| 758 |
+
1,
|
| 759 |
+
)
|
| 760 |
+
curriculum_path.write_text(curriculum_text, encoding="utf-8")
|
| 761 |
|
| 762 |
# The upstream prompt contains a Survival wood-log example and forbids
|
| 763 |
# building. Appending a Creative rule leaves contradictory instructions.
|
|
|
|
| 1174 |
except Exception:
|
| 1175 |
return {"ready": False}
|
| 1176 |
|
| 1177 |
+
def status(self) -> dict[str, Any]:
|
| 1178 |
+
mineflayer = self._mineflayer_status()
|
| 1179 |
+
try:
|
| 1180 |
+
lines = LOG_PATH.read_text(encoding="utf-8", errors="replace").splitlines()
|
| 1181 |
+
except OSError:
|
| 1182 |
+
lines = []
|
| 1183 |
+
try:
|
| 1184 |
+
completed = json.loads((CKPT_DIR / "curriculum" / "completed_tasks.json").read_text(encoding="utf-8"))
|
| 1185 |
+
failed = json.loads((CKPT_DIR / "curriculum" / "failed_tasks.json").read_text(encoding="utf-8"))
|
| 1186 |
+
except (OSError, ValueError):
|
| 1187 |
+
completed, failed = [], []
|
| 1188 |
+
activity = {
|
| 1189 |
+
"completed_count": len(completed),
|
| 1190 |
+
"failed_count": len(failed),
|
| 1191 |
+
"recent_completed": completed[-5:],
|
| 1192 |
+
"curriculum_fallbacks": sum("Curriculum fallback after bounded retries" in line for line in lines),
|
| 1193 |
+
"action_parse_errors": sum("Error parsing action response (before program execution)" in line for line in lines),
|
| 1194 |
+
"curriculum_value_errors": sum("ValueError:" in line and "Use exactly Mine/Place" in line for line in lines),
|
| 1195 |
+
"partial_read_errors": sum("PartialReadError" in line for line in lines),
|
| 1196 |
+
"file_not_found_errors": sum("FileNotFoundError" in line for line in lines),
|
| 1197 |
+
"index_errors": sum("IndexError" in line for line in lines),
|
| 1198 |
+
"syntax_errors": sum("SyntaxError" in line for line in lines),
|
| 1199 |
+
}
|
| 1200 |
+
with self.lock:
|
| 1201 |
proc = self.proc
|
| 1202 |
return {
|
| 1203 |
"state": self.state,
|
|
|
|
| 1214 |
"bot_username": BOT_USERNAME,
|
| 1215 |
"bot_display_name": BOT_DISPLAY_NAME,
|
| 1216 |
"checkpoint_dir": str(CKPT_DIR),
|
| 1217 |
+
"checkpoint_persistent": str(CKPT_DIR).startswith("/persist/"),
|
| 1218 |
+
"activity": activity,
|
| 1219 |
"restart_count": self.restart_count,
|
| 1220 |
"mineflayer": mineflayer,
|
| 1221 |
"log_tail": self.tail(120),
|
voyager_verification.py
CHANGED
|
@@ -71,21 +71,114 @@ def validate_task(task, completed, failed):
|
|
| 71 |
return task
|
| 72 |
|
| 73 |
|
| 74 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
"""Compact legacy prose in prompts while retaining the full checkpoint."""
|
| 76 |
entries = []
|
| 77 |
legacy = 0
|
| 78 |
for task in tasks:
|
| 79 |
spec = task_spec(task)
|
| 80 |
-
if spec:
|
| 81 |
kind, count, item = spec
|
| 82 |
entry = f"{kind.title()} {count} {'blocks ' if kind == 'explore' else ''}{item}"
|
| 83 |
if entry not in entries:
|
| 84 |
entries.append(entry)
|
| 85 |
else:
|
| 86 |
legacy += 1
|
| 87 |
-
|
| 88 |
-
|
|
|
|
|
|
|
| 89 |
return ", ".join(entries) or "None"
|
| 90 |
|
| 91 |
|
|
|
|
| 71 |
return task
|
| 72 |
|
| 73 |
|
| 74 |
+
def canonical_task(value):
|
| 75 |
+
"""Convert one legacy/LLM objective into the permitted Creative grammar."""
|
| 76 |
+
spec = task_spec(value)
|
| 77 |
+
if not spec:
|
| 78 |
+
return None
|
| 79 |
+
kind, count, name = spec
|
| 80 |
+
if kind == "explore":
|
| 81 |
+
task = f"Explore {count} blocks {name}"
|
| 82 |
+
else:
|
| 83 |
+
task = f"{kind.title()} {count} {name}"
|
| 84 |
+
if not re.fullmatch(r"(?:Mine|Place|Obtain|Equip) [1-4] [a-z][a-z0-9_]*|Explore (?:16|24|32) blocks (?:north|south|east|west)", task):
|
| 85 |
+
return None
|
| 86 |
+
if kind == "equip" and count != 1:
|
| 87 |
+
return None
|
| 88 |
+
# These are either Survival starter grind or unbreakable/impractical goals.
|
| 89 |
+
if kind in {"mine", "place"} and name in {"bedrock", "obsidian", "air", "water", "lava", "barrier"}:
|
| 90 |
+
return None
|
| 91 |
+
if kind == "obtain" and name in {
|
| 92 |
+
"wood_log", "wooden_log", "planks", "stick", "sticks", "wooden_pickaxe",
|
| 93 |
+
"stone_pickaxe", "crafting_table", "furnace",
|
| 94 |
+
}:
|
| 95 |
+
return None
|
| 96 |
+
return task
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def curriculum_candidates(response):
|
| 100 |
+
"""Return single actions in order, splitting model-generated compound tasks."""
|
| 101 |
+
lines = re.findall(r"(?im)^\s*Task\s*:\s*(.+)$", str(response or ""))
|
| 102 |
+
if not lines:
|
| 103 |
+
lines = str(response or "").splitlines()
|
| 104 |
+
for line in lines:
|
| 105 |
+
for part in re.split(
|
| 106 |
+
r"\s*[,;]\s*|\s+(?:and then|then|and)\s+(?=(?:Mine|Place|Obtain|Equip|Explore)\b)",
|
| 107 |
+
line, flags=re.IGNORECASE,
|
| 108 |
+
):
|
| 109 |
+
candidate = canonical_task(part.strip(" .:-\t"))
|
| 110 |
+
if candidate:
|
| 111 |
+
yield candidate
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def choose_curriculum_task(response, completed, failed):
|
| 115 |
+
for task in curriculum_candidates(response):
|
| 116 |
+
try:
|
| 117 |
+
return validate_task(task, completed, failed)
|
| 118 |
+
except ValueError:
|
| 119 |
+
continue
|
| 120 |
+
raise ValueError("No new, valid single Creative task in Curriculum response")
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def fallback_curriculum_task(messages, completed, failed):
|
| 124 |
+
"""Use observed blocks first; otherwise rotate through safe Creative goals."""
|
| 125 |
+
observed = "\n".join(str(getattr(message, "content", message)) for message in messages)
|
| 126 |
+
nearby = re.findall(r"(?im)^Nearby blocks:\s*([^\n]+)", observed)
|
| 127 |
+
if nearby:
|
| 128 |
+
for name in nearby[-1].split(","):
|
| 129 |
+
name = name.strip().lower().replace(" ", "_")
|
| 130 |
+
if name != "none" and re.fullmatch(r"[a-z][a-z0-9_]*", name):
|
| 131 |
+
task = canonical_task(f"Mine 1 {name}")
|
| 132 |
+
if task:
|
| 133 |
+
try:
|
| 134 |
+
return validate_task(task, completed, failed)
|
| 135 |
+
except ValueError:
|
| 136 |
+
pass
|
| 137 |
+
for distance in (16, 24, 32):
|
| 138 |
+
for direction in ("north", "east", "south", "west"):
|
| 139 |
+
task = f"Explore {distance} blocks {direction}"
|
| 140 |
+
try:
|
| 141 |
+
return validate_task(task, completed, failed)
|
| 142 |
+
except ValueError:
|
| 143 |
+
pass
|
| 144 |
+
palette = (
|
| 145 |
+
"stone", "dirt", "grass_block", "cobblestone", "oak_planks", "spruce_planks",
|
| 146 |
+
"birch_planks", "sandstone", "bricks", "glass", "white_wool", "orange_wool",
|
| 147 |
+
"red_wool", "blue_wool", "green_wool", "white_concrete", "red_concrete",
|
| 148 |
+
"blue_concrete", "terracotta", "polished_andesite", "polished_granite",
|
| 149 |
+
"deepslate_bricks", "moss_block", "quartz_block", "glowstone", "sea_lantern",
|
| 150 |
+
"copper_block", "iron_block", "gold_block", "diamond_block", "emerald_block",
|
| 151 |
+
"netherite_block", "oak_log", "spruce_log", "birch_log", "snow_block",
|
| 152 |
+
)
|
| 153 |
+
for count in (1, 2, 3, 4):
|
| 154 |
+
for name in palette:
|
| 155 |
+
task = f"Place {count} {name}"
|
| 156 |
+
try:
|
| 157 |
+
return validate_task(task, completed, failed)
|
| 158 |
+
except ValueError:
|
| 159 |
+
pass
|
| 160 |
+
# The finite grammar can theoretically be exhausted. Keep Voyager alive;
|
| 161 |
+
# in practice hundreds of distinct safe objectives remain in the palette.
|
| 162 |
+
return "Explore 16 blocks north"
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def task_history(tasks, limit=48):
|
| 166 |
"""Compact legacy prose in prompts while retaining the full checkpoint."""
|
| 167 |
entries = []
|
| 168 |
legacy = 0
|
| 169 |
for task in tasks:
|
| 170 |
spec = task_spec(task)
|
| 171 |
+
if spec and canonical_task(task):
|
| 172 |
kind, count, item = spec
|
| 173 |
entry = f"{kind.title()} {count} {'blocks ' if kind == 'explore' else ''}{item}"
|
| 174 |
if entry not in entries:
|
| 175 |
entries.append(entry)
|
| 176 |
else:
|
| 177 |
legacy += 1
|
| 178 |
+
omitted = max(0, len(entries) - limit)
|
| 179 |
+
entries = entries[-limit:]
|
| 180 |
+
if legacy or omitted:
|
| 181 |
+
entries.append(f"{legacy + omitted} older objectives retained in checkpoint")
|
| 182 |
return ", ".join(entries) or "None"
|
| 183 |
|
| 184 |
|