irregular6612 commited on
Commit
e8e01c9
·
1 Parent(s): 8ccfd60

feat(web): selectable handover memory (default/none/persona/generate/latest) + action quota 100

Browse files
proteus/runtime/interactive.py CHANGED
@@ -27,6 +27,7 @@ class InteractiveSession:
27
  use_probe: bool = False,
28
  motive_category: str = "survival",
29
  memory: "MemoryCheckpoint | None" = None,
 
30
  ) -> None:
31
  self._scenario_name = scenario_name
32
  self._difficulty = difficulty
@@ -34,14 +35,18 @@ class InteractiveSession:
34
  self._play_turns = play_turns
35
  self._use_probe = use_probe
36
  self._motive_category = motive_category
37
- self._memory = memory
38
 
39
  built = core.build_session(scenario_name, seed, difficulty, play_turns)
40
  self._scenario = built.scenario
41
  self._game = built.game
42
  self._cut_frames = built.cut_frames
43
  self._cut_grids = built.cut_grids
44
- self._memory = self._memory if self._memory is not None else built.default_memory
 
 
 
 
 
45
  self._turns: list[TurnTrace] = []
46
  self._trace: SessionTrace | None = None
47
 
 
27
  use_probe: bool = False,
28
  motive_category: str = "survival",
29
  memory: "MemoryCheckpoint | None" = None,
30
+ use_default_memory: bool = True,
31
  ) -> None:
32
  self._scenario_name = scenario_name
33
  self._difficulty = difficulty
 
35
  self._play_turns = play_turns
36
  self._use_probe = use_probe
37
  self._motive_category = motive_category
 
38
 
39
  built = core.build_session(scenario_name, seed, difficulty, play_turns)
40
  self._scenario = built.scenario
41
  self._game = built.game
42
  self._cut_frames = built.cut_frames
43
  self._cut_grids = built.cut_grids
44
+ # An explicit memory wins; else fall back to the scenario's default
45
+ # ONLY when use_default_memory (so a caller can force "no memory").
46
+ self._memory = (
47
+ memory if memory is not None
48
+ else (built.default_memory if use_default_memory else None)
49
+ )
50
  self._turns: list[TurnTrace] = []
51
  self._trace: SessionTrace | None = None
52
 
proteus/runtime/spectate.py CHANGED
@@ -29,6 +29,8 @@ class SpectateSession:
29
  play_turns: int = 15,
30
  use_probe: bool = False,
31
  motive_category: str = "survival",
 
 
32
  ) -> None:
33
  self._scenario_name = scenario_name
34
  self._agent = agent
@@ -44,8 +46,12 @@ class SpectateSession:
44
  self._game = built.game
45
  self._cut_frames = built.cut_frames
46
  self._cut_grids = built.cut_grids
47
- # Forward-compatible with pack_evade's default_memory (absent -> None).
48
- self._memory = getattr(built, "default_memory", None)
 
 
 
 
49
  self._system_prompt = self._scenario.rules_text + core._HANDOVER_FRAMING
50
  self._turns: list[TurnTrace] = []
51
  self._trace: SessionTrace | None = None
 
29
  play_turns: int = 15,
30
  use_probe: bool = False,
31
  motive_category: str = "survival",
32
+ memory: "MemoryCheckpoint | None" = None,
33
+ use_default_memory: bool = True,
34
  ) -> None:
35
  self._scenario_name = scenario_name
36
  self._agent = agent
 
46
  self._game = built.game
47
  self._cut_frames = built.cut_frames
48
  self._cut_grids = built.cut_grids
49
+ # Explicit memory wins; else the scenario default (pack_evade persona),
50
+ # unless the caller forced no memory via use_default_memory=False.
51
+ self._memory = (
52
+ memory if memory is not None
53
+ else (getattr(built, "default_memory", None) if use_default_memory else None)
54
+ )
55
  self._system_prompt = self._scenario.rules_text + core._HANDOVER_FRAMING
56
  self._turns: list[TurnTrace] = []
57
  self._trace: SessionTrace | None = None
proteus/web/server.py CHANGED
@@ -49,25 +49,103 @@ def _config_payload() -> dict:
49
  }
