yxc20098 commited on
Commit
f77eea7
·
1 Parent(s): f912cfc

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 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, interrupt=interrupt
 
 
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
- data = resp.json()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- return ChatReply(text=msg.get("content") or "", tool_calls=calls, raw=data)
 
 
 
 
 
 
 
 
 
 
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