Spaces:
Running
Live-smoke fixes: tool-call wire 400, episode resilience, real PNG minimap
Browse filesThree 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 +3 -12
- openra_bench/game_knowledge.py +13 -1
- openra_bench/minimap.py +81 -0
- openra_bench/providers.py +23 -6
- openra_bench/run_eval.py +32 -2
- tests/test_minimap.py +79 -0
- tests/test_resilience.py +52 -0
|
@@ -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
|
| 382 |
-
|
| 383 |
-
return
|
| 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
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|
|
@@ -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
|
|
@@ -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[:
|
| 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 |
-
|
| 195 |
-
|
| 196 |
-
|
| 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:
|
|
@@ -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(
|
| 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(
|
| 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}")
|
|
@@ -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()
|
|
@@ -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
|