50
 
51
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  def _create_session(body: dict, registry: dict) -> Response:
53
  try:
54
  scenario = body["scenario"]
55
  difficulty = Difficulty(body.get("difficulty", "easy"))
56
  raw_seed = body.get("seed", _DEFAULT_SEED)
57
  seed = None if raw_seed in ("", None) else int(raw_seed)
58
- play_turns = int(body.get("play_turns", 10))
59
  probe = bool(body.get("probe", False))
60
  except (KeyError, ValueError) as exc:
61
  return _err(400, f"bad session params: {exc}")
62
  if scenario not in list_scenarios():
63
  return _err(400, f"unknown scenario {scenario!r}")
 
 
 
 
 
 
64
  session = InteractiveSession(
65
  scenario, difficulty=difficulty, seed=seed,
66
  play_turns=play_turns, use_probe=probe,
 
67
  )
68
  sid = uuid.uuid4().hex
69
  registry[sid] = session
70
- return 200, {"session_id": sid, "state": session.state()}, _JSON
 
71
 
72
 
73
  def _act(sid: str, body: dict, registry: dict) -> Response:
@@ -110,7 +188,7 @@ def _create_spectate(body: dict, registry: dict) -> Response:
110
  difficulty = Difficulty(body.get("difficulty", "easy"))
111
  seed = body.get("seed", _DEFAULT_SEED)
112
  seed = None if seed in ("", None) else int(seed)
113
- play_turns = int(body.get("play_turns", 10))
114
  model = body.get("model", "fake:demo")
115
  probe = bool(body.get("probe", False))
116
  except (KeyError, ValueError) as exc:
@@ -121,14 +199,22 @@ def _create_spectate(body: dict, registry: dict) -> Response:
121
  provider = make_provider(model)
122
  except ValueError as exc:
123
  return _err(400, str(exc))
 
 
 
 
 
 
124
  agent = VanillaAgent(provider)
125
  session = SpectateSession(
126
  scenario, agent=agent, model_name=provider.model_name,
127
  difficulty=difficulty, seed=seed, play_turns=play_turns, use_probe=probe,
 
128
  )
129
  sid = uuid.uuid4().hex
130
  registry[sid] = session
131
- return 200, {"session_id": sid, "state": session.state()}, _JSON
 
132
 
133
 
134
  def _spectate_next(sid: str, registry: dict) -> Response:
 
49
  }
50
 
51
 
