yxc20098 commited on
Commit
247ff7a
ยท
1 Parent(s): 82e69a9

Live-smoke fixes: tool-call wire 400, episode resilience, real PNG minimap

Browse files

Three real bugs caught by the first OpenRouter run (qwen3.6):
1. 400 on turn 2: assistant tool_calls were replayed with
function.arguments as a dict; OpenAI/OpenRouter require a JSON
string. _wire_messages now coerces it (history keeps the dict for
readable playback; pure โ€” input not mutated). Error text widened
200โ†’800 chars for debuggability.
2. A fatal provider error aborted the whole sweep + lost the report.
evaluate now wraps each episode: non-Budget exception โ‡’ outcome
'error', journalled, run continues and still finalizes; Budget
still stops intentionally.
3. Minimap was delegated to the training renderer which needs a
terrain_png the bench engine never emits โ‡’ 'cannot identify image
file' โ‡’ model silently ran TEXT-ONLY. New openra_bench/minimap.py
renders a valid colour PNG from the ASCII fog grid + unit cells (no
external dep); agent uses it.
+ objective wording: {not:{own_units_gte:1}} now reads 'your whole
force is destroyed' instead of a double negative.

tests: test_minimap.py (4, incl PIL re-open + agent attach),
test_resilience.py +2 (wire-args, episode error continues). 30+
touched-module tests green.

openra_bench/agent.py CHANGED
@@ -378,18 +378,9 @@ def _render_minimap_b64(render_state: dict) -> str | None:
378
  """Best-effort minimap PNG. Returns None (text-only fallback) when
379
  terrain isn't resolvable โ€” vision degrades gracefully in Phase 0."""
380
  try:
381
- from openra_rl_training.training.minimap_renderer import render_minimap
382
-
383
- return render_minimap(
384
- terrain_png=render_state.get("terrain_png"), # None in Phase 0 -> None
385
- map_width=render_state.get("map_width", 64),
386
- map_height=render_state.get("map_height", 64),
387
- bounds_x=render_state.get("bounds_x", 0),
388
- bounds_y=render_state.get("bounds_y", 0),
389
- own_units=render_state.get("units_summary", []) or [],
390
- enemy_units=render_state.get("enemy_summary", []) or [],
391
- ascii_minimap=render_state.get("minimap", ""),
392
- )
393
  except Exception as e: # noqa: BLE001 โ€” vision is optional
394
  logger.debug("minimap render skipped: %s", e)
395
  return None
 
378
  """Best-effort minimap PNG. Returns None (text-only fallback) when
379
  terrain isn't resolvable โ€” vision degrades gracefully in Phase 0."""
380
  try:
381
+ from .minimap import render_png_b64
382
+
383
+ return render_png_b64(render_state)
 
 
 
 
 
 
 
 
 
384
  except Exception as e: # noqa: BLE001 โ€” vision is optional
385
  logger.debug("minimap render skipped: %s", e)
386
  return None
openra_bench/game_knowledge.py CHANGED
@@ -196,7 +196,19 @@ def _describe(node: Any, join: str = " AND ") -> str:
196
  if "any_of" in node:
197
  return "(" + " OR ".join(_describe(c) for c in node["any_of"]) + ")"
198
  if "not" in node:
199
- return "NOT (" + _describe(node["not"]) + ")"
 
 
 
 
 
 
 
 
 
 
 
 
200
  return join.join(_leaf_phrase(k, v) for k, v in node.items())
201
 
202
 
 
196
  if "any_of" in node:
197
  return "(" + " OR ".join(_describe(c) for c in node["any_of"]) + ")"
198
  if "not" in node:
199
+ inner = node["not"]
200
+ inner_d = inner if isinstance(inner, dict) else dict(
201
+ getattr(inner, "__pydantic_extra__", {}) or {}
202
+ )
203
+ # Common fail form {not: {own_units_gte: N}} reads far clearer
204
+ # as a plain loss statement than a double negative.
205
+ if set(inner_d) == {"own_units_gte"}:
206
+ n = inner_d["own_units_gte"]
207
+ return (
208
+ "your whole force is destroyed" if int(n) <= 1
209
+ else f"fewer than {n} of your units remain"
210
+ )
211
+ return "NOT (" + _describe(inner) + ")"
212
  return join.join(_leaf_phrase(k, v) for k, v in node.items())
