visproj commited on
Commit
cdac089
·
verified ·
1 Parent(s): 72c99b0

feat: persist episodes and journal to the lantern-episodes dataset

Browse files
Files changed (7) hide show
  1. app.py +173 -175
  2. lantern/agent.py +2 -0
  3. lantern/bench.py +65 -0
  4. lantern/export.py +69 -0
  5. lantern/llm.py +18 -5
  6. lantern/store.py +84 -0
  7. requirements.txt +1 -0
app.py CHANGED
@@ -1,175 +1,173 @@
1
- """Lantern web server: runs Beam episodes and streams them to the browser over SSE.
2
-
3
- Live runs spend the Space owner's inference credits, so they are limited to one at a
4
- time and a daily cap. Every finished episode is saved and can be replayed for free.
5
- """
6
-
7
- from __future__ import annotations
8
-
9
- import asyncio
10
- import json
11
- import os
12
- import threading
13
- import time
14
- import uuid
15
- from datetime import date
16
- from pathlib import Path
17
-
18
- from fastapi import FastAPI, HTTPException
19
- from fastapi.responses import FileResponse, StreamingResponse
20
- from fastapi.staticfiles import StaticFiles
21
- from pydantic import BaseModel, Field
22
-
23
- from lantern.agent import EpisodeConfig, run_episode
24
- from lantern.env import TASKS, DungeonEnv
25
- from lantern.journal import Journal
26
- from lantern.llm import ChatClient
27
-
28
- DATA_DIR = Path(os.environ.get("LANTERN_DATA", "data"))
29
- EPISODES_DIR = DATA_DIR / "episodes"
30
- DAILY_LIVE_RUNS = int(os.environ.get("LANTERN_DAILY_RUNS", "20"))
31
- WEB_DIR = Path(__file__).parent / "web"
32
-
33
- app = FastAPI(title="Lantern")
34
- journal = Journal(DATA_DIR / "journal.json")
35
-
36
-
37
- class Run:
38
- """One live episode running in a background thread, buffering its events."""
39
-
40
- def __init__(self, task_id: str, seed: int, use_lessons: bool):
41
- self.id = uuid.uuid4().hex[:12]
42
- self.task_id, self.seed, self.use_lessons = task_id, seed, use_lessons
43
- self.events: list[dict] = []
44
- self.done = False
45
- self.stop = threading.Event()
46
-
47
- def start(self) -> None:
48
- threading.Thread(target=self._play, daemon=True).start()
49
-
50
- def _play(self) -> None:
51
- env = None
52
- try:
53
- env = DungeonEnv(TASKS[self.task_id], seed=self.seed)
54
- lessons = journal.lessons(self.task_id)
55
- config = EpisodeConfig(use_lessons=self.use_lessons)
56
- for event in run_episode(env, ChatClient(), lessons, config, self.stop):
57
- if event["type"] == "end" and event["new_lessons"]:
58
- event["journal"] = journal.add(self.task_id, event["new_lessons"])
59
- self.events.append(event)
60
- except Exception as exc: # surface the failure to the viewer instead of hanging
61
- self.events.append({"type": "error", "message": f"{type(exc).__name__}: {exc}"})
62
- finally:
63
- if env is not None:
64
- env.close()
65
- self.done = True
66
- self._save()
67
-
68
- def _save(self) -> None:
69
- EPISODES_DIR.mkdir(parents=True, exist_ok=True)
70
- end = next((e for e in reversed(self.events) if e["type"] == "end"), {})
71
- record = {"id": self.id, "task": self.task_id, "seed": self.seed,
72
- "use_lessons": self.use_lessons, "finished_at": time.time(),
73
- "outcome": end.get("outcome", "error"), "steps": end.get("steps", 0),
74
- "events": self.events}
75
- (EPISODES_DIR / f"{self.id}.json").write_text(json.dumps(record), "utf-8")
76
-
77
-
78
- runs: dict[str, Run] = {}
79
- _quota = {"day": date.today(), "used": 0}
80
- _lock = threading.Lock()
81
-
82
-
83
- class RunRequest(BaseModel):
84
- task: str
85
- seed: int = Field(default=0, ge=0, le=1_000_000)
86
- use_lessons: bool = True
87
-
88
-
89
- @app.get("/api/tasks")
90
- def list_tasks() -> list[dict]:
91
- return [{"id": t.id, "title": t.title, "goal": t.goal, "max_steps": t.max_steps,
92
- "lessons": len(journal.lessons(t.id))} for t in TASKS.values()]
93
-
94
-
95
- @app.get("/api/status")
96
- def status() -> dict:
97
- busy = any(not r.done for r in runs.values())
98
- return {"busy": busy, "runs_left_today": max(0, DAILY_LIVE_RUNS - _quota["used"])}
99
-
100
-
101
- @app.post("/api/runs")
102
- def start_run(req: RunRequest) -> dict:
103
- if req.task not in TASKS:
104
- raise HTTPException(404, f"Unknown level '{req.task}'.")
105
- with _lock:
106
- if _quota["day"] != date.today():
107
- _quota.update(day=date.today(), used=0)
108
- if any(not r.done for r in runs.values()):
109
- raise HTTPException(409, "Beam is already exploring. Watch that run or open a replay.")
110
- if _quota["used"] >= DAILY_LIVE_RUNS:
111
- raise HTTPException(429, "Today's live runs are used up. Replays are still available.")
112
- _quota["used"] += 1
113
- run = Run(req.task, req.seed, req.use_lessons)
114
- runs[run.id] = run
115
- run.start()
116
- return {"id": run.id}
117
-
118
-
119
- @app.post("/api/runs/{run_id}/stop")
120
- def stop_run(run_id: str) -> dict:
121
- run = runs.get(run_id)
122
- if run is None:
123
- raise HTTPException(404, "No such run.")
124
- run.stop.set()
125
- return {"stopping": True}
126
-
127
-
128
- @app.get("/api/runs/{run_id}/events")
129
- async def run_events(run_id: str) -> StreamingResponse:
130
- run = runs.get(run_id)
131
- if run is None:
132
- raise HTTPException(404, "No such run.")
133
-
134
- async def stream():
135
- sent = 0
136
- while True:
137
- while sent < len(run.events):
138
- yield f"data: {json.dumps(run.events[sent])}\n\n"
139
- sent += 1
140
- if run.done and sent >= len(run.events):
141
- yield "event: close\ndata: {}\n\n"
142
- return
143
- await asyncio.sleep(0.25)
144
-
145
- return StreamingResponse(stream(), media_type="text/event-stream",
146
- headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
147
-
148
-
149
- @app.get("/api/episodes")
150
- def list_episodes() -> list[dict]:
151
- if not EPISODES_DIR.exists():
152
- return []
153
- out = []
154
- for path in sorted(EPISODES_DIR.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True)[:50]:
155
- rec = json.loads(path.read_text("utf-8"))
156
- out.append({k: rec[k] for k in ("id", "task", "seed", "use_lessons", "outcome", "steps", "finished_at")})
157
- return out
158
-
159
-
160
- @app.get("/api/episodes/{episode_id}")
161
- def get_episode(episode_id: str) -> dict:
162
- if not episode_id.isalnum():
163
- raise HTTPException(400, "Bad episode id.")
164
- path = EPISODES_DIR / f"{episode_id}.json"
165
- if not path.exists():
166
- raise HTTPException(404, "No such episode.")
167
- return json.loads(path.read_text("utf-8"))
168
-
169
-
170
- @app.get("/")
171
- def index() -> FileResponse:
172
- return FileResponse(WEB_DIR / "index.html")
173
-
174
-
175
- app.mount("/static", StaticFiles(directory=WEB_DIR), name="static")
 
1
+ """Lantern web server: runs Beam episodes and streams them to the browser over SSE.
2
+
3
+ Live runs spend the Space owner's inference credits, so they are limited to one at a
4
+ time and a daily cap. Every finished episode is saved and can be replayed for free.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import json
11
+ import os
12
+ import threading
13
+ import time
14
+ import uuid
15
+ from datetime import date
16
+ from pathlib import Path
17
+
18
+ from fastapi import FastAPI, HTTPException
19
+ from fastapi.responses import FileResponse, StreamingResponse
20
+ from fastapi.staticfiles import StaticFiles
21
+ from pydantic import BaseModel, Field
22
+
23
+ from lantern.agent import EpisodeConfig, run_episode
24
+ from lantern.env import TASKS, DungeonEnv
25
+ from lantern.journal import Journal
26
+ from lantern.llm import ChatClient
27
+ from lantern.store import EpisodeStore
28
+
29
+ DATA_DIR = Path(os.environ.get("LANTERN_DATA", "data"))
30
+ DAILY_LIVE_RUNS = int(os.environ.get("LANTERN_DAILY_RUNS", "20"))
31
+ WEB_DIR = Path(__file__).parent / "web"
32
+
33
+ app = FastAPI(title="Lantern")
34
+ # Episodes and the journal are mirrored to a Hub dataset so they survive restarts.
35
+ store = EpisodeStore(DATA_DIR, os.environ.get("LANTERN_DATASET"), os.environ.get("LANTERN_DATASET_TOKEN"))
36
+ store.pull()
37
+ journal = Journal(store.journal_path)
38
+
39
+
40
+ class Run:
41
+ """One live episode running in a background thread, buffering its events."""
42
+
43
+ def __init__(self, task_id: str, seed: int, use_lessons: bool):
44
+ self.id = uuid.uuid4().hex[:12]
45
+ self.task_id, self.seed, self.use_lessons = task_id, seed, use_lessons
46
+ self.events: list[dict] = []
47
+ self.done = False
48
+ self.stop = threading.Event()
49
+
50
+ def start(self) -> None:
51
+ threading.Thread(target=self._play, daemon=True).start()
52
+
53
+ def _play(self) -> None:
54
+ env = None
55
+ try:
56
+ env = DungeonEnv(TASKS[self.task_id], seed=self.seed)
57
+ lessons = journal.lessons(self.task_id)
58
+ config = EpisodeConfig(use_lessons=self.use_lessons)
59
+ for event in run_episode(env, ChatClient(), lessons, config, self.stop):
60
+ if event["type"] == "end" and event["new_lessons"]:
61
+ event["journal"] = journal.add(self.task_id, event["new_lessons"])
62
+ store.push_journal()
63
+ self.events.append(event)
64
+ except Exception as exc: # surface the failure to the viewer instead of hanging
65
+ self.events.append({"type": "error", "message": f"{type(exc).__name__}: {exc}"})
66
+ finally:
67
+ if env is not None:
68
+ env.close()
69
+ self.done = True
70
+ self._save()
71
+
72
+ def _save(self) -> None:
73
+ end = next((e for e in reversed(self.events) if e["type"] == "end"), {})
74
+ store.save({"id": self.id, "task": self.task_id, "seed": self.seed, "source": "space",
75
+ "use_lessons": self.use_lessons, "finished_at": time.time(),
76
+ "outcome": end.get("outcome", "error"), "steps": end.get("steps", 0),
77
+ "events": self.events})
78
+
79
+
80
+ runs: dict[str, Run] = {}
81
+ _quota = {"day": date.today(), "used": 0}
82
+ _lock = threading.Lock()
83
+
84
+
85
+ class RunRequest(BaseModel):
86
+ task: str
87
+ seed: int = Field(default=0, ge=0, le=1_000_000)
88
+ use_lessons: bool = True
89
+
90
+
91
+ @app.get("/api/tasks")
92
+ def list_tasks() -> list[dict]:
93
+ return [{"id": t.id, "title": t.title, "goal": t.goal, "max_steps": t.max_steps,
94
+ "lessons": len(journal.lessons(t.id))} for t in TASKS.values()]
95
+
96
+
97
+ @app.get("/api/status")
98
+ def status() -> dict:
99
+ busy = any(not r.done for r in runs.values())
100
+ return {"busy": busy, "runs_left_today": max(0, DAILY_LIVE_RUNS - _quota["used"])}
101
+
102
+
103
+ @app.post("/api/runs")
104
+ def start_run(req: RunRequest) -> dict:
105
+ if req.task not in TASKS:
106
+ raise HTTPException(404, f"Unknown level '{req.task}'.")
107
+ with _lock:
108
+ if _quota["day"] != date.today():
109
+ _quota.update(day=date.today(), used=0)
110
+ if any(not r.done for r in runs.values()):
111
+ raise HTTPException(409, "Beam is already exploring. Watch that run or open a replay.")
112
+ if _quota["used"] >= DAILY_LIVE_RUNS:
113
+ raise HTTPException(429, "Today's live runs are used up. Replays are still available.")
114
+ _quota["used"] += 1
115
+ run = Run(req.task, req.seed, req.use_lessons)
116
+ runs[run.id] = run
117
+ run.start()
118
+ return {"id": run.id}
119
+
120
+
121
+ @app.post("/api/runs/{run_id}/stop")
122
+ def stop_run(run_id: str) -> dict:
123
+ run = runs.get(run_id)
124
+ if run is None:
125
+ raise HTTPException(404, "No such run.")
126
+ run.stop.set()
127
+ return {"stopping": True}
128
+
129
+
130
+ @app.get("/api/runs/{run_id}/events")
131
+ async def run_events(run_id: str) -> StreamingResponse:
132
+ run = runs.get(run_id)
133
+ if run is None:
134
+ raise HTTPException(404, "No such run.")
135
+
136
+ async def stream():
137
+ sent = 0
138
+ while True:
139
+ while sent < len(run.events):
140
+ yield f"data: {json.dumps(run.events[sent])}\n\n"
141
+ sent += 1
142
+ if run.done and sent >= len(run.events):
143
+ yield "event: close\ndata: {}\n\n"
144
+ return
145
+ await asyncio.sleep(0.25)
146
+
147
+ return StreamingResponse(stream(), media_type="text/event-stream",
148
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
149
+
150
+
151
+ @app.get("/api/episodes")
152
+ def list_episodes() -> list[dict]:
153
+ keys = ("id", "task", "seed", "use_lessons", "outcome", "steps", "finished_at")
154
+ return [{k: rec.get(k) for k in keys} | {"source": rec.get("source", "space")}
155
+ for rec in store.episodes()[:50]]
156
+
157
+
158
+ @app.get("/api/episodes/{episode_id}")
159
+ def get_episode(episode_id: str) -> dict:
160
+ if not episode_id.isalnum():
161
+ raise HTTPException(400, "Bad episode id.")
162
+ record = store.load(episode_id)
163
+ if record is None:
164
+ raise HTTPException(404, "No such episode.")
165
+ return record
166
+
167
+
168
+ @app.get("/")
169
+ def index() -> FileResponse:
170
+ return FileResponse(WEB_DIR / "index.html")
171
+
172
+
173
+ app.mount("/static", StaticFiles(directory=WEB_DIR), name="static")
 
 
lantern/agent.py CHANGED
@@ -136,6 +136,8 @@ def run_episode(env: Env, client: ChatClient, lessons: list[str] | None = None,
136
 
137
  yield {"type": "start", "task": task.id, "title": task.title, "goal": task.goal,
138
  "actions": env.action_names, "lessons": list(lessons or []) if config.use_lessons else [],
 
 
139
  "frame": frame.to_dict()}
140
 
141
  while outcome is None:
 
136
 
137
  yield {"type": "start", "task": task.id, "title": task.title, "goal": task.goal,
138
  "actions": env.action_names, "lessons": list(lessons or []) if config.use_lessons else [],
139
+ # Exact teacher context, so trajectories can be replayed as training data.
140
+ "model": getattr(client, "model", None), "system": system, "tools": tools,
141
  "frame": frame.to_dict()}
142
 
143
  while outcome is None:
lantern/bench.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Benchmark series: play one level across seeds, optionally carrying the journal forward."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ import uuid
7
+
8
+ from .agent import EpisodeConfig, run_episode
9
+ from .env import TASKS, DungeonEnv
10
+ from .journal import merge_lessons
11
+ from .llm import ChatClient
12
+ from .store import EpisodeStore
13
+
14
+ SEED_BASE = 1000 # benchmark seeds stay clear of the low seeds visitors try in the Space
15
+
16
+
17
+ def bench_seeds(n: int) -> list[int]:
18
+ return [SEED_BASE + i for i in range(n)]
19
+
20
+
21
+ def play_series(task_id: str, seeds: list[int], use_lessons: bool, tag: str,
22
+ client: ChatClient, store: EpisodeStore, source: str = "bench") -> list[dict]:
23
+ """Play `seeds` in order on one level and save every episode; returns per-episode summaries.
24
+
25
+ Each series starts with an empty journal; with `use_lessons`, lessons written after
26
+ each episode are merged in before the next seed.
27
+ """
28
+ config = EpisodeConfig(use_lessons=use_lessons, reflect=use_lessons)
29
+ lessons: list[str] = []
30
+ summaries = []
31
+ for seed in seeds:
32
+ env = DungeonEnv(TASKS[task_id], seed=seed)
33
+ try:
34
+ events = list(run_episode(env, client, lessons, config))
35
+ except Exception as exc: # one bad episode must not sink the series
36
+ events = [{"type": "error", "message": f"{type(exc).__name__}: {exc}"}]
37
+ finally:
38
+ env.close()
39
+ end = next((e for e in events if e["type"] == "end"), {})
40
+ lessons_in = len(lessons)
41
+ if use_lessons and end.get("new_lessons"):
42
+ lessons = merge_lessons(lessons, end["new_lessons"], 12)
43
+ record = {"id": uuid.uuid4().hex[:12], "task": task_id, "seed": seed, "source": source,
44
+ "bench": tag, "model": client.model, "use_lessons": use_lessons,
45
+ "finished_at": time.time(), "outcome": end.get("outcome", "error"),
46
+ "steps": end.get("steps", 0), "lessons_in": lessons_in, "events": events}
47
+ store.save(record, wait=True)
48
+ summaries.append({k: record[k] for k in ("id", "task", "seed", "use_lessons", "outcome", "steps")}
49
+ | {"usage": end.get("usage", {})})
50
+ return summaries
51
+
52
+
53
+ def summary_table(results: list[dict], task_ids: list[str], conditions: list[str]) -> str:
54
+ """Plain-text results table: wins, average steps, and tokens per level and condition."""
55
+ lines = [f"{'level':14}{'journal':>8}{'won':>8}{'avg steps':>11}{'tokens':>12}"]
56
+ for t in task_ids:
57
+ for cond in conditions:
58
+ rows = [r for r in results if r["task"] == t and r["use_lessons"] == (cond == "on")]
59
+ if not rows:
60
+ continue
61
+ won = sum(r["outcome"] == "success" for r in rows)
62
+ steps = sum(r["steps"] for r in rows) / len(rows)
63
+ tok = sum(r["usage"].get("prompt_tokens", 0) + r["usage"].get("completion_tokens", 0) for r in rows)
64
+ lines.append(f"{t:14}{cond:>8}{won:>4}/{len(rows):<3}{steps:>11.1f}{tok:>12,}")
65
+ return "\n".join(lines)
lantern/export.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Turn recorded episodes into supervised fine-tuning rows for a student model.
2
+
3
+ Each Beam call becomes one chat example: the system prompt and observation Beam saw, and
4
+ the tool calls it made. Only calls that executed something are kept, and by default only
5
+ episodes Beam won, so the student imitates successful play.
6
+
7
+ python -m lantern.export data/ train.jsonl
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ from .store import EpisodeStore
17
+
18
+
19
+ def tool_calls_for(executed: list[dict]) -> list[dict]:
20
+ """Rebuild OpenAI-style tool calls from the agent's executed-tool records."""
21
+ calls = []
22
+ for i, e in enumerate(executed):
23
+ if e["tool"] == "act":
24
+ args = {"actions": e["planned"]}
25
+ elif e["tool"] == "write_notes":
26
+ args = {"notes": e["notes"]}
27
+ else:
28
+ continue
29
+ calls.append({"id": f"call_{i}", "type": "function",
30
+ "function": {"name": e["tool"], "arguments": json.dumps(args)}})
31
+ return calls
32
+
33
+
34
+ def episode_rows(record: dict) -> list[dict]:
35
+ """SFT rows for one episode, or [] if it lacks the teacher context (older recordings)."""
36
+ start = next((e for e in record["events"] if e["type"] == "start"), None)
37
+ if not start or "system" not in start:
38
+ return []
39
+ rows = []
40
+ for e in record["events"]:
41
+ if e["type"] != "turn" or not e["executed"]:
42
+ continue
43
+ rows.append({
44
+ "messages": [
45
+ {"role": "system", "content": start["system"]},
46
+ {"role": "user", "content": e["prompt"]},
47
+ {"role": "assistant", "content": "", "tool_calls": tool_calls_for(e["executed"])},
48
+ ],
49
+ "tools": start["tools"],
50
+ "task": record["task"], "seed": record.get("seed"), "episode": record["id"],
51
+ })
52
+ return rows
53
+
54
+
55
+ def export(root: Path, out: Path, outcomes: tuple[str, ...] = ("success",)) -> int:
56
+ """Write SFT rows from every stored episode with a matching outcome; returns the row count."""
57
+ n = 0
58
+ with out.open("w", encoding="utf-8") as f:
59
+ for record in EpisodeStore(root).episodes():
60
+ if record.get("outcome") not in outcomes:
61
+ continue
62
+ for row in episode_rows(record):
63
+ f.write(json.dumps(row) + "\n")
64
+ n += 1
65
+ return n
66
+
67
+
68
+ if __name__ == "__main__":
69
+ print(export(Path(sys.argv[1]), Path(sys.argv[2])), "rows")
lantern/llm.py CHANGED
@@ -7,6 +7,7 @@ production) or the local `hf auth login` cache. It is never logged.
7
  from __future__ import annotations