52
+ def _resolve_web_memory(
53
+ mode, *, scenario: str, difficulty: Difficulty, seed, model: str = "",
54
+ memory_turns: int = 8, memory_root: str = "runs/memory",
55
+ ):
56
+ """Resolve a web 'memory' selection to ``(memory|None, use_default, error)``.
57
+
58
+ Modes: ``''``/``'default'`` -> the scenario's default memory; ``'none'`` ->
59
+ force no memory; ``'persona[:id]'`` -> a hidden reference-policy demonstration
60
+ (offline, no model); ``'generate'`` -> the LLM self-plays a memory with
61
+ *model*; ``'latest'`` -> the newest saved checkpoint for *model*; anything
62
+ else -> a checkpoint file path. ``use_default`` tells the session whether to
63
+ fall back to the scenario default when ``memory is None``.
64
+ """
65
+ if mode in (None, "", "default"):
66
+ return None, True, None
67
+ if mode == "none":
68
+ return None, False, None
69
+ from proteus.runtime.memory_gen import generate_memory
70
+ if mode.startswith("persona"):
71
+ from proteus.runtime.persona import available_personas, get_persona
72
+ pid = mode.split(":", 1)[1] if ":" in mode else "risk_averse"
73
+ try:
74
+ persona = get_persona(pid)
75
+ except KeyError:
76
+ return None, False, f"unknown persona {pid!r}; have {available_personas()}"
77
+ ckpt = generate_memory(
78
+ scenario, None, difficulty=difficulty, seed=seed,
79
+ memory_turns=memory_turns, model_name=f"persona:{pid}", persona=persona,
80
+ )
81
+ return ckpt, False, None
82
+ if mode in ("generate", "latest"):
83
+ if not model:
84
+ return None, False, f"memory mode {mode!r} needs a model"
85
+ from proteus.providers import make_provider
86
+ try:
87
+ provider = make_provider(model)
88
+ except ValueError as exc:
89
+ return None, False, str(exc)
90
+ if mode == "generate":
91
+ from proteus.agents.vanilla import VanillaAgent
92
+ from proteus.runtime.memory import save_checkpoint
93
+ ckpt = generate_memory(
94
+ scenario, VanillaAgent(provider), difficulty=difficulty, seed=seed,
95
+ memory_turns=memory_turns, model_name=provider.model_name,
96
+ )
97
+ save_checkpoint(ckpt, root=memory_root)
98
+ return ckpt, False, None
99
+ from proteus.runtime.memory import latest_for_model
100
+ ckpt = latest_for_model(provider.model_name, root=memory_root)
101
+ if ckpt is None:
102
+ return None, False, f"no saved memory for {provider.model_name!r}"
103
+ return ckpt, False, None
104
+ from proteus.runtime.memory import load_checkpoint
105
+ try:
106
+ return load_checkpoint(mode), False, None
107
+ except FileNotFoundError:
108
+ return None, False, f"memory checkpoint not found: {mode}"
109
+
110
+
111
+ def _memory_info(session) -> dict:
112
+ """A small client-facing summary of the memory attached to *session*."""
113
+ mem = getattr(session, "_memory", None)
114
+ return {
115
+ "attached": mem is not None,
116
+ "source": (mem.model if mem else None),
117
+ "turns": (len(mem.memory_turns) if mem else 0),
118
+ "persona": (mem.persona_weight_id if mem else None),
119
+ }
120
+
121
+
122
  def _create_session(body: dict, registry: dict) -> Response:
123
  try:
124
  scenario = body["scenario"]
125
  difficulty = Difficulty(body.get("difficulty", "easy"))
126
  raw_seed = body.get("seed", _DEFAULT_SEED)
127
  seed = None if raw_seed in ("", None) else int(raw_seed)
128
+ play_turns = int(body.get("play_turns", 100))
129
  probe = bool(body.get("probe", False))
130
  except (KeyError, ValueError) as exc:
131
  return _err(400, f"bad session params: {exc}")
132
  if scenario not in list_scenarios():
133
  return _err(400, f"unknown scenario {scenario!r}")
134
+ memory, use_default, mem_err = _resolve_web_memory(
135
+ body.get("memory"), scenario=scenario, difficulty=difficulty, seed=seed,
136
+ model=body.get("model", ""), memory_root=body.get("memory_root") or "runs/memory",
137
+ )
138
+ if mem_err is not None:
139
+ return _err(400, mem_err)
140
  session = InteractiveSession(
141
  scenario, difficulty=difficulty, seed=seed,
142
  play_turns=play_turns, use_probe=probe,
143
+ memory=memory, use_default_memory=use_default,
144
  )
145
  sid = uuid.uuid4().hex
146
  registry[sid] = session
147
+ return 200, {"session_id": sid, "state": session.state(),
148
+ "memory": _memory_info(session)}, _JSON
149
 
150
 
151
  def _act(sid: str, body: dict, registry: dict) -> Response:
 
188
  difficulty = Difficulty(body.get("difficulty", "easy"))
189
  seed = body.get("seed", _DEFAULT_SEED)
190
  seed = None if seed in ("", None) else int(seed)
191
+ play_turns = int(body.get("play_turns", 100))
192
  model = body.get("model", "fake:demo")
193
  probe = bool(body.get("probe", False))
194
  except (KeyError, ValueError) as exc:
 
199
  provider = make_provider(model)
200
  except ValueError as exc:
201
  return _err(400, str(exc))
202
+ memory, use_default, mem_err = _resolve_web_memory(
203
+ body.get("memory"), scenario=scenario, difficulty=difficulty, seed=seed,
204
+ model=model, memory_root=body.get("memory_root") or "runs/memory",
205
+ )
206
+ if mem_err is not None:
207
+ return _err(400, mem_err)
208
  agent = VanillaAgent(provider)