213
 
214
 
openra_bench/minimap.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Self-contained minimap PNG (no training-repo / terrain_png dep).
2
+
3
+ The bench engine emits an ASCII fog grid (`#`=unknown, `.`=explored)
4
+ plus unit/building cell lists โ€” enough to draw a real, legible colour
5
+ minimap the model can actually see. Pure; returns base64 PNG, or None
6
+ if Pillow is missing or there's nothing to draw (graceful text-only
7
+ fallback).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import base64
13
+ import io
14
+ import logging
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ CELL = 6 # px per map cell (โ‰ˆ768ร—240 for a 128ร—40 map โ€” legible)
19
+
20
+ # (R,G,B)
21
+ _BG_UNKNOWN = (18, 18, 22) # '#' fog / never seen
22
+ _BG_EXPLORED = (70, 74, 82) # '.' revealed terrain
23
+ _OWN = (60, 200, 90) # your units
24
+ _OWN_BLD = (60, 130, 230) # your buildings
25
+ _ENEMY = (225, 60, 55) # enemy units
26
+ _ENEMY_BLD = (240, 160, 40) # enemy buildings
27
+
28
+
29
+ def _draw_cell(px, w: int, h: int, cx: int, cy: int, rgb, r: int = 1) -> None:
30
+ for yy in range(max(0, cy - r), min(h, cy + r + 1)):
31
+ for xx in range(max(0, cx - r), min(w, cx + r + 1)):
32
+ for sy in range(CELL):
33
+ for sx in range(CELL):
34
+ px[xx * CELL + sx, yy * CELL + sy] = rgb
35
+
36
+
37
+ def render_png_b64(render_state: dict) -> str | None:
38
+ try:
39
+ from PIL import Image
40
+ except Exception: # noqa: BLE001 โ€” vision optional
41
+ return None
42
+
43
+ ascii_mm = render_state.get("minimap") or ""
44
+ rows = [r for r in ascii_mm.split("\n") if r]
45
+ if not rows:
46
+ return None
47
+ h = len(rows)
48
+ w = max(len(r) for r in rows)
49
+ if w == 0 or w * h > 200_000: # sanity cap
50
+ return None
51
+
52
+ img = Image.new("RGB", (w * CELL, h * CELL), _BG_UNKNOWN)
53
+ px = img.load()
54
+ for y, row in enumerate(rows):
55
+ for x, ch in enumerate(row):
56
+ if ch != "#": # explored / visible
57
+ for sy in range(CELL):
58
+ for sx in range(CELL):
59
+ px[x * CELL + sx, y * CELL + sy] = _BG_EXPLORED
60
+
61
+ def _plot(items, rgb_unit, rgb_bld):
62
+ for it in items or []:
63
+ if not isinstance(it, dict):
64
+ continue
65
+ cx, cy = int(it.get("cell_x", 0)), int(it.get("cell_y", 0))
66
+ if 0 <= cx < w and 0 <= cy < h:
67
+ is_b = bool(it.get("is_building"))
68
+ _draw_cell(px, w, h, cx, cy, rgb_bld if is_b else rgb_unit,
69
+ r=1 if is_b else 0)
70
+
71
+ _plot(render_state.get("units_summary"), _OWN, _OWN_BLD)
72
+ _plot(render_state.get("own_buildings"), _OWN_BLD, _OWN_BLD)
73
+ _plot(render_state.get("enemy_summary"), _ENEMY, _ENEMY_BLD)
74
+
75
+ try:
76
+ buf = io.BytesIO()
77
+ img.save(buf, format="PNG")
78
+ return base64.b64encode(buf.getvalue()).decode("ascii")
79
+ except Exception as e: # noqa: BLE001
80
+ logger.debug("minimap encode failed: %s", e)
81
+ return None
openra_bench/providers.py CHANGED
@@ -144,7 +144,7 @@ class OpenAICompatibleProvider(ChatProvider):
144
  transient = self._policy.is_transient_status(resp.status_code)