8
 
9
  import os
 
10
  import time
11
  from dataclasses import dataclass, field
12
  from pathlib import Path
@@ -47,27 +48,39 @@ class ChatClient:
47
  """
48
 
49
  def __init__(self, model: str = BEAM_MODEL, url: str = ROUTER_URL,
50
- token: str | None = None, timeout_s: float = 300.0, retries: int = 3):
 
 
 
51
  self.model = model
52
  self.url = url
 
 
53
  self._headers = {"Authorization": f"Bearer {token or _load_token()}"}
54
  self._http = httpx.Client(timeout=timeout_s)
55
  self._retries = retries
56
 
57
  def chat(self, messages: list[dict], tools: list[dict] | None = None,
58
- temperature: float = 0.7, max_tokens: int = 16384) -> Reply:
59
  """Send one chat completion request, retrying transient failures."""
60
  body = {"model": self.model, "messages": messages, "temperature": temperature,
61
- "top_p": 0.99, "max_tokens": max_tokens}
 
62
  if tools:
63
  body["tools"] = tools
64
  body["tool_choice"] = "auto"
65
 
66
  for attempt in range(self._retries):
67
  start = time.monotonic()
68
- resp = self._http.post(self.url, json=body, headers=self._headers)
 
 
 
 
 
 
69
  if resp.status_code in (429, 500, 502, 503, 504) and attempt < self._retries - 1:
70
- time.sleep(2 ** attempt)
71
  continue
72
  resp.raise_for_status()
73
  data = resp.json()
 
7
  from __future__ import annotations
8
 
9
  import os
10
+ import random
11
  import time
12
  from dataclasses import dataclass, field
13
  from pathlib import Path
 
48
  """
