yxc20098 commited on
Commit
715cbbc
·
1 Parent(s): 098c6e0

Add provider-agnostic model agent (vLLM/OpenRouter/Bedrock)

Browse files

- providers.py: ChatProvider abstraction; OpenAICompatibleProvider
(covers vLLM + OpenRouter + OpenAI by base_url), BedrockProvider stub
with precise NotImplementedError. Pure ProviderConfig selection.
- agent.py: ModelAgent — Training-compatible text briefing, OpenAI
function-calling tool schema (move_units/attack_unit/observe) filtered
by scenario tools, tolerant tool-call->Command parsing with aliases,
optional minimap PNG multimodal (graceful text-only fallback),
bounded chat history with stale-image stripping. Exposes agent_fn for
eval_core.
- tests/test_agent.py: offline FakeProvider drives the full
ModelAgent->eval_core->live-Rust loop; schema/briefing/parsing units;
opt-in OpenRouter live smoke (skips without OPENROUTER_API_KEY).

22 passed, 1 skipped.

openra_bench/__init__.py CHANGED
@@ -4,4 +4,4 @@ planning, on the Rust OpenRA environment, reusing OpenRA-RL-Training components.
4
  See EVAL_STACK_PLAN.md for architecture and phasing.
5
  """
6
 
7
- __all__ = ["rust_adapter", "eval_core"]
 
4
  See EVAL_STACK_PLAN.md for architecture and phasing.
5
  """
6
 