145
  cls = RuntimeError if transient else FatalProviderError
146
  exc = cls(
147
- f"{resp.status_code} from provider: {resp.text[:200]}"
148
  )
149
  exc.transient = transient # type: ignore[attr-defined]
150
  exc.retry_after = retry_after # type: ignore[attr-defined]
@@ -190,11 +190,28 @@ class OpenAICompatibleProvider(ChatProvider):
190
 
191
  @staticmethod
192
  def _wire_messages(messages: list[dict]) -> list[dict]:
193
- """Pure: project each message onto OpenAI-legal keys only."""
194
- return [
195
- {k: v for k, v in m.items() if k in OpenAICompatibleProvider._WIRE_KEYS}
196
- for m in messages
197
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
 
199
  @staticmethod
200
  def _reply_from_data(data: dict) -> ChatReply:
 
144
  transient = self._policy.is_transient_status(resp.status_code)
145
  cls = RuntimeError if transient else FatalProviderError
146
  exc = cls(
147
+ f"{resp.status_code} from provider: {resp.text[:800]}"
148
  )
149
  exc.transient = transient # type: ignore[attr-defined]
150
  exc.retry_after = retry_after # type: ignore[attr-defined]
 
190
 
191
  @staticmethod
192
  def _wire_messages(messages: list[dict]) -> list[dict]:
193
+ """Pure: project each message onto OpenAI-legal keys only, and
194
+ coerce `tool_calls[].function.arguments` to a JSON **string**
195
+ (the wire spec requires a string; history keeps the dict for
196
+ readable playback). Pure โ€” inputs are not mutated."""
197
+ out: list[dict] = []
198
+ for m in messages:
199
+ wm = {
200
+ k: v for k, v in m.items()
201
+ if k in OpenAICompatibleProvider._WIRE_KEYS
202
+ }
203
+ tcs = wm.get("tool_calls")
204
+ if tcs:
205
+ fixed = []
206
+ for tc in tcs:
207
+ fn = dict(tc.get("function", {}))
208
+ args = fn.get("arguments", {})
209
+ if not isinstance(args, str):
210
+ fn["arguments"] = json.dumps(args)
211
+ fixed.append({**tc, "function": fn})
212
+ wm["tool_calls"] = fixed
213
+ out.append(wm)
214
+ return out
215
 
216
  @staticmethod
217
  def _reply_from_data(data: dict) -> ChatReply:
openra_bench/run_eval.py CHANGED
@@ -295,18 +295,48 @@ def evaluate(
295
  pass
296
 
297
  try:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
298
  if concurrency > 1 and len(tasks) > 1:
299
  from concurrent.futures import ThreadPoolExecutor
300
 
301
  with ThreadPoolExecutor(max_workers=concurrency) as ex:
302
- futs = {ex.submit(_run_one, t): t for t in tasks}
303
  from concurrent.futures import as_completed
304
 
305
  for fu in as_completed(futs):
306
  _record(fu.result())
307
  else:
308
  for t in tasks:
309
- _record(_run_one(t))
310
  except BudgetExceeded as e:
311
  truncated = True
312
  skipped.append(f"BUDGET STOP: {e}")
 
295
  pass
296
 
297
  try:
298
+ def _safe_run(task: tuple) -> dict:
299
+ # One bad episode (fatal provider 400, engine crash, โ€ฆ) must
300
+ # not abort a multi-hour sweep or lose the report โ€” record
301
+ # it as outcome="error" and continue. Budget is the only
302
+ # signal that intentionally stops the whole run.
303
+ compiled, cell, split, seed = task
304
+ try:
305
+ return _run_one(task)
306
+ except BudgetExceeded:
307
+ raise
308
+ except Exception as e: # noqa: BLE001
309
+ msg = f"{type(e).__name__}: {e}"
310
+ return {
311
+ "cell": cell,
312
+ "capability": compiled.meta.capability,
313
+ "split": split,
314
+ "seed": seed,
315
+ "outcome": "error",
316
+ "composite": 0.0,
317
+ "perception": 0.0,
318
+ "reasoning": 0.0,
319
+ "action": 0.0,
320
+ "weakest_link": "n/a",
321
+ "objective_progress": 0.0,
322
+ "reward_vector": {},
323
+ "turns": 0,
324
+ "notes": [msg[:500]],
325
+ "_sc": None,
326
+ }
327
+
328
  if concurrency > 1 and len(tasks) > 1:
329
  from concurrent.futures import ThreadPoolExecutor
330
 
331
  with ThreadPoolExecutor(max_workers=concurrency) as ex:
332
+ futs = {ex.submit(_safe_run, t): t for t in tasks}
333
  from concurrent.futures import as_completed
334
 
335
  for fu in as_completed(futs):
336
  _record(fu.result())
337
  else:
338
  for t in tasks:
339
+ _record(_safe_run(t))
340
  except BudgetExceeded as e:
341
  truncated = True
342
  skipped.append(f"BUDGET STOP: {e}")
tests/test_minimap.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Bench-native minimap PNG: must produce a *valid* image (the live
2
+ smoke caught the old training-repo path emitting bytes PIL couldn't
3
+ open, so the model silently ran text-only)."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import base64
8
+ import io
9
+
10
+ import pytest
11
+
12
+ from openra_bench.minimap import render_png_b64
13
+
14
+ PIL = pytest.importorskip("PIL")
15
+ from PIL import Image # noqa: E402
16
+
17
+
18
+ def _grid(w, h, explored_cols=0):
19
+ return "\n".join(
20
+ "".join("." if x < explored_cols else "#" for x in range(w))
21
+ for _ in range(h)
22
+ )
23
+
24
+
25
+ def test_renders_a_valid_png_pil_can_reopen():
26
+ rs = {
27
+ "minimap": _grid(128, 40, explored_cols=20),
28
+ "units_summary": [{"cell_x": 6, "cell_y": 8}],
29
+ "enemy_summary": [
30
+ {"cell_x": 34, "cell_y": 22, "is_building": False},
31
+ {"cell_x": 40, "cell_y": 5, "is_building": True},
32
+ ],
33
+ }
34
+ b64 = render_png_b64(rs)
35
+ assert isinstance(b64, str) and b64
36
+ im = Image.open(io.BytesIO(base64.b64decode(b64))) # must NOT raise
37
+ im.verify()
38
+ assert im.format == "PNG"
39
+ assert im.size == (128 * 6, 40 * 6)
40
+
41
+
42
+ def test_unit_and_fog_pixels_are_distinct():
43
+ rs = {
44
+ "minimap": _grid(20, 10, explored_cols=0), # all fog
45
+ "units_summary": [{"cell_x": 5, "cell_y": 5}],
46
+ }
47
+ im = Image.open(
48
+ io.BytesIO(base64.b64decode(render_png_b64(rs)))
49
+ ).convert("RGB")
50
+ own = im.getpixel((5 * 6 + 3, 5 * 6 + 3)) # on the unit
51
+ fog = im.getpixel((0, 0)) # fog corner
52
+ assert own != fog
53
+ assert own[1] > own[0] and own[1] > own[2] # green-ish own unit
54
+
55
+
56
+ def test_graceful_none_when_nothing_to_draw():
57
+ assert render_png_b64({"minimap": ""}) is None
58
+ assert render_png_b64({}) is None
59
+
60
+
61
+ def test_agent_attaches_image_when_vision_on():
62
+ from openra_bench.agent import ModelAgent
63
+ from openra_bench.providers import ProviderConfig
64
+
65
+ a = ModelAgent(ProviderConfig(vision=True), allowed_tools=["observe"],
66
+ provider=type("P", (), {"complete": lambda *a, **k: None})())
67
+ msg = a._user_message({
68
+ "minimap": _grid(16, 8, 4),
69
+ "units_summary": [{"id": "1001", "cell_x": 1, "cell_y": 1}],
70
+ "enemy_summary": [],
71
+ })
72
+ assert isinstance(msg["content"], list)
73
+ kinds = [p["type"] for p in msg["content"]]
74
+ assert "image_url" in kinds and "text" in kinds
75
+ url = next(p["image_url"]["url"] for p in msg["content"]
76
+ if p["type"] == "image_url")
77
+ assert url.startswith("data:image/png;base64,")
78
+ # the attached payload is itself a valid PNG
79
+ Image.open(io.BytesIO(base64.b64decode(url.split(",", 1)[1]))).verify()
tests/test_resilience.py CHANGED
@@ -166,3 +166,55 @@ def test_evaluate_journal_resume_is_lossless(tmp_path):
166
  assert b["resumed"] == 2 and b["overall"]["n"] == 2
167
  assert len(jp.read_text().splitlines()) == 2 # nothing re-appended
168
  assert "cost" in b and "truncated" in b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
  assert b["resumed"] == 2 and b["overall"]["n"] == 2
167
  assert len(jp.read_text().splitlines()) == 2 # nothing re-appended
168
  assert "cost" in b and "truncated" in b
169
+
170
+
171
+ # โ”€โ”€ wire: tool_call arguments must be a JSON string (OpenRouter 400) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
172
+
173
+
174
+ def test_wire_messages_serializes_tool_call_arguments():
175
+ from openra_bench.providers import OpenAICompatibleProvider as P
176
+
177
+ hist = [
178
+ {"role": "system", "content": "s"},
179
+ {"role": "assistant", "content": "", "reasoning": "drop me",
180
+ "tool_calls": [{"id": "c0", "type": "function",
181
+ "function": {"name": "move_units",
182
+ "arguments": {"unit_ids": [1],
183
+ "target_x": 5}}}]},
184
+ {"role": "tool", "tool_call_id": "c0", "content": "ok"},
185
+ ]
186
+ wire = P._wire_messages(hist)
187
+ args = wire[1]["tool_calls"][0]["function"]["arguments"]
188
+ assert isinstance(args, str) # JSON string, not dict
189
+ assert json.loads(args) == {"unit_ids": [1], "target_x": 5}
190
+ assert "reasoning" not in wire[1] # playback-only key stripped
191
+ # pure: original history untouched (still a dict for playback)
192
+ assert isinstance(
193
+ hist[1]["tool_calls"][0]["function"]["arguments"], dict
194
+ )
195
+ # already-string args are left alone
196
+ hist[1]["tool_calls"][0]["function"]["arguments"] = '{"a":1}'
197
+ assert P._wire_messages(hist)[1]["tool_calls"][0]["function"][
198
+ "arguments"
199
+ ] == '{"a":1}'
200
+
201
+
202
+ def test_evaluate_continues_past_a_failing_episode(tmp_path):
203
+ pytest.importorskip("openra_train")
204
+ from openra_bench.run_eval import evaluate
205
+
206
+ PACK = Path("openra_bench/scenarios/packs/perception-frontier-reading.yaml")
207
+
208
+ def boom_factory(_compiled):
209
+ def agent_fn(_rs, _Command):
210
+ raise RuntimeError("simulated fatal provider 400")
211
+ return agent_fn
212
+
213
+ out = evaluate([PACK], ["easy"], [1, 2], agent_factory=boom_factory,
214
+ journal_path=tmp_path / "j.jsonl")
215
+ eps = out["episodes"]
216
+ assert len(eps) == 2
217
+ assert all(e["outcome"] == "error" for e in eps) # recorded, not raised
218
+ assert "overall" in out # report still produced
219
+ # journal captured them so --resume won't re-run the errored ones
220
+ assert len((tmp_path / "j.jsonl").read_text().splitlines()) == 2