49
 
50
  def __init__(self, model: str = BEAM_MODEL, url: str = ROUTER_URL,
51
+ token: str | None = None, timeout_s: float = 300.0, retries: int = 6,
52
+ max_tokens: int = 16384, extra_body: dict | None = None):
53
+ """`max_tokens` is the default reply budget; `extra_body` is merged into every
54
+ request (e.g. `chat_template_kwargs` for a self-hosted student model)."""
55
  self.model = model
56
  self.url = url
57
+ self.max_tokens = max_tokens
58
+ self.extra_body = extra_body or {}
59
  self._headers = {"Authorization": f"Bearer {token or _load_token()}"}
60
  self._http = httpx.Client(timeout=timeout_s)
61
  self._retries = retries
62
 
63
  def chat(self, messages: list[dict], tools: list[dict] | None = None,
64
+ temperature: float = 0.7, max_tokens: int | None = None) -> Reply:
65
  """Send one chat completion request, retrying transient failures."""
66
  body = {"model": self.model, "messages": messages, "temperature": temperature,
67
+ "top_p": 0.99, "max_tokens": min(max_tokens or self.max_tokens, self.max_tokens),
68
+ **self.extra_body}
69
  if tools:
70
  body["tools"] = tools
71
  body["tool_choice"] = "auto"
