Spaces:
Running
Running
Playback: capture model reasoning + per-turn goal tracker + viewer
Browse files- providers: ChatReply.reasoning; extract reasoning_content/reasoning
from the raw reply; _wire_messages() strips playback-only keys so
reasoning never posts back (strict vLLM-safe), _reply_from_data pure
- agent: persist reply.reasoning on the assistant history turn
- goal_tracker: leaf_progress (win-cond predicate progress) +
reward_vector (normalized cumulative, scenario-agnostic) + turn_goal
bundling both side by side
- playback.record_turn records goal; eval_core wires turn_goal
- playback_view.load_episode (tested data layer) + Streamlit viewer
(scripts/view_playback.py): minimap, briefing, reasoning, tool_calls,
signals, objective bars + reward vector
- tests: test_playback_completeness.py (7) — 299 passed, 1 skipped
- openra_bench/agent.py +4 -0
- openra_bench/eval_core.py +5 -1
- openra_bench/goal_tracker.py +131 -0
- openra_bench/playback.py +4 -0
- openra_bench/playback_view.py +116 -0
- openra_bench/providers.py +35 -3
- scripts/view_playback.py +11 -0
- tests/test_playback_completeness.py +164 -0
openra_bench/agent.py
CHANGED
|
@@ -421,6 +421,10 @@ class ModelAgent:
|
|
| 421 |
{
|
| 422 |
"role": "assistant",
|
| 423 |
"content": reply.text or "",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 424 |
"tool_calls": [
|
| 425 |
{
|
| 426 |
"id": f"c{i}",
|
|
|
|
| 421 |
{
|
| 422 |
"role": "assistant",
|
| 423 |
"content": reply.text or "",
|
| 424 |
+
# Playback-only: the wire layer (providers._wire_messages)
|
| 425 |
+
# strips this before posting, so it never goes back to
|
| 426 |
+
# the model but is preserved in messages.json.
|
| 427 |
+
"reasoning": reply.reasoning or "",
|
| 428 |
"tool_calls": [
|
| 429 |
{
|
| 430 |
"id": f"c{i}",
|
openra_bench/eval_core.py
CHANGED
|
@@ -217,8 +217,12 @@ def run_level(
|
|
| 217 |
_png = _render_minimap_b64(rs)
|
| 218 |
except Exception: # noqa: BLE001 — playback never breaks a run
|
| 219 |
pass
|
|
|
|
|
|
|
| 220 |
playback.record_turn(
|
| 221 |
-
turns, rs, cmds, adapter.signals, _png,
|
|
|
|
|
|
|
| 222 |
)
|
| 223 |
trace.append(
|
| 224 |
{
|
|
|
|
| 217 |
_png = _render_minimap_b64(rs)
|
| 218 |
except Exception: # noqa: BLE001 — playback never breaks a run
|
| 219 |
pass
|
| 220 |
+
from .goal_tracker import turn_goal
|
| 221 |
+
|
| 222 |
playback.record_turn(
|
| 223 |
+
turns, rs, cmds, adapter.signals, _png,
|
| 224 |
+
interrupt=interrupt,
|
| 225 |
+
goal=turn_goal(compiled.win_condition, ctx),
|
| 226 |
)
|
| 227 |
trace.append(
|
| 228 |
{
|
openra_bench/goal_tracker.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Per-turn goal tracking for playback.
|
| 2 |
+
|
| 3 |
+
Two complementary views of "how is the agent doing toward the
|
| 4 |
+
objective", recorded every turn so a replay shows the trajectory of
|
| 5 |
+
intent, not just the final verdict:
|
| 6 |
+
|
| 7 |
+
* ``leaf_progress`` — walks the scenario's declarative win-condition
|
| 8 |
+
tree and, for every leaf predicate, reports current vs. target and a
|
| 9 |
+
0..1 ratio. Scenario-specific, directly tied to *this* objective.
|
| 10 |
+
* ``reward_vector`` — a fixed, normalized, monotone-cumulative vector
|
| 11 |
+
(economy / military / territory / scouting / objective) that is
|
| 12 |
+
scenario-agnostic and therefore comparable across runs and on the
|
| 13 |
+
leaderboard. Mirrors the training rollout's per-turn reward_vector.
|
| 14 |
+
|
| 15 |
+
``turn_goal`` bundles both side by side plus a scalar
|
| 16 |
+
``objective_progress`` and the boolean ``won``.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
from typing import Any
|
| 22 |
+
|
| 23 |
+
from .scenarios.win_conditions import LEAF_KEYS, WinContext, evaluate
|
| 24 |
+
|
| 25 |
+
# Current-value extractors for the numeric leaf predicates. Anything not
|
| 26 |
+
# listed is treated as a pure boolean gate (ratio = 1.0 iff satisfied).
|
| 27 |
+
_CURRENT: dict[str, Any] = {
|
| 28 |
+
"explored_pct_gte": lambda c: c.signals.explored_percent,
|
| 29 |
+
"enemies_discovered_gte": lambda c: len(c.signals.enemies_seen_ids),
|
| 30 |
+
"buildings_discovered_gte": lambda c: len(c.signals.enemy_buildings_seen_ids),
|
| 31 |
+
"units_killed_gte": lambda c: c.signals.units_killed,
|
| 32 |
+
"units_lost_lte": lambda c: c.signals.units_lost,
|
| 33 |
+
"within_ticks": lambda c: c.signals.game_tick,
|
| 34 |
+
"after_ticks": lambda c: c.signals.game_tick,
|
| 35 |
+
"own_units_gte": lambda c: len(c.render_state.get("units_summary", []) or []),
|
| 36 |
+
"cash_gte": lambda c: c.signals.cash,
|
| 37 |
+
"resources_gte": lambda c: c.signals.resources,
|
| 38 |
+
"economy_value_gte": lambda c: c.signals.cash + c.signals.resources,
|
| 39 |
+
"power_surplus_gte": lambda c: c.signals.power_provided
|
| 40 |
+
- c.signals.power_drained,
|
| 41 |
+
"buildings_owned_gte": lambda c: len(c.signals.own_building_types),
|
| 42 |
+
"building_total_gte": lambda c: len(c.signals.own_buildings),
|
| 43 |
+
}
|
| 44 |
+
# `_lte` predicates are satisfied while *below* the bound (constraints).
|
| 45 |
+
_LTE = frozenset({"units_lost_lte", "within_ticks"})
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _clamp(x: float) -> float:
|
| 49 |
+
return 0.0 if x < 0.0 else 1.0 if x > 1.0 else float(x)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _leaves(node: Any) -> list[dict]:
|
| 53 |
+
"""Flatten a win-condition tree to its leaf predicate dicts."""
|
| 54 |
+
if node is None:
|
| 55 |
+
return []
|
| 56 |
+
if not isinstance(node, dict):
|
| 57 |
+
node = dict(getattr(node, "__pydantic_extra__", {}) or {})
|
| 58 |
+
out: list[dict] = []
|
| 59 |
+
for k, v in node.items():
|
| 60 |
+
if k in ("all_of", "any_of"):
|
| 61 |
+
for child in v:
|
| 62 |
+
out.extend(_leaves(child))
|
| 63 |
+
elif k == "not":
|
| 64 |
+
out.extend(_leaves(v))
|
| 65 |
+
elif k in LEAF_KEYS:
|
| 66 |
+
out.append({k: v})
|
| 67 |
+
return out
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def leaf_progress(win_condition: Any, ctx: WinContext) -> list[dict]:
|
| 71 |
+
rows: list[dict] = []
|
| 72 |
+
for leaf in _leaves(win_condition):
|
| 73 |
+
(name, target), = leaf.items()
|
| 74 |
+
satisfied = bool(evaluate({name: target}, ctx))
|
| 75 |
+
cur_fn = _CURRENT.get(name)
|
| 76 |
+
if cur_fn is None or not isinstance(target, (int, float)):
|
| 77 |
+
rows.append({
|
| 78 |
+
"name": name, "target": target, "current": None,
|
| 79 |
+
"ratio": 1.0 if satisfied else 0.0, "satisfied": satisfied,
|
| 80 |
+
})
|
| 81 |
+
continue
|
| 82 |
+
cur = cur_fn(ctx)
|
| 83 |
+
tgt = float(target)
|
| 84 |
+
if name in _LTE:
|
| 85 |
+
ratio = 1.0 if satisfied else _clamp(tgt / cur) if cur else 1.0
|
| 86 |
+
else:
|
| 87 |
+
ratio = 1.0 if satisfied else (_clamp(cur / tgt) if tgt else 0.0)
|
| 88 |
+
rows.append({
|
| 89 |
+
"name": name, "target": target, "current": cur,
|
| 90 |
+
"ratio": round(ratio, 4), "satisfied": satisfied,
|
| 91 |
+
})
|
| 92 |
+
return rows
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def reward_vector(signals: Any) -> dict[str, float]:
|
| 96 |
+
"""Normalized, scenario-agnostic cumulative progress vector.
|
| 97 |
+
|
| 98 |
+
All dimensions are 0..1 and (because the underlying signals are
|
| 99 |
+
monotone over an episode) non-decreasing turn over turn — a true
|
| 100 |
+
cumulative tracker, comparable across scenarios.
|
| 101 |
+
"""
|
| 102 |
+
cash = getattr(signals, "cash", 0) + getattr(signals, "resources", 0)
|
| 103 |
+
kills = getattr(signals, "units_killed", 0)
|
| 104 |
+
seen = len(getattr(signals, "enemies_seen_ids", []) or []) + len(
|
| 105 |
+
getattr(signals, "enemy_buildings_seen_ids", []) or []
|
| 106 |
+
)
|
| 107 |
+
return {
|
| 108 |
+
"economy": round(_clamp(cash / 10000.0), 4),
|
| 109 |
+
"military": round(_clamp(kills / 10.0), 4),
|
| 110 |
+
"territory": round(_clamp(
|
| 111 |
+
getattr(signals, "explored_percent", 0.0) / 100.0), 4),
|
| 112 |
+
"scouting": round(_clamp(seen / 10.0), 4),
|
| 113 |
+
"objective": 0.0, # filled by turn_goal once the win cond is known
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def turn_goal(win_condition: Any, ctx: WinContext) -> dict:
|
| 118 |
+
leaves = leaf_progress(win_condition, ctx)
|
| 119 |
+
won = bool(evaluate(win_condition, ctx))
|
| 120 |
+
obj = 1.0 if won else (
|
| 121 |
+
round(sum(r["ratio"] for r in leaves) / len(leaves), 4)
|
| 122 |
+
if leaves else 0.0
|
| 123 |
+
)
|
| 124 |
+
rv = reward_vector(ctx.signals)
|
| 125 |
+
rv["objective"] = obj
|
| 126 |
+
return {
|
| 127 |
+
"leaves": leaves,
|
| 128 |
+
"reward_vector": rv,
|
| 129 |
+
"objective_progress": obj,
|
| 130 |
+
"won": won,
|
| 131 |
+
}
|
openra_bench/playback.py
CHANGED
|
@@ -58,6 +58,7 @@ class Playback:
|
|
| 58 |
signals: Any,
|
| 59 |
minimap_png_b64: str | None = None,
|
| 60 |
interrupt: str | None = None,
|
|
|
|
| 61 |
) -> None:
|
| 62 |
self._n += 1
|
| 63 |
if minimap_png_b64:
|
|
@@ -88,6 +89,9 @@ class Playback:
|
|
| 88 |
),
|
| 89 |
"units": render_state.get("units_summary", []),
|
| 90 |
"enemies": render_state.get("enemy_summary", []),
|
|
|
|
|
|
|
|
|
|
| 91 |
}
|
| 92 |
self._turns_fh.write(json.dumps(_jsonable(rec)) + "\n")
|
| 93 |
self._turns_fh.flush()
|
|
|
|
| 58 |
signals: Any,
|
| 59 |
minimap_png_b64: str | None = None,
|
| 60 |
interrupt: str | None = None,
|
| 61 |
+
goal: dict | None = None,
|
| 62 |
) -> None:
|
| 63 |
self._n += 1
|
| 64 |
if minimap_png_b64:
|
|
|
|
| 89 |
),
|
| 90 |
"units": render_state.get("units_summary", []),
|
| 91 |
"enemies": render_state.get("enemy_summary", []),
|
| 92 |
+
# Per-turn goal tracker: win-condition leaf progress AND the
|
| 93 |
+
# normalized cumulative reward vector, side by side.
|
| 94 |
+
"goal": goal or {},
|
| 95 |
}
|
| 96 |
self._turns_fh.write(json.dumps(_jsonable(rec)) + "\n")
|
| 97 |
self._turns_fh.flush()
|
openra_bench/playback_view.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Playback viewer (pipeline step 7, read side).
|
| 2 |
+
|
| 3 |
+
`load_episode` is the pure data layer — it reassembles a saved episode
|
| 4 |
+
(manifest + turns + transcript + per-turn minimap PNG paths) into one
|
| 5 |
+
dict, and is unit-tested. `render_streamlit` is a thin optional UI on
|
| 6 |
+
top of it (mirrors the training repo's Streamlit pipeline viewer):
|
| 7 |
+
|
| 8 |
+
pip install streamlit
|
| 9 |
+
streamlit run scripts/view_playback.py -- <playback_root>
|
| 10 |
+
|
| 11 |
+
Per turn it shows: the minimap, the user briefing, the model's
|
| 12 |
+
reasoning + tool calls, the signal snapshot, and the goal tracker
|
| 13 |
+
(win-condition leaf bars + the cumulative reward vector).
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import json
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def load_episode(ep_dir: str | Path) -> dict:
|
| 23 |
+
"""Reassemble one ``seed<N>`` episode folder. Tolerant of a still-
|
| 24 |
+
running episode (missing files become empty)."""
|
| 25 |
+
d = Path(ep_dir)
|
| 26 |
+
manifest = _read_json(d / "manifest.json", {})
|
| 27 |
+
messages = _read_json(d / "messages.json", [])
|
| 28 |
+
turns = []
|
| 29 |
+
tj = d / "turns.jsonl"
|
| 30 |
+
if tj.exists():
|
| 31 |
+
for line in tj.read_text().splitlines():
|
| 32 |
+
line = line.strip()
|
| 33 |
+
if line:
|
| 34 |
+
rec = json.loads(line)
|
| 35 |
+
png = d / f"minimap_turn{rec.get('turn', 0):03d}.png"
|
| 36 |
+
rec["minimap_png"] = str(png) if png.exists() else None
|
| 37 |
+
turns.append(rec)
|
| 38 |
+
return {"dir": str(d), "manifest": manifest, "turns": turns,
|
| 39 |
+
"messages": messages}
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def find_episodes(root: str | Path) -> list[Path]:
|
| 43 |
+
"""All ``.../seed<N>`` episode dirs under a playback root."""
|
| 44 |
+
return sorted(
|
| 45 |
+
p.parent for p in Path(root).glob("**/manifest.json")
|
| 46 |
+
) or sorted(Path(root).glob("**/seed*"))
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _read_json(p: Path, default):
|
| 50 |
+
try:
|
| 51 |
+
return json.loads(p.read_text())
|
| 52 |
+
except Exception: # noqa: BLE001 — partial/running episode
|
| 53 |
+
return default
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _assistant_turns(messages: list[dict]) -> list[dict]:
|
| 57 |
+
return [m for m in messages if m.get("role") == "assistant"]
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def render_streamlit(root: str) -> None: # pragma: no cover - UI glue
|
| 61 |
+
import streamlit as st
|
| 62 |
+
|
| 63 |
+
st.set_page_config(page_title="OpenRA-Bench playback", layout="wide")
|
| 64 |
+
eps = find_episodes(root)
|
| 65 |
+
if not eps:
|
| 66 |
+
st.error(f"no episodes under {root}")
|
| 67 |
+
return
|
| 68 |
+
pick = st.sidebar.selectbox(
|
| 69 |
+
"episode", eps, format_func=lambda p: f"{p.parent.name}/{p.name}"
|
| 70 |
+
)
|
| 71 |
+
ep = load_episode(pick)
|
| 72 |
+
m = ep["manifest"]
|
| 73 |
+
st.title(f"{m.get('scenario', pick)} — {m.get('outcome', '?')}")
|
| 74 |
+
st.caption(
|
| 75 |
+
f"turns {m.get('turns')}/{m.get('max_turns')} · "
|
| 76 |
+
f"capability {m.get('capability')} · seed {m.get('seed')}"
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
asst = _assistant_turns(ep["messages"])
|
| 80 |
+
for i, t in enumerate(ep["turns"]):
|
| 81 |
+
with st.expander(
|
| 82 |
+
f"turn {t['turn']} · tick {t.get('tick')}"
|
| 83 |
+
+ (f" · ⚡{t['interrupt']}" if t.get("interrupt") else ""),
|
| 84 |
+
expanded=(i == 0),
|
| 85 |
+
):
|
| 86 |
+
left, right = st.columns([1, 1])
|
| 87 |
+
with left:
|
| 88 |
+
if t.get("minimap_png"):
|
| 89 |
+
st.image(t["minimap_png"], caption="minimap")
|
| 90 |
+
elif t.get("ascii_minimap"):
|
| 91 |
+
st.code(t["ascii_minimap"])
|
| 92 |
+
st.json(t.get("signals", {}))
|
| 93 |
+
with right:
|
| 94 |
+
a = asst[i] if i < len(asst) else {}
|
| 95 |
+
if a.get("reasoning"):
|
| 96 |
+
st.markdown("**reasoning**")
|
| 97 |
+
st.write(a["reasoning"])
|
| 98 |
+
st.markdown("**commands**")
|
| 99 |
+
st.code("\n".join(t.get("commands", [])) or "(none)")
|
| 100 |
+
g = t.get("goal") or {}
|
| 101 |
+
if g:
|
| 102 |
+
st.markdown(
|
| 103 |
+
f"**objective progress: "
|
| 104 |
+
f"{g.get('objective_progress', 0):.0%}**"
|
| 105 |
+
+ (" ✅ won" if g.get("won") else "")
|
| 106 |
+
)
|
| 107 |
+
for leaf in g.get("leaves", []):
|
| 108 |
+
st.progress(
|
| 109 |
+
min(1.0, float(leaf.get("ratio", 0.0))),
|
| 110 |
+
text=f"{leaf['name']} "
|
| 111 |
+
f"{leaf.get('current')}/{leaf.get('target')}",
|
| 112 |
+
)
|
| 113 |
+
rv = g.get("reward_vector", {})
|
| 114 |
+
st.caption("reward vector: " + " ".join(
|
| 115 |
+
f"{k}={v:.2f}" for k, v in rv.items()
|
| 116 |
+
))
|
openra_bench/providers.py
CHANGED
|
@@ -81,6 +81,7 @@ class ChatReply:
|
|
| 81 |
|
| 82 |
text: str
|
| 83 |
tool_calls: list[dict] # [{"name": str, "arguments": dict}]
|
|
|
|
| 84 |
raw: dict = field(default_factory=dict)
|
| 85 |
|
| 86 |
|
|
@@ -105,7 +106,7 @@ class OpenAICompatibleProvider(ChatProvider):
|
|
| 105 |
}
|
| 106 |
body: dict[str, Any] = {
|
| 107 |
"model": cfg.model,
|
| 108 |
-
"messages": messages,
|
| 109 |
"temperature": cfg.temperature,
|
| 110 |
"max_tokens": cfg.max_tokens,
|
| 111 |
}
|
|
@@ -118,7 +119,28 @@ class OpenAICompatibleProvider(ChatProvider):
|
|
| 118 |
json=body,
|
| 119 |
)
|
| 120 |
resp.raise_for_status()
|
| 121 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
msg = data["choices"][0]["message"]
|
| 123 |
calls: list[dict] = []
|
| 124 |
for tc in msg.get("tool_calls") or []:
|
|
@@ -130,7 +152,17 @@ class OpenAICompatibleProvider(ChatProvider):
|
|
| 130 |
except json.JSONDecodeError:
|
| 131 |
args = {}
|
| 132 |
calls.append({"name": fn.get("name", ""), "arguments": args})
|
| 133 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
|
| 135 |
def close(self) -> None:
|
| 136 |
self._client.close()
|
|
|
|
| 81 |
|
| 82 |
text: str
|
| 83 |
tool_calls: list[dict] # [{"name": str, "arguments": dict}]
|
| 84 |
+
reasoning: str = "" # chain-of-thought, when the model/provider emits it
|
| 85 |
raw: dict = field(default_factory=dict)
|
| 86 |
|
| 87 |
|
|
|
|
| 106 |
}
|
| 107 |
body: dict[str, Any] = {
|
| 108 |
"model": cfg.model,
|
| 109 |
+
"messages": self._wire_messages(messages),
|
| 110 |
"temperature": cfg.temperature,
|
| 111 |
"max_tokens": cfg.max_tokens,
|
| 112 |
}
|
|
|
|
| 119 |
json=body,
|
| 120 |
)
|
| 121 |
resp.raise_for_status()
|
| 122 |
+
return self._reply_from_data(resp.json())
|
| 123 |
+
|
| 124 |
+
# Keys the OpenAI Chat Completions wire format accepts per message.
|
| 125 |
+
# `history` carries extra playback-only keys (notably "reasoning");
|
| 126 |
+
# those must never be posted back or strict servers (vLLM) 400.
|
| 127 |
+
_WIRE_KEYS = frozenset(
|
| 128 |
+
{"role", "content", "name", "tool_calls", "tool_call_id"}
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
@staticmethod
|
| 132 |
+
def _wire_messages(messages: list[dict]) -> list[dict]:
|
| 133 |
+
"""Pure: project each message onto OpenAI-legal keys only."""
|
| 134 |
+
return [
|
| 135 |
+
{k: v for k, v in m.items() if k in OpenAICompatibleProvider._WIRE_KEYS}
|
| 136 |
+
for m in messages
|
| 137 |
+
]
|
| 138 |
+
|
| 139 |
+
@staticmethod
|
| 140 |
+
def _reply_from_data(data: dict) -> ChatReply:
|
| 141 |
+
"""Pure parse of a Chat Completions response, including the
|
| 142 |
+
provider-specific reasoning channel (vLLM/DeepSeek emit
|
| 143 |
+
`reasoning_content`; OpenRouter/others a flat `reasoning`)."""
|
| 144 |
msg = data["choices"][0]["message"]
|
| 145 |
calls: list[dict] = []
|
| 146 |
for tc in msg.get("tool_calls") or []:
|
|
|
|
| 152 |
except json.JSONDecodeError:
|
| 153 |
args = {}
|
| 154 |
calls.append({"name": fn.get("name", ""), "arguments": args})
|
| 155 |
+
rc = msg.get("reasoning_content") or msg.get("reasoning") or ""
|
| 156 |
+
if isinstance(rc, list): # some providers chunk it
|
| 157 |
+
rc = "".join(
|
| 158 |
+
p.get("text", "") if isinstance(p, dict) else str(p) for p in rc
|
| 159 |
+
)
|
| 160 |
+
return ChatReply(
|
| 161 |
+
text=msg.get("content") or "",
|
| 162 |
+
tool_calls=calls,
|
| 163 |
+
reasoning=str(rc),
|
| 164 |
+
raw=data,
|
| 165 |
+
)
|
| 166 |
|
| 167 |
def close(self) -> None:
|
| 168 |
self._client.close()
|
scripts/view_playback.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Streamlit entrypoint for the playback viewer.
|
| 2 |
+
|
| 3 |
+
pip install streamlit
|
| 4 |
+
streamlit run scripts/view_playback.py -- <playback_root>
|
| 5 |
+
"""
|
| 6 |
+
import sys
|
| 7 |
+
|
| 8 |
+
from openra_bench.playback_view import render_streamlit
|
| 9 |
+
|
| 10 |
+
if __name__ == "__main__":
|
| 11 |
+
render_streamlit(sys.argv[1] if len(sys.argv) > 1 else "playback")
|
tests/test_playback_completeness.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Playback must capture everything needed to *replay reasoning*.
|
| 2 |
+
|
| 3 |
+
Three gaps closed here, each asserted end to end:
|
| 4 |
+
|
| 5 |
+
1. Model reasoning/thinking is extracted from the provider raw reply
|
| 6 |
+
and persisted on the assistant turn (so messages.json contains the
|
| 7 |
+
chain-of-thought, like the training traces) — while the *wire*
|
| 8 |
+
messages stay OpenAI-clean (no non-standard keys leak back).
|
| 9 |
+
2. Every turn records a goal tracker: per-win-condition-leaf progress
|
| 10 |
+
AND a normalized cumulative reward vector, side by side.
|
| 11 |
+
3. The saved episode is loadable by the viewer's data layer.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import json
|
| 17 |
+
|
| 18 |
+
import pytest
|
| 19 |
+
|
| 20 |
+
from openra_bench.goal_tracker import leaf_progress, reward_vector, turn_goal
|
| 21 |
+
from openra_bench.providers import ChatReply, OpenAICompatibleProvider, ProviderConfig
|
| 22 |
+
from openra_bench.scenarios.win_conditions import WinContext
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# ---- 1. reasoning capture --------------------------------------------------
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class _Sig:
|
| 29 |
+
explored_percent = 40.0
|
| 30 |
+
enemies_seen_ids = ["e1", "e2"]
|
| 31 |
+
enemy_buildings_seen_ids: list[str] = []
|
| 32 |
+
units_killed = 3
|
| 33 |
+
units_lost = 1
|
| 34 |
+
game_tick = 1200
|
| 35 |
+
cash = 1400
|
| 36 |
+
resources = 600
|
| 37 |
+
power_provided = 100
|
| 38 |
+
power_drained = 60
|
| 39 |
+
own_building_types = {"powr"}
|
| 40 |
+
own_buildings = [("powr", 5, 5)]
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def test_provider_extracts_reasoning_content():
|
| 44 |
+
cfg = ProviderConfig(provider="vllm")
|
| 45 |
+
prov = OpenAICompatibleProvider(cfg)
|
| 46 |
+
data = {
|
| 47 |
+
"choices": [
|
| 48 |
+
{
|
| 49 |
+
"message": {
|
| 50 |
+
"content": "moving out",
|
| 51 |
+
"reasoning_content": "enemy is east, scout first",
|
| 52 |
+
"tool_calls": [],
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
]
|
| 56 |
+
}
|
| 57 |
+
reply = prov._reply_from_data(data) # pure parse, no network
|
| 58 |
+
assert isinstance(reply, ChatReply)
|
| 59 |
+
assert reply.reasoning == "enemy is east, scout first"
|
| 60 |
+
# also accepts the OpenRouter-style flat "reasoning" key
|
| 61 |
+
data["choices"][0]["message"] = {"content": "x", "reasoning": "because"}
|
| 62 |
+
assert prov._reply_from_data(data).reasoning == "because"
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def test_wire_messages_strip_nonstandard_keys():
|
| 66 |
+
# history may carry reasoning for playback; it must NOT be posted.
|
| 67 |
+
msgs = [
|
| 68 |
+
{"role": "system", "content": "s"},
|
| 69 |
+
{"role": "assistant", "content": "a", "reasoning": "secret",
|
| 70 |
+
"tool_calls": [{"id": "c0", "type": "function",
|
| 71 |
+
"function": {"name": "observe", "arguments": {}}}]},
|
| 72 |
+
{"role": "tool", "tool_call_id": "c0", "content": "ok"},
|
| 73 |
+
]
|
| 74 |
+
wire = OpenAICompatibleProvider._wire_messages(msgs)
|
| 75 |
+
assert all("reasoning" not in m for m in wire)
|
| 76 |
+
# structural keys survive
|
| 77 |
+
assert wire[1]["tool_calls"][0]["function"]["name"] == "observe"
|
| 78 |
+
assert wire[2]["tool_call_id"] == "c0"
|
| 79 |
+
# original untouched (pure)
|
| 80 |
+
assert msgs[1]["reasoning"] == "secret"
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def test_agent_records_reasoning_on_assistant_turn():
|
| 84 |
+
from openra_bench.agent import ModelAgent
|
| 85 |
+
|
| 86 |
+
class P:
|
| 87 |
+
def complete(self, messages, tools):
|
| 88 |
+
return ChatReply(text="go", tool_calls=[], reasoning="I think east")
|
| 89 |
+
|
| 90 |
+
a = ModelAgent(ProviderConfig(vision=False), allowed_tools=["observe"],
|
| 91 |
+
provider=P())
|
| 92 |
+
a.agent_fn({"minimap": "", "units_summary": [], "enemy_summary": []},
|
| 93 |
+
type("C", (), {"observe": staticmethod(lambda: "obs")}))
|
| 94 |
+
asst = [m for m in a.history if m["role"] == "assistant"][-1]
|
| 95 |
+
assert asst["reasoning"] == "I think east"
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
# ---- 2. per-turn goal tracker ---------------------------------------------
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def _ctx():
|
| 102 |
+
return WinContext(signals=_Sig(), render_state={"units_summary": []})
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def test_leaf_progress_reports_ratio_per_predicate():
|
| 106 |
+
wc = {"all_of": [{"units_killed_gte": 5}, {"explored_pct_gte": 80},
|
| 107 |
+
{"cash_gte": 1000}]}
|
| 108 |
+
prog = leaf_progress(wc, _ctx())
|
| 109 |
+
by = {p["name"]: p for p in prog}
|
| 110 |
+
assert by["units_killed_gte"]["current"] == 3
|
| 111 |
+
assert by["units_killed_gte"]["target"] == 5
|
| 112 |
+
assert by["units_killed_gte"]["ratio"] == pytest.approx(0.6)
|
| 113 |
+
assert by["cash_gte"]["satisfied"] is True # 1400 >= 1000
|
| 114 |
+
assert by["explored_pct_gte"]["satisfied"] is False
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def test_reward_vector_is_normalized_and_cumulative():
|
| 118 |
+
rv = reward_vector(_Sig())
|
| 119 |
+
for k in ("economy", "military", "territory", "scouting", "objective"):
|
| 120 |
+
assert k in rv and 0.0 <= rv[k] <= 1.0
|
| 121 |
+
assert rv["territory"] == pytest.approx(0.40) # 40% explored
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def test_turn_goal_bundles_predicates_and_vector_side_by_side():
|
| 125 |
+
g = turn_goal({"units_killed_gte": 5}, _ctx())
|
| 126 |
+
assert "leaves" in g and "reward_vector" in g
|
| 127 |
+
assert "objective_progress" in g and 0.0 <= g["objective_progress"] <= 1.0
|
| 128 |
+
assert g["won"] is False # 3 < 5
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
# ---- 3. end-to-end persisted + loadable -----------------------------------
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def test_playback_round_trip_has_reasoning_and_goal(tmp_path):
|
| 135 |
+
from openra_bench.playback import Playback
|
| 136 |
+
from openra_bench.playback_view import load_episode
|
| 137 |
+
|
| 138 |
+
pb = Playback(tmp_path, "pack:easy", 7)
|
| 139 |
+
|
| 140 |
+
class S(_Sig):
|
| 141 |
+
pass
|
| 142 |
+
|
| 143 |
+
pb.record_turn(
|
| 144 |
+
1, {"minimap": "..", "units_summary": [], "enemy_summary": []},
|
| 145 |
+
["Command::Observe"], S(), None,
|
| 146 |
+
goal=turn_goal({"units_killed_gte": 5}, _ctx()),
|
| 147 |
+
)
|
| 148 |
+
pb.write_messages([
|
| 149 |
+
{"role": "system", "content": "s"},
|
| 150 |
+
{"role": "user", "content": "briefing"},
|
| 151 |
+
{"role": "assistant", "content": "go", "reasoning": "scout east",
|
| 152 |
+
"tool_calls": []},
|
| 153 |
+
])
|
| 154 |
+
pb.finalize({"scenario": "pack:easy", "outcome": "loss", "turns": 1})
|
| 155 |
+
|
| 156 |
+
ep = load_episode(pb.dir)
|
| 157 |
+
assert ep["manifest"]["outcome"] == "loss"
|
| 158 |
+
assert ep["turns"][0]["goal"]["won"] is False
|
| 159 |
+
assert ep["turns"][0]["goal"]["leaves"][0]["name"] == "units_killed_gte"
|
| 160 |
+
asst = [m for m in ep["messages"] if m["role"] == "assistant"][0]
|
| 161 |
+
assert asst["reasoning"] == "scout east"
|
| 162 |
+
# turns.jsonl is valid JSONL
|
| 163 |
+
raw = (pb.dir / "turns.jsonl").read_text().strip().splitlines()
|
| 164 |
+
assert json.loads(raw[0])["goal"]["reward_vector"]["territory"] >= 0.0
|