naldo commited on
Commit
31408fc
·
1 Parent(s): 24010a2

Bound Creative agent parsing and remove faulty respawn mod

Browse files
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
- # Voyager documentation config for Better Respawn. The actual 1.19 mod
298
- # uses config/better_respawn/better_respawn.properties.
299
- config_dir = JAVA_DIR / "config" / "better_respawn"
300
- config_dir.mkdir(parents=True, exist_ok=True)
301
- (config_dir / "better_respawn.properties").write_text(
302
- "respawn_block_range=32\n"
303
- "max_respawn_distance=32\n"
304
- "min_respawn_distance=0\n",
305
- encoding="utf-8",
306
- )
307
- _log(
308
- "Official Voyager Fabric mods ready: Fabric API 0.58.0+1.19, "
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 validate_task, verify_task, quarantine_invalid_skills, task_history
 
 
 
 
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, VOYAGER_HOME=Path("upstream"), quarantine_invalid_skills=quarantine_invalid_skills)
 
 
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, "validate_task": validate_task, "task_history": task_history}
 
 
 
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 "Task already completed" in retry[-1].content
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 "Creative mode rejects starter crafting grind" in curriculum
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) -> 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
- completed = json.loads(completed_path.read_text(encoding="utf-8"))
50
- failed = json.loads(failed_path.read_text(encoding="utf-8"))
51
- completed = list(dict.fromkeys(str(item) for item in completed))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  completed_norm = {_norm_task(item) for item in completed}
53
- failed = list(dict.fromkeys(str(item) for item in 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
- completed_path.write_text(json.dumps(completed, indent=2) + "\n", encoding="utf-8")
66
- failed_path.write_text(json.dumps(failed, indent=2) + "\n", encoding="utf-8")
 
 
 
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
- action_agent_path.write_text(action_agent_text, encoding="utf-8")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- curriculum_path.write_text(curriculum_text, encoding="utf-8")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- with self.lock:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 task_history(tasks):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- if legacy:
88
- entries.append(f"{legacy} older objectives retained in checkpoint")
 
 
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