72
 
73
  for attempt in range(self._retries):
74
  start = time.monotonic()
75
+ try:
76
+ resp = self._http.post(self.url, json=body, headers=self._headers)
77
+ except httpx.TransportError:
78
+ if attempt == self._retries - 1:
79
+ raise
80
+ time.sleep(2 ** attempt + random.random())
81
+ continue
82
  if resp.status_code in (429, 500, 502, 503, 504) and attempt < self._retries - 1:
83
+ time.sleep(2 ** attempt + random.random()) # jitter so parallel runs spread out
84
  continue
85
  resp.raise_for_status()
86
  data = resp.json()
lantern/store.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Episode storage: local JSON files mirrored to a Hugging Face dataset repo.
2
+
3
+ Layout (same locally and on the Hub):
4
+ episodes/{task}/{episode_id}.json
5
+ journal.json
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import logging
12
+ import threading
13
+ import time
14
+ from pathlib import Path
15
+
16
+ log = logging.getLogger("lantern.store")
17
+
18
+
19
+ class EpisodeStore:
20
+ """Writes episodes to `root`, and, when `repo_id` is set, mirrors them to the Hub."""
21
+
22
+ def __init__(self, root: Path, repo_id: str | None = None, token: str | None = None):
23
+ self.root = Path(root)
24
+ self.repo_id = repo_id
25
+ self.token = token
26
+ (self.root / "episodes").mkdir(parents=True, exist_ok=True)
27
+
28
+ @property
29
+ def journal_path(self) -> Path:
30
+ return self.root / "journal.json"
31
+
32
+ def pull(self) -> None:
33
+ """Download existing episodes and the journal from the Hub, if configured."""
34
+ if not self.repo_id:
35
+ return
36
+ from huggingface_hub import snapshot_download
37
+ try:
38
+ snapshot_download(self.repo_id, repo_type="dataset", local_dir=self.root, token=self.token,
39
+ allow_patterns=["episodes/**", "journal.json"])
40
+ except Exception as exc: # the app still works from local files
41
+ log.warning("Could not pull episodes from %s: %s", self.repo_id, exc)
42
+
43
+ def save(self, record: dict, wait: bool = False) -> Path:
44
+ """Write one episode record locally, then upload it (in the background unless `wait`)."""
45
+ rel = Path("episodes") / record["task"] / f"{record['id']}.json"
46
+ path = self.root / rel
47
+ path.parent.mkdir(parents=True, exist_ok=True)
48
+ path.write_text(json.dumps(record), "utf-8")
49
+ self._upload([(path, rel.as_posix())], f"Add episode {record['id']} ({record['task']})", wait)
50
+ return path
51
+
52
+ def push_journal(self) -> None:
53
+ if self.journal_path.exists():
54
+ self._upload([(self.journal_path, "journal.json")], "Update journal")
55
+
56
+ def episodes(self) -> list[dict]:
57
+ """All stored episode records, newest first."""
58
+ records = [json.loads(p.read_text("utf-8")) for p in (self.root / "episodes").rglob("*.json")]
59
+ return sorted(records, key=lambda r: r.get("finished_at", 0), reverse=True)
60
+
61
+ def load(self, episode_id: str) -> dict | None:
62
+ match = next((self.root / "episodes").rglob(f"{episode_id}.json"), None)
63
+ return json.loads(match.read_text("utf-8")) if match else None
64
+
65
+ def _upload(self, files: list[tuple[Path, str]], message: str, wait: bool = False) -> None:
66
+ if not (self.repo_id and self.token):
67
+ return
68
+
69
+ def run() -> None:
70
+ from huggingface_hub import CommitOperationAdd, HfApi
71
+ for attempt in range(4): # parallel writers can race on the repo head
72
+ try:
73
+ HfApi(token=self.token).create_commit(
74
+ self.repo_id, repo_type="dataset", commit_message=message,
75
+ operations=[CommitOperationAdd(path_in_repo=rel, path_or_fileobj=str(p)) for p, rel in files])
76
+ return
77
+ except Exception as exc:
78
+ log.warning("Upload to %s failed (attempt %d): %s", self.repo_id, attempt + 1, exc)
79
+ time.sleep(2 + attempt * 3)
80
+
81
+ if wait:
82
+ run()
83
+ else:
84
+ threading.Thread(target=run, daemon=True).start()
requirements.txt CHANGED
@@ -4,3 +4,4 @@ httpx==0.28.1
4
  pydantic>=2.7,<3
5
  nle==1.3.0
6
  minihack==1.0.2
 
 
4
  pydantic>=2.7,<3
5
  nle==1.3.0
6
  minihack==1.0.2
7
+ huggingface_hub==0.36.2