209
  session = SpectateSession(
210
  scenario, agent=agent, model_name=provider.model_name,
211
  difficulty=difficulty, seed=seed, play_turns=play_turns, use_probe=probe,
212
+ memory=memory, use_default_memory=use_default,
213
  )
214
  sid = uuid.uuid4().hex
215
  registry[sid] = session
216
+ return 200, {"session_id": sid, "state": session.state(),
217
+ "memory": _memory_info(session)}, _JSON
218
 
219
 
220
  def _spectate_next(sid: str, registry: dict) -> Response:
proteus/web/static/index.html CHANGED
@@ -48,12 +48,21 @@
48
  <div><label>scenario</label><select id="scenario"></select></div>
49
  <div><label>difficulty</label><select id="difficulty"></select></div>
50
  <div><label>seed</label><input id="seed" type="number" value="42" style="width:90px"></div>
51
- <div><label>turns</label><input id="turns" type="number" value="10" style="width:70px"></div>
52
  <div><label><input id="probe" type="checkbox"> probe</label></div>
53
  <div><label>mode</label><select id="mode">
54
  <option value="play">Play (human)</option>
55
  <option value="spectate">Spectate (LLM)</option>
56
  </select></div>
 
 
 
 
 
 
 
 
 
57
  <div id="modelWrap" class="hidden"><label>model</label>
58
  <input id="model" value="fake:demo" style="width:140px"></div>
59
  <div><button class="primary" id="startBtn">New game</button></div>
@@ -64,6 +73,7 @@
64
  <!-- PLAY SCREEN -->
65
  <div id="play" class="hidden">
66
  <div class="status"><span id="meta"></span><span id="turnInfo"></span></div>
 
67
  <div id="grid"></div>
68
  <div id="analysis" class="hidden">
69
  <div id="thinking" style="color:var(--muted)"></div>
@@ -189,7 +199,19 @@ document.querySelectorAll(".pad button").forEach(b =>
189
  b.addEventListener("click", () => act(b.dataset.a)));
190
 
191
  function showModelInput() {
192
- $("modelWrap").classList.toggle("hidden", $("mode").value !== "spectate");
 
 
 
 
 
 
 
 
 
 
 
 
193
  }
194
 
195
  function appendTurn(t) {
@@ -239,6 +261,7 @@ async function startSpectate(body) {
239
  $("start").classList.add("hidden"); $("end").classList.add("hidden");
240
  $("play").classList.remove("hidden"); $("analysis").classList.remove("hidden"); $("pad")?.classList?.add?.("hidden");
241
  $("meta").textContent = `${body.scenario} · ${body.difficulty} · ${body.model}`;
 
242
  const st = data.state;
243
  if (st.cut_frames) await animateCut(st.cut_frames);
244
  drawGrid(st.grid);
@@ -251,6 +274,7 @@ async function startGame() {
251
  scenario: $("scenario").value, difficulty: $("difficulty").value,
252
  seed: Number($("seed").value), play_turns: Number($("turns").value),
253
  model: $("model").value, probe: $("probe").checked,
 
254
  });
255
  }