7
+ __all__ = ["rust_adapter", "eval_core", "agent", "providers", "scenarios"]
openra_bench/agent.py ADDED
@@ -0,0 +1,242 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Provider-agnostic model agent.
2
+
3
+ Turns a `RustObsAdapter.render_state()` into a Training-compatible text
4
+ briefing (+ optional minimap image), calls a `ChatProvider`, and parses
5
+ tool calls back into `openra_train.Command` objects. Exposes an
6
+ `agent_fn` matching `eval_core`'s `(render_state, Command) -> [Command]`
7
+ contract.
8
+
9
+ Tool contract mirrors OpenRA-RL-Training so models trained there behave
10
+ consistently: `move_units(unit_ids, target_x, target_y)`,
11
+ `attack_unit(unit_ids, target_id)`, `observe()`. The scenario's `tools`
12
+ list filters which are offered.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ from typing import Any
19
+
20
+ from .providers import ChatProvider, ProviderConfig, make_provider
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ SYSTEM_PROMPT = (
25
+ "You are commanding units in Command & Conquer: Red Alert.\n"
26
+ "Each turn you receive a BRIEFING (and, when available, a MINIMAP image: "
27
+ "bright=visible, dim=explored, black=unknown fog).\n"
28
+ "Units are listed as `<id> <type> @(x,y)` (with `-> (tx,ty)` if moving).\n"
29
+ "Pass numeric unit IDs to tools, e.g. unit_ids=[1004,1005].\n"
30
+ "Every turn MUST include at least one tool call. Think briefly, then act."
31
+ )
32
+
33
+ _TOOL_SCHEMAS: dict[str, dict] = {
34
+ "move_units": {
35
+ "type": "function",
36
+ "function": {
37
+ "name": "move_units",
38
+ "description": "Move the given units to a map cell. Units auto-fire "
39
+ "opportunistically en route. Use to position/scout/retreat.",
40
+ "parameters": {
41
+ "type": "object",
42
+ "properties": {
43
+ "unit_ids": {"type": "array", "items": {"type": "integer"}},
44
+ "target_x": {"type": "integer"},
45
+ "target_y": {"type": "integer"},
46
+ },
47
+ "required": ["unit_ids", "target_x", "target_y"],
48
+ },
49
+ },
50
+ },
51
+ "attack_unit": {
52
+ "type": "function",
53
+ "function": {
54
+ "name": "attack_unit",
55
+ "description": "Order the given units to pathfind to and focus-fire "
56
+ "a specific enemy actor id until it dies.",
57
+ "parameters": {
58
+ "type": "object",
59
+ "properties": {
60
+ "unit_ids": {"type": "array", "items": {"type": "integer"}},
61
+ "target_id": {"type": "integer"},
62
+ },
63
+ "required": ["unit_ids", "target_id"],
64
+ },
65
+ },
66
+ },
67
+ "observe": {
68
+ "type": "function",
69
+ "function": {
70
+ "name": "observe",
71
+ "description": "Take no action; advance the game and re-observe.",
72
+ "parameters": {"type": "object", "properties": {}},
73
+ },
74
+ },
75
+ }
76
+ # Aliases tolerated from models trained on slightly different names.
77
+ _TOOL_ALIASES = {"attack_target": "attack_unit", "stop_units": "observe"}
78
+
79
+
80
+ def _tool_schemas(allowed: list[str] | None) -> list[dict]:
81
+ names = list(_TOOL_SCHEMAS) if not allowed else allowed
82
+ out = [_TOOL_SCHEMAS[n] for n in names if n in _TOOL_SCHEMAS]
83
+ if "observe" not in {t["function"]["name"] for t in out}:
84
+ out.append(_TOOL_SCHEMAS["observe"]) # always allow a no-op
85
+ return out
86
+
87
+
88
+ def build_briefing(render_state: dict, objective: str = "") -> str:
89
+ """Training-style text state. Self-contained (no engine handles)."""
90
+ lines: list[str] = []
91
+ if objective:
92
+ lines.append(f"OBJECTIVE: {objective}")
93
+ lines.append(
94
+ f"tick={render_state.get('game_tick', 0)} "
95
+ f"explored={render_state.get('explored_percent', 0.0):.1f}%"
96
+ )
97
+ own = render_state.get("units_summary", []) or []
98
+ lines.append(f"\nYOUR UNITS ({len(own)}):")
99
+ for u in own:
100
+ act = u.get("activity")
101
+ suffix = f", {act}" if act and act != "idle" else ""
102
+ lines.append(
103
+ f" {u['id']} {u.get('type') or 'unit'} @({u['cell_x']},{u['cell_y']}){suffix}"
104
+ )
105
+ enemy = render_state.get("enemy_summary", []) or []
106
+ if enemy:
107
+ lines.append(f"\nVISIBLE ENEMIES ({len(enemy)}):")
108
+ for e in enemy:
109
+ kind = "building" if e.get("is_building") else (e.get("type") or "unit")
110
+ lines.append(f" {e['id']} {kind} @({e['cell_x']},{e['cell_y']})")
111
+ else:
112
+ lines.append("\nVISIBLE ENEMIES: none (scout the fog)")
113
+ return "\n".join(lines)
114
+
115
+
116
+ def _render_minimap_b64(render_state: dict) -> str | None:
117
+ """Best-effort minimap PNG. Returns None (text-only fallback) when
118
+ terrain isn't resolvable — vision degrades gracefully in Phase 0."""
119
+ try:
120
+ from openra_rl_training.training.minimap_renderer import render_minimap
121
+
122
+ return render_minimap(
123
+ terrain_png=render_state.get("terrain_png"), # None in Phase 0 -> None
124
+ map_width=render_state.get("map_width", 64),
125
+ map_height=render_state.get("map_height", 64),
126
+ bounds_x=render_state.get("bounds_x", 0),
127
+ bounds_y=render_state.get("bounds_y", 0),
128
+ own_units=render_state.get("units_summary", []) or [],
129
+ enemy_units=render_state.get("enemy_summary", []) or [],
130
+ ascii_minimap=render_state.get("minimap", ""),
131
+ )
132
+ except Exception as e: # noqa: BLE001 — vision is optional
133
+ logger.debug("minimap render skipped: %s", e)
134
+ return None
135
+
136
+
137
+ def _to_commands(tool_calls: list[dict], Command: Any) -> list:
138
+ cmds = []
139
+ for call in tool_calls:
140
+ name = _TOOL_ALIASES.get(call.get("name", ""), call.get("name", ""))
141
+ args = call.get("arguments") or {}
142
+ try:
143
+ if name == "move_units":
144
+ ids = [str(i) for i in args["unit_ids"]]
145
+ cmds.append(
146
+ Command.move_units(ids, int(args["target_x"]), int(args["target_y"]))
147
+ )
148
+ elif name == "attack_unit":
149
+ ids = [str(i) for i in args["unit_ids"]]
150
+ cmds.append(Command.attack_unit(ids, str(args["target_id"])))
151
+ elif name == "observe":
152
+ cmds.append(Command.observe())
153
+ except (KeyError, TypeError, ValueError) as e:
154
+ logger.debug("dropping malformed tool call %s: %s", call, e)
155
+ return cmds
156
+
157
+
158
+ class ModelAgent:
159
+ """One instance per episode (keeps bounded chat history).
160
+
161
+ Usage:
162
+ agent = ModelAgent(cfg, allowed_tools=compiled.scenario.tools,
163
+ objective=compiled.scenario.description)
164
+ result = run_level(compiled, agent.agent_fn, seed=...)
165
+ """
166
+
167
+ def __init__(
168
+ self,
169
+ cfg: ProviderConfig,
170
+ allowed_tools: list[str] | None = None,
171
+ objective: str = "",
172
+ provider: ChatProvider | None = None,
173
+ ):
174
+ self.cfg = cfg
175
+ self.objective = objective
176
+ self.tools = _tool_schemas(allowed_tools)
177
+ self.provider = provider or make_provider(cfg)
178
+ self.history: list[dict] = [{"role": "system", "content": SYSTEM_PROMPT}]
179
+ self.stats = {"turns": 0, "tool_calls": 0, "empty_replies": 0}
180
+
181
+ def _user_message(self, render_state: dict) -> dict:
182
+ text = build_briefing(render_state, self.objective)
183
+ if self.cfg.vision:
184
+ b64 = _render_minimap_b64(render_state)
185
+ if b64:
186
+ return {
187
+ "role": "user",
188
+ "content": [
189
+ {"type": "text", "text": text},
190
+ {
191
+ "type": "image_url",
192
+ "image_url": {"url": f"data:image/png;base64,{b64}"},
193
+ },
194
+ ],
195
+ }
196
+ return {"role": "user", "content": text}
197
+
198
+ @staticmethod
199
+ def _strip_old_images(history: list[dict]) -> None:
200
+ """Keep only the latest image to bound ViT token cost (mirrors
201
+ Training's _strip_historical_images)."""
202
+ seen = False
203
+ for msg in reversed(history):
204
+ c = msg.get("content")
205
+ if isinstance(c, list):
206
+ if not seen:
207
+ seen = True
208
+ continue
209
+ msg["content"] = " ".join(
210
+ p.get("text", "") for p in c if p.get("type") == "text"
211
+ )
212
+
213
+ def agent_fn(self, render_state: dict, Command: Any) -> list:
214
+ self.stats["turns"] += 1
215
+ self.history.append(self._user_message(render_state))
216
+ self._strip_old_images(self.history)
217
+ reply = self.provider.complete(self.history, self.tools)
218
+ self.history.append(
219
+ {
220
+ "role": "assistant",
221
+ "content": reply.text or "",
222
+ "tool_calls": [
223
+ {
224
+ "id": f"c{i}",
225
+ "type": "function",
226
+ "function": {"name": c["name"], "arguments": c["arguments"]},
227
+ }
228
+ for i, c in enumerate(reply.tool_calls)
229
+ ],
230
+ }
231
+ )
232
+ cmds = _to_commands(reply.tool_calls, Command)
233
+ self.stats["tool_calls"] += len(cmds)
234
+ if not cmds:
235
+ self.stats["empty_replies"] += 1
236
+ cmds = [Command.observe()]
237
+ # Satisfy the OpenAI contract: every tool_call needs a tool result.
238
+ for i in range(len(reply.tool_calls)):
239
+ self.history.append(
240
+ {"role": "tool", "tool_call_id": f"c{i}", "content": "ok"}
241
+ )
242
+ return cmds
openra_bench/providers.py ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Provider abstraction for the eval agent.
2
+
3
+ One small interface, `ChatProvider.complete(messages, tools) -> ChatReply`.
4
+ Adapters:
5
+
6
+ * `OpenAICompatibleProvider` — OpenAI Chat Completions wire format. Covers
7
+ local **vLLM** (matches Training's rollout path) and **OpenRouter**
8
+ (the Phase-0 test target) by base_url alone.
9
+ * `BedrockProvider` — AWS Bedrock Converse. Stubbed with a precise
10
+ NotImplementedError so the wiring exists before the dependency does.
11
+
12
+ Selection is pure config (`ProviderConfig`); no provider-specific code
13
+ leaks into the agent.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ from dataclasses import dataclass, field
21
+ from typing import Any, Literal
22
+
23
+ import httpx
24
+
25
+ ProviderName = Literal["openai", "vllm", "openrouter", "bedrock"]
26
+
27
+ # Convenience presets; base_url/api_key_env still overridable in config.
28
+ _PRESETS: dict[str, dict[str, str]] = {
29
+ "openrouter": {
30
+ "base_url": "https://openrouter.ai/api/v1",
31
+ "api_key_env": "OPENROUTER_API_KEY",
32
+ },
33
+ "vllm": {
34
+ "base_url": "http://localhost:8100/v1",
35
+ "api_key_env": "VLLM_API_KEY", # vLLM ignores the value
36
+ },
37
+ "openai": {
38
+ "base_url": "https://api.openai.com/v1",
39
+ "api_key_env": "OPENAI_API_KEY",
40
+ },
41
+ }
42
+
43
+
44
+ @dataclass
45
+ class ProviderConfig:
46
+ provider: ProviderName = "openrouter"
47
+ model: str = "anthropic/claude-3.5-sonnet"
48
+ base_url: str | None = None
49
+ api_key_env: str | None = None
50
+ temperature: float = 0.7
51
+ max_tokens: int = 1024
52
+ timeout_s: float = 120.0
53
+ vision: bool = True
54
+ extra_headers: dict[str, str] = field(default_factory=dict)
55
+
56
+ def resolved_base_url(self) -> str:
57
+ if self.base_url:
58
+ return self.base_url
59
+ preset = _PRESETS.get(self.provider)
60
+ if not preset:
61
+ raise ValueError(
62
+ f"no base_url and no preset for provider {self.provider!r}"
63
+ )
64
+ return preset["base_url"]
65
+
66
+ def resolved_api_key(self) -> str:
67
+ env = self.api_key_env or _PRESETS.get(self.provider, {}).get("api_key_env")
68
+ if not env:
69
+ raise ValueError(f"no api_key_env for provider {self.provider!r}")
70
+ key = os.environ.get(env, "")
71
+ if not key and self.provider != "vllm":
72
+ raise RuntimeError(
73
+ f"{env} not set — required for provider {self.provider!r}"
74
+ )
75
+ return key or "not-needed"
76
+
77
+
78
+ @dataclass
79
+ class ChatReply:
80
+ """Normalized model reply."""
81
+
82
+ text: str
83
+ tool_calls: list[dict] # [{"name": str, "arguments": dict}]
84
+ raw: dict = field(default_factory=dict)
85
+
86
+
87
+ class ChatProvider:
88
+ def complete(self, messages: list[dict], tools: list[dict]) -> ChatReply:
89
+ raise NotImplementedError
90
+
91
+
92
+ class OpenAICompatibleProvider(ChatProvider):
93
+ """OpenAI /chat/completions with `tools`. vLLM + OpenRouter + OpenAI."""
94
+
95
+ def __init__(self, cfg: ProviderConfig):
96
+ self.cfg = cfg
97
+ self._client = httpx.Client(timeout=cfg.timeout_s)
98
+
99
+ def complete(self, messages: list[dict], tools: list[dict]) -> ChatReply:
100
+ cfg = self.cfg
101
+ headers = {
102
+ "Authorization": f"Bearer {cfg.resolved_api_key()}",
103
+ "Content-Type": "application/json",
104
+ **cfg.extra_headers,
105
+ }
106
+ body: dict[str, Any] = {
107
+ "model": cfg.model,
108
+ "messages": messages,
109
+ "temperature": cfg.temperature,
110
+ "max_tokens": cfg.max_tokens,
111
+ }
112
+ if tools:
113
+ body["tools"] = tools
114
+ body["tool_choice"] = "auto"
115
+ resp = self._client.post(
116
+ f"{cfg.resolved_base_url()}/chat/completions",
117
+ headers=headers,
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 []:
125
+ fn = tc.get("function", {})
126
+ args = fn.get("arguments", {})
127
+ if isinstance(args, str):
128
+ try:
129
+ args = json.loads(args or "{}")
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()
137
+
138
+
139
+ class BedrockProvider(ChatProvider):
140
+ """AWS Bedrock Converse. Wired but not yet implemented."""
141
+
142
+ def __init__(self, cfg: ProviderConfig):
143
+ self.cfg = cfg
144
+
145
+ def complete(self, messages: list[dict], tools: list[dict]) -> ChatReply:
146
+ raise NotImplementedError(
147
+ "BedrockProvider not implemented yet. Use provider='openrouter' "
148
+ "or 'vllm' for Phase 0; Bedrock Converse adapter is a tracked "
149
+ "follow-up (needs boto3 + message/tool shape translation)."
150
+ )
151
+
152
+
153
+ def make_provider(cfg: ProviderConfig) -> ChatProvider:
154
+ if cfg.provider == "bedrock":
155
+ return BedrockProvider(cfg)
156
+ if cfg.provider in ("openai", "vllm", "openrouter"):
157
+ return OpenAICompatibleProvider(cfg)
158
+ raise ValueError(f"unknown provider {cfg.provider!r}")
tests/test_agent.py ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Agent + provider tests.
2
+
3
+ Offline by default: a FakeProvider returns scripted tool calls so the
4
+ briefing build, tool-schema filtering, tool-call parsing, and the full
5
+ ModelAgent -> eval_core -> live-Rust loop are all asserted without a
6
+ network. The OpenRouter live test is opt-in (skips without an API key).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from pathlib import Path
13
+
14
+ import pytest
15
+
16
+ ot = pytest.importorskip("openra_train", reason="Rust env wheel not installed")
17
+
18
+ from openra_bench.agent import ModelAgent, build_briefing, _to_commands, _tool_schemas
19
+ from openra_bench.providers import ChatProvider, ChatReply, ProviderConfig
20
+
21
+ TRAIN = Path("/Users/berta/Projects/OpenRA-RL-Training")
22
+ PACKS = Path(__file__).parent.parent / "openra_bench" / "scenarios" / "packs"
23
+
24
+
25
+ class FakeProvider(ChatProvider):
26
+ """Returns a fixed sequence of tool-call sets, then observe()."""
27
+
28
+ def __init__(self, script: list[list[dict]]):
29
+ self.script = script
30
+ self.i = 0
31
+ self.seen_messages: list[list[dict]] = []
32
+
33
+ def complete(self, messages, tools):
34
+ self.seen_messages.append(messages)
35
+ calls = self.script[self.i] if self.i < len(self.script) else [
36
+ {"name": "observe", "arguments": {}}
37
+ ]
38
+ self.i += 1
39
+ return ChatReply(text="", tool_calls=calls)
40
+
41
+
42
+ def test_tool_schema_filtering():
43
+ only_move = _tool_schemas(["move_units"])
44
+ names = {t["function"]["name"] for t in only_move}
45
+ assert "move_units" in names
46
+ assert "attack_unit" not in names
47
+ assert "observe" in names, "a no-op must always be offered"
48
+ assert {t["function"]["name"] for t in _tool_schemas(None)} == {
49
+ "move_units",
50
+ "attack_unit",
51
+ "observe",
52
+ }
53
+
54
+
55
+ def test_build_briefing_format():
56
+ rs = {
57
+ "game_tick": 120,
58
+ "explored_percent": 12.5,
59
+ "units_summary": [
60
+ {"id": "1001", "type": "jeep", "cell_x": 5, "cell_y": 6, "activity": "idle"}
61
+ ],
62
+ "enemy_summary": [],
63
+ }
64
+ b = build_briefing(rs, objective="find the base")
65
+ assert "OBJECTIVE: find the base" in b
66
+ assert "1001 jeep @(5,6)" in b
67
+ assert "tick=120" in b and "explored=12.5%" in b
68
+ assert "none (scout the fog)" in b
69
+
70
+
71
+ def test_tool_call_parsing_and_aliases():
72
+ cmds = _to_commands(
73
+ [
74
+ {"name": "move_units", "arguments": {"unit_ids": [1, 2], "target_x": 9, "target_y": 4}},
75
+ {"name": "attack_target", "arguments": {"unit_ids": [3], "target_id": 77}}, # alias
76
+ {"name": "observe", "arguments": {}},
77
+ {"name": "garbage", "arguments": {}}, # dropped
78
+ {"name": "move_units", "arguments": {"unit_ids": [1]}}, # malformed -> dropped
79
+ ],
80
+ ot.Command,
81
+ )
82
+ # 3 valid (move, attack-alias, observe); 2 dropped
83
+ assert len(cmds) == 3
84
+
85
+
86
+ def test_model_agent_drives_live_rust_with_fake_provider():
87
+ from openra_bench.eval_core import run_level
88
+ from openra_bench.scenarios import load_pack
89
+ from openra_bench.scenarios.loader import compile_level
90
+
91
+ pack = load_pack(PACKS / "perception-frontier-reading.yaml")
92
+ compiled = compile_level(pack, "easy")
93
+
94
+ # Scripted "scout east" behaviour, then observe forever.
95
+ fake = FakeProvider(
96
+ [[{"name": "move_units", "arguments": {"unit_ids": [1001], "target_x": 100, "target_y": 20}}]]
97
+ * 8
98
+ )
99
+ agent = ModelAgent(
100
+ ProviderConfig(vision=False),
101
+ allowed_tools=compiled.scenario.tools,
102
+ objective=compiled.scenario.description,
103
+ provider=fake,
104
+ )
105
+ res = run_level(compiled, agent.agent_fn, seed=1)
106
+
107
+ assert res.outcome in {"win", "draw", "loss"}
108
+ assert res.turns >= 1 and len(res.trace) == res.turns
109
+ assert agent.stats["turns"] == res.turns
110
+ # Provider actually saw a system prompt + a user briefing.
111
+ first = fake.seen_messages[0]
112
+ assert first[0]["role"] == "system"
113
+ assert any(m["role"] == "user" for m in first)
114
+
115
+
116
+ def test_history_strips_stale_images():
117
+ hist = [
118
+ {"role": "user", "content": [{"type": "text", "text": "t1"}, {"type": "image_url", "image_url": {}}]},
119
+ {"role": "user", "content": [{"type": "text", "text": "t2"}, {"type": "image_url", "image_url": {}}]},
120
+ ]
121
+ ModelAgent._strip_old_images(hist)
122
+ assert isinstance(hist[0]["content"], str) and hist[0]["content"] == "t1"
123
+ assert isinstance(hist[1]["content"], list) # newest image kept
124
+
125
+
126
+ @pytest.mark.skipif(
127
+ not os.environ.get("OPENROUTER_API_KEY"),
128
+ reason="set OPENROUTER_API_KEY to run the live provider test",
129
+ )
130
+ def test_openrouter_live_smoke():
131
+ from openra_bench.eval_core import run_level
132
+ from openra_bench.scenarios import load_pack
133
+ from openra_bench.scenarios.loader import compile_level
134
+
135
+ pack = load_pack(PACKS / "perception-frontier-reading.yaml")
136
+ compiled = compile_level(pack, "easy")
137
+ agent = ModelAgent(
138
+ ProviderConfig(
139
+ provider="openrouter",
140
+ model=os.environ.get("OPENROUTER_MODEL", "anthropic/claude-3.5-sonnet"),
141
+ vision=False,
142
+ max_tokens=512,
143
+ ),
144
+ allowed_tools=compiled.scenario.tools,
145
+ objective=compiled.scenario.description,
146
+ )
147
+ res = run_level(compiled, agent.agent_fn, seed=1)
148
+ assert res.outcome in {"win", "draw", "loss"}
149
+ assert agent.stats["tool_calls"] >= 1, "model issued no usable tool calls"