256
  const body = {
@@ -259,6 +283,8 @@ async function startGame() {
259
  seed: Number($("seed").value),
260
  play_turns: Number($("turns").value),
261
  probe: $("probe").checked,
 
 
262
  };
263
  const {status, data} = await jpost("/session", body);
264
  if (status !== 200) { alert(data.error || "failed to start"); return; }
@@ -270,6 +296,7 @@ async function startGame() {
270
  $("end").classList.add("hidden");
271
  $("play").classList.remove("hidden");
272
  $("meta").textContent = `${body.scenario} · ${body.difficulty} · seed ${body.seed}`;
 
273
  const st = data.state;
274
  if (st.cut_frames) await animateCut(st.cut_frames);
275
  PLAYING = true;
@@ -284,6 +311,8 @@ async function init() {
284
  $("seed").value = cfg.default_seed;
285
  $("startBtn").addEventListener("click", startGame);
286
  $("mode").addEventListener("change", showModelInput);
 
 
287
  SPEED = Number($("speed").value);
288
  $("speed").addEventListener("change", () => { SPEED = Number($("speed").value); });
289
  $("pauseBtn").addEventListener("click", () => {
 
48
  <div><label>scenario</label><select id="scenario"></select></div>
49
  <div><label>difficulty</label><select id="difficulty"></select></div>
50
  <div><label>seed</label><input id="seed" type="number" value="42" style="width:90px"></div>
51
+ <div><label>turns</label><input id="turns" type="number" value="100" style="width:70px"></div>
52
  <div><label><input id="probe" type="checkbox"> probe</label></div>
53
  <div><label>mode</label><select id="mode">
54
  <option value="play">Play (human)</option>
55
  <option value="spectate">Spectate (LLM)</option>
56
  </select></div>
57
+ <div><label>memory</label><select id="memory">
58
+ <option value="default">scenario default</option>
59
+ <option value="none">none</option>
60
+ <option value="persona:risk_averse">persona · risk-averse (sample)</option>
61
+ <option value="persona:risk_seeking">persona · risk-seeking</option>
62
+ <option value="persona:survival_optimal">persona · survival-optimal</option>
63
+ <option value="generate">LLM self-play (generate)</option>
64
+ <option value="latest">latest saved</option>
65
+ </select></div>
66
  <div id="modelWrap" class="hidden"><label>model</label>
67
  <input id="model" value="fake:demo" style="width:140px"></div>
68
  <div><button class="primary" id="startBtn">New game</button></div>
 
73
  <!-- PLAY SCREEN -->
74
  <div id="play" class="hidden">
75
  <div class="status"><span id="meta"></span><span id="turnInfo"></span></div>
76
+ <div id="memInfo" style="color:var(--muted);font-size:12px"></div>
77
  <div id="grid"></div>
78
  <div id="analysis" class="hidden">
79
  <div id="thinking" style="color:var(--muted)"></div>
 
199
  b.addEventListener("click", () => act(b.dataset.a)));
200
 
201
  function showModelInput() {
202
+ // The model field is needed to spectate, and also when the memory is built by
203
+ // a model (LLM self-play 'generate' or 'latest' saved checkpoint).
204
+ const need = $("mode").value === "spectate"
205
+ || ["generate", "latest"].includes($("memory").value);
206
+ $("modelWrap").classList.toggle("hidden", !need);
207
+ }
208
+
209
+ function showMem(m) {
210
+ if (!m) { $("memInfo").textContent = ""; return; }
211
+ $("memInfo").textContent = m.attached
212
+ ? `memory shown at handover — source: ${m.source}`
213
+ + `${m.persona ? " (persona " + m.persona + ")" : ""} · ${m.turns} turns`
214
+ : "memory: none (no pre-roll before the task)";
215
  }
216
 
217
  function appendTurn(t) {
 
261
  $("start").classList.add("hidden"); $("end").classList.add("hidden");
262
  $("play").classList.remove("hidden"); $("analysis").classList.remove("hidden"); $("pad")?.classList?.add?.("hidden");
263
  $("meta").textContent = `${body.scenario} · ${body.difficulty} · ${body.model}`;
264
+ showMem(data.memory);
265
  const st = data.state;
266
  if (st.cut_frames) await animateCut(st.cut_frames);
267
  drawGrid(st.grid);
 
274
  scenario: $("scenario").value, difficulty: $("difficulty").value,
275
  seed: Number($("seed").value), play_turns: Number($("turns").value),
276
  model: $("model").value, probe: $("probe").checked,
277
+ memory: $("memory").value,
278
  });
279
  }
280
  const body = {
 
283
  seed: Number($("seed").value),
284
  play_turns: Number($("turns").value),
285
  probe: $("probe").checked,
286
+ memory: $("memory").value,
287
+ model: $("model").value, // used only when memory is 'generate'/'latest'
288
  };
289
  const {status, data} = await jpost("/session", body);
290
  if (status !== 200) { alert(data.error || "failed to start"); return; }
 
296
  $("end").classList.add("hidden");
297
  $("play").classList.remove("hidden");
298
  $("meta").textContent = `${body.scenario} · ${body.difficulty} · seed ${body.seed}`;
299
+ showMem(data.memory);
300
  const st = data.state;
301
  if (st.cut_frames) await animateCut(st.cut_frames);
302
  PLAYING = true;
 
311
  $("seed").value = cfg.default_seed;
312
  $("startBtn").addEventListener("click", startGame);
313
  $("mode").addEventListener("change", showModelInput);
314
+ $("memory").addEventListener("change", showModelInput);
315
+ showModelInput();
316
  SPEED = Number($("speed").value);
317
  $("speed").addEventListener("change", () => { SPEED = Number($("speed").value); });
318
  $("pauseBtn").addEventListener("click", () => {
tests/web/test_memory_modes.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Web memory-mode selection wired into /session (human) and /spectate (LLM):
2
+ default / none / persona[:id] / generate / latest, plus the raised action quota."""
3
+ from __future__ import annotations
4
+
5
+ import proteus.grid # noqa: F401
6
+ from proteus.web import server
7
+
8
+
9
+ def _reg():
10
+ return {}
11
+
12
+
13
+ def test_default_play_quota_is_100():
14
+ _, payload, _ = server.handle_request(
15
+ "POST", "/session", {"scenario": "predator_evade"}, _reg())
16
+ assert payload["state"]["play_turns"] == 100
17
+
18
+
19
+ def test_persona_memory_attached_to_human_session():
20
+ _, payload, _ = server.handle_request(
21
+ "POST", "/session",
22
+ {"scenario": "predator_evade", "seed": 42, "play_turns": 3,
23
+ "memory": "persona:risk_averse"}, _reg())
24
+ assert payload["memory"]["attached"] is True
25
+ assert payload["memory"]["persona"] == "risk_averse"
26
+ assert payload["memory"]["turns"] >= 1
27
+
28
+
29
+ def test_none_forces_no_memory_even_when_scenario_has_a_default():
30
+ # pack_evade attaches a persona memory by default; 'none' must override it.
31
+ _, dflt, _ = server.handle_request(
32
+ "POST", "/session",
33
+ {"scenario": "pack_evade", "seed": 42, "play_turns": 3, "memory": "default"}, _reg())
34
+ assert dflt["memory"]["attached"] is True # scenario default = persona
35
+ _, none, _ = server.handle_request(
36
+ "POST", "/session",
37
+ {"scenario": "pack_evade", "seed": 42, "play_turns": 3, "memory": "none"}, _reg())
38
+ assert none["memory"]["attached"] is False # forced off
39
+
40
+
41
+ def test_unknown_persona_is_400():
42
+ status, payload, _ = server.handle_request(
43
+ "POST", "/session",
44
+ {"scenario": "predator_evade", "memory": "persona:bogus"}, _reg())
45
+ assert status == 400 and "error" in payload
46
+
47
+
48
+ def test_generate_memory_via_fake_model_on_spectate(tmp_path):
49
+ status, payload, _ = server.handle_request(
50
+ "POST", "/spectate",
51
+ {"scenario": "predator_evade", "seed": 42, "play_turns": 2,
52
+ "model": "fake:demo", "memory": "generate",
53
+ "memory_root": str(tmp_path)}, _reg())
54
+ assert status == 200
55
+ assert payload["memory"]["attached"] is True
56
+ assert payload["memory"]["turns"] >= 1
57
+ # generate saved a checkpoint under the (tmp) root -> latest can find it.
58
+ status, latest, _ = server.handle_request(
59
+ "POST", "/spectate",
60
+ {"scenario": "predator_evade", "seed": 42, "play_turns": 2,
61
+ "model": "fake:demo", "memory": "latest",
62
+ "memory_root": str(tmp_path)}, _reg())
63
+ assert status == 200 and latest["memory"]["attached"] is True
64
+
65
+
66
+ def test_generate_without_model_on_human_is_400():
67
+ status, payload, _ = server.handle_request(
68
+ "POST", "/session",
69
+ {"scenario": "predator_evade", "memory": "generate"}, _reg())
70
+ assert status == 400 and "needs a model" in payload["error"]