opal-optimizer 0.3.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. opal/__init__.py +7 -0
  2. opal/_version.py +3 -0
  3. opal/agent.py +394 -0
  4. opal/backends/__init__.py +31 -0
  5. opal/backends/_codex_client.py +450 -0
  6. opal/backends/_privacy.py +116 -0
  7. opal/backends/base.py +248 -0
  8. opal/backends/claude.py +665 -0
  9. opal/backends/codex.py +356 -0
  10. opal/backends/mailbox.py +1034 -0
  11. opal/backends/unavailable.py +161 -0
  12. opal/cli.py +396 -0
  13. opal/context.py +111 -0
  14. opal/defaults.json +11 -0
  15. opal/defaults.py +54 -0
  16. opal/estimate.py +120 -0
  17. opal/hf.py +109 -0
  18. opal/misc/__init__.py +2 -0
  19. opal/misc/aggressive.py +157 -0
  20. opal/misc/audit.py +84 -0
  21. opal/misc/boundary.py +226 -0
  22. opal/misc/checkpoint.py +399 -0
  23. opal/misc/ddp.py +147 -0
  24. opal/misc/integrity.py +263 -0
  25. opal/misc/nested.py +67 -0
  26. opal/misc/neutral_analysis.py +166 -0
  27. opal/misc/precision.py +210 -0
  28. opal/misc/recovery.py +231 -0
  29. opal/misc/schedulers.py +214 -0
  30. opal/misc/setup.py +253 -0
  31. opal/misc/skip.py +165 -0
  32. opal/misc/taskfacts.py +293 -0
  33. opal/misc/waiting.py +58 -0
  34. opal/programs.py +482 -0
  35. opal/prompts/example_updater.py +252 -0
  36. opal/prompts/neutral.md +206 -0
  37. opal/report.py +1001 -0
  38. opal/report_page.py +175 -0
  39. opal/report_privacy.py +159 -0
  40. opal/results.py +80 -0
  41. opal/schedule.py +134 -0
  42. opal/summaries.py +224 -0
  43. opal/wrapper.py +1674 -0
  44. opal_optimizer-0.3.5.dist-info/METADATA +232 -0
  45. opal_optimizer-0.3.5.dist-info/RECORD +49 -0
  46. opal_optimizer-0.3.5.dist-info/WHEEL +5 -0
  47. opal_optimizer-0.3.5.dist-info/entry_points.txt +2 -0
  48. opal_optimizer-0.3.5.dist-info/licenses/LICENSE +21 -0
  49. opal_optimizer-0.3.5.dist-info/top_level.txt +1 -0
opal/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """OPAL (OPtimizer with an Agent in the Loop): a coding agent inside your training run, rewriting the optimizer while
2
+ it trains."""
3
+
4
+ from ._version import __version__
5
+ from .wrapper import AgentOptimizer, final_program, replay, wrap
6
+
7
+ __all__ = ["wrap", "replay", "final_program", "AgentOptimizer"]
opal/_version.py ADDED
@@ -0,0 +1,3 @@
1
+ """The package, build metadata and backend clients share this version."""
2
+
3
+ __version__ = "0.3.5"
opal/agent.py ADDED
@@ -0,0 +1,394 @@
1
+ """The agent side of a run on rank 0: run folder, one decision at a time in a background thread, the message text."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime
6
+ import json
7
+ import math
8
+ import os
9
+ import secrets
10
+ import sys
11
+ import threading
12
+ import time
13
+ import traceback
14
+ from pathlib import Path
15
+
16
+ from . import programs
17
+ from .misc import aggressive, audit
18
+ from .backends._privacy import redact_secrets
19
+
20
+
21
+ class Run:
22
+ """Everything a run leaves behind: workspace (what the agent sees), programs/vNNN (every applied version),
23
+ decisions/NNN (message, backend transcript, response), events.jsonl, reports.jsonl, losses.jsonl."""
24
+
25
+ def __init__(self, path):
26
+ self.dir = Path(path).resolve()
27
+ self.workspace = self.dir / "workspace"
28
+ self.programs = self.dir / "programs"
29
+ self.decisions = self.dir / "decisions"
30
+ for d in (self.workspace / "inbox", self.workspace / "reference", self.programs, self.decisions):
31
+ d.mkdir(parents=True, exist_ok=True)
32
+ self._notes_seen = 0
33
+
34
+ def event(self, kind, **kw):
35
+ rec = dict(kind=kind, utc=datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"), **kw)
36
+ self._write("events.jsonl", rec)
37
+
38
+ def append(self, name, rec):
39
+ self._write(name, rec)
40
+
41
+ def _write(self, name, rec):
42
+ """Records never stop training: on a full disk or an exhausted quota, warn once and go on."""
43
+ try:
44
+ with open(self.dir / name, "a") as f:
45
+ f.write(json.dumps(rec, default=str) + "\n")
46
+ except OSError as exc:
47
+ if not getattr(self, "_write_failed", False):
48
+ self._write_failed = True
49
+ print(
50
+ f"[opal] could not write the run records ({exc}); training continues",
51
+ file=sys.stderr,
52
+ flush=True,
53
+ )
54
+
55
+ def new_notes(self):
56
+ f = self.dir / "notes.txt"
57
+ if not f.exists():
58
+ return ""
59
+ text = f.read_text()
60
+ new, self._notes_seen = text[self._notes_seen :], len(text)
61
+ return new.strip()
62
+
63
+
64
+ class AgentWorker:
65
+ def __init__(self, run: Run, backend, system_text: str, log=print):
66
+ self.run, self.backend, self.system, self.log = run, backend, system_text, log
67
+ self.thread = None
68
+ self.ready = None
69
+ self.k = 0
70
+ self.cost = 0.0
71
+ self.unknown_cost_decisions = set() # a request stays unknown until it reports a numeric charge
72
+ self.usage = None # present only for token-reporting backends (Codex, including through a mailbox)
73
+ self._last = None # (k, req) of the newest request
74
+ self.after_decision = None # fn(k, req, resp), called once a response is recorded (a late one: in training)
75
+ backend.setup(run.dir, run.workspace)
76
+ (run.dir / "system.md").write_text(system_text)
77
+
78
+ @property
79
+ def recorded_cost(self):
80
+ if self.unknown_cost_decisions or self.usage is not None or self.backend.name == "codex":
81
+ return None
82
+ return round(self.cost, 4)
83
+
84
+ @property
85
+ def busy(self) -> bool:
86
+ return self.ready is not None or (self.thread is not None and self.thread.is_alive())
87
+
88
+ def submit(self, req: dict) -> int:
89
+ assert not self.busy
90
+ self.k += 1
91
+ self.unknown_cost_decisions.add(self.k)
92
+ self._last = (self.k, req)
93
+ self.thread = threading.Thread(
94
+ target=self._decide, args=(self.k, req), name=f"opal-decision-{self.k}", daemon=True
95
+ )
96
+ self.thread.start()
97
+ return self.k
98
+
99
+ def wait(self):
100
+ if self.thread is not None:
101
+ self.thread.join()
102
+
103
+ def take(self):
104
+ resp, self.ready = self.ready, None
105
+ return resp
106
+
107
+ def collect_late(self):
108
+ """An answer that reached the backend after it stopped waiting (a mailbox whose broker came back) while no
109
+ newer request is out: checked and recorded like any answer, so it is applied at the next update boundary."""
110
+ late = getattr(self.backend, "late_answer", None)
111
+ if late is None or self.busy or self._last is None:
112
+ return
113
+ try:
114
+ out = late()
115
+ except Exception:
116
+ return # an unreadable mailbox never stops training; the next request says the answer was discarded
117
+ if out is not None:
118
+ self._finish(*self._last, dict(out, late=True))
119
+
120
+ def _decide(self, k, req):
121
+ rec = self.run.decisions / f"{k:03d}"
122
+ ws = self.run.workspace
123
+ try:
124
+ rec.mkdir(parents=True, exist_ok=True)
125
+ (ws / "scratch").mkdir(exist_ok=True)
126
+ programs.write_dir(ws, req["files"]) # the workspace starts from the running programs at every request
127
+ (ws / "inbox" / f"decision-{k:03d}.json").write_text(json.dumps(req["inbox"], indent=1, default=str))
128
+ message = format_message(k, req)
129
+ (rec / "message.txt").write_text(message)
130
+ self.backend.log = lambda text, k=k: self.log(f"decision {k}: {redact_secrets(text)}")
131
+ self.backend.training_waits = bool(req.get("blocking")) # for the line when the agent is unavailable
132
+ out = self.backend.decide(ws, message, self.system, rec)
133
+ except Exception:
134
+ out = dict(text="", cost_usd=None, returncode=-1, error=traceback.format_exc(limit=6)[-3000:], seconds=0.0)
135
+ self._finish(k, req, out)
136
+
137
+ def _finish(self, k, req, out):
138
+ """Check the programs the agent left in the workspace and record its answer as ready."""
139
+ rec, ws = self.run.decisions / f"{k:03d}", self.run.workspace
140
+ out = dict(out)
141
+ for field in ("error", "text"):
142
+ if out.get(field) is not None:
143
+ out[field] = redact_secrets(out[field])
144
+ files, changed, errors = None, False, []
145
+ try:
146
+ files = programs.program(programs.read_dir(ws))
147
+ changed = files != req["files"]
148
+ failed = out.get("error") or out.get("returncode") not in (0, None)
149
+ if changed and failed:
150
+ errors = [
151
+ "the agent call did not finish ("
152
+ + str(out.get("error") or f"return code {out.get('returncode')}")[:300]
153
+ + "); its edits were not applied"
154
+ ]
155
+ elif changed:
156
+ errors = programs.check_syntax(files)
157
+ except Exception:
158
+ errors = ["could not read the edited files: " + traceback.format_exc(limit=3)[-800:]]
159
+ charge = out.get("cost_usd")
160
+ if isinstance(charge, (int, float)) and not isinstance(charge, bool) and math.isfinite(charge):
161
+ self.cost += float(charge)
162
+ self.unknown_cost_decisions.discard(k)
163
+ if isinstance(out.get("usage"), dict):
164
+ if self.usage is None:
165
+ self.usage = {}
166
+ for key, value in out["usage"].items():
167
+ self.usage[key] = self.usage.get(key, 0) + value
168
+ error = out.get("error")
169
+ if not error and out.get("returncode") not in (0, None): # a failed call always carries an error
170
+ error = f"the agent call ended with exit code {out.get('returncode')}"
171
+ resp = dict(
172
+ k=k,
173
+ requested_at=req["update"],
174
+ base=req["files"],
175
+ files=files if changed and not errors else None,
176
+ errors=errors,
177
+ text=(out.get("text") or "")[-8000:],
178
+ cost_usd=out.get("cost_usd"),
179
+ returncode=out.get("returncode"),
180
+ backend_error=error or None,
181
+ seconds=round(out.get("seconds") or 0.0, 1),
182
+ **({"unavailable_seconds": out["unavailable_seconds"]} if out.get("unavailable_seconds") else {}),
183
+ **{x: out[x] for x in ("usage", "session", "rollout") if x in out},
184
+ **{x: True for x in ("gave_up", "late") if out.get(x)},
185
+ )
186
+ try: # written whole, so that `opal show` never reads half a file
187
+ tmp = rec / "response.json.tmp"
188
+ tmp.write_text(json.dumps({x: v for x, v in resp.items() if x != "base"}, indent=1))
189
+ os.replace(tmp, rec / "response.json")
190
+ if errors and changed and files: # the workspace goes back to the running programs next time; keep them
191
+ programs.write_dir(rec / "rejected", files)
192
+ except Exception:
193
+ pass
194
+ if self.after_decision is not None:
195
+ try:
196
+ self.after_decision(k, req, resp)
197
+ except Exception:
198
+ pass
199
+ self.ready = resp
200
+
201
+
202
+ def _ms(v):
203
+ return "not measured yet" if v is None else f"{v:.3g} ms"
204
+
205
+
206
+ def _chunks(values, n=100):
207
+ if len(values) <= n:
208
+ return values, 1
209
+ size = -(-len(values) // n)
210
+ return [sum(values[i : i + size]) / len(values[i : i + size]) for i in range(0, len(values), size)], size
211
+
212
+
213
+ def previous_answer(prev, last=None):
214
+ """One sentence on what became of the agent's previous answer: when its edit took effect, or why it did not."""
215
+ if not prev:
216
+ return None
217
+ k, r, u, kind = prev["k"], prev.get("requested_at") or 0, prev.get("applied_at") or 0, prev.get("outcome")
218
+ if kind == "applied":
219
+ s = (
220
+ f"Your edit from decision {k} (requested at update {r:,}) took effect at update {u:,}, {u - r:,} updates "
221
+ "after the state you saw"
222
+ )
223
+ if prev.get("late"):
224
+ s += "; it arrived after the run had stopped waiting for it, and was applied when it came"
225
+ if last and last.get("k") == k and last.get("reverted_at"):
226
+ s += f"; it failed and was reverted at update {last['reverted_at']:,} (the errors below say why)"
227
+ return s + "."
228
+ if prev.get("gave_up"):
229
+ why = str(prev.get("backend_error") or "no answer").split(" (")[0]
230
+ return (
231
+ f"Decision {k} (requested at update {r:,}) was not answered in time: the run stopped waiting for it "
232
+ f"after {prev.get('seconds') or 0:.0f} s ({why}). If you answered it, your answer arrived after the run "
233
+ "had stopped waiting and was discarded, and this folder was reset to the running programs."
234
+ )
235
+ if prev.get("backend_error") is not None:
236
+ why = " ".join(str(prev["backend_error"]).split())[:200] or "no error text"
237
+ return (
238
+ f"Decision {k} was not answered: the agent call failed ({why}). The programs were not changed (requested "
239
+ f"at update {r:,})."
240
+ )
241
+ if kind == "rejected":
242
+ return (
243
+ f"Decision {k}: your edit was not applied and was discarded at update {u:,} (the errors below say why); "
244
+ "this folder holds the running programs."
245
+ )
246
+ if kind == "load_failed":
247
+ return (
248
+ f"Decision {k}: your new programs failed to load at update {u:,} and were discarded (see the errors "
249
+ "below); this folder holds the running programs."
250
+ )
251
+ return f"Decision {k} (requested at update {r:,}, answered by update {u:,}): you kept the programs."
252
+
253
+
254
+ DEV_SHOWN = 50 # development measurements written into a message; all of them are in the inbox
255
+
256
+
257
+ def development_line(k, dev) -> str:
258
+ """The development-set history, oldest first: 'update: loss, accuracy' for the most recent DEV_SHOWN."""
259
+ head = "Development set (held out from training; not the test set in the goal)"
260
+ if not dev:
261
+ return head + ": no measurement yet."
262
+ keys = [x for x in ("loss", "accuracy") if any(x in d for d in dev)]
263
+ shown = dev[-DEV_SHOWN:]
264
+ which = (
265
+ f"all {len(dev)} measurements so far"
266
+ if len(shown) == len(dev)
267
+ else f"the most recent {len(shown)} of {len(dev)} measurements (all in inbox/decision-{k:03d}.json)"
268
+ )
269
+
270
+ def entry(d):
271
+ return f"{d['update']:,}: " + ", ".join(f"{d[x]:.4g}" if x in d else "-" for x in keys)
272
+
273
+ return f"{head}, {' and '.join(keys)} at each evaluation, {which} (update: {', '.join(keys)}): " + "; ".join(
274
+ entry(d) for d in shown
275
+ )
276
+
277
+
278
+ def format_message(k, req) -> str:
279
+ u, total = req["update"], req["total_updates"]
280
+ ep = f", epoch {u / total * req['epochs']:.2f} of {req['epochs']:g}" if req.get("epochs") else "" # epochs= given
281
+ lines = [
282
+ f"Decision {k}. Requested at update {u:,} of {total:,} ({100 * u / total:.1f}% of training{ep}), after that "
283
+ "update's backward pass and before its parameter update."
284
+ ]
285
+ if req["blocking"]:
286
+ lines.append(f"Training is paused until you finish; your edits take effect from update {u:,}.")
287
+ else:
288
+ lines.append(
289
+ "Training continues while you work. Your edits take effect at the first update boundary after you finish; "
290
+ "the next message tells you which update that was."
291
+ )
292
+ lines.append("Why now: " + "; ".join(req["reasons"]) + ".")
293
+ if req.get("last"): # max_decisions reached with this one
294
+ lines.append("This is your last decision in this run.")
295
+ elif "next_decision" in req: # show_next_decision=True
296
+ nxt = req["next_decision"]
297
+ lines.append(f"The next scheduled decision is at update {nxt:,}." if nxt else "No scheduled decisions remain.")
298
+ previous = previous_answer(req.get("previous"), req.get("last_applied"))
299
+ if previous:
300
+ lines.append(previous)
301
+ loss, per = _chunks(req["inbox"]["losses"])
302
+ if loss:
303
+ lines.append(
304
+ f"Training loss since your previous decision (mean per {per} update{'s' if per > 1 else ''}, {len(loss)} "
305
+ f"values, from update {req['inbox']['losses_from']:,}): " + ", ".join(f"{v:.4g}" for v in loss)
306
+ )
307
+ else:
308
+ lines.append("No training loss was passed to step(loss=...) since your previous decision.")
309
+ if "development" in req: # wrap(validation=N): OPAL 0.1.1-validation
310
+ lines.append(development_line(k, req["development"]))
311
+ reports = req["inbox"]["reports"]
312
+ made = max(req["inbox"].get("reports_made", len(reports)), len(reports))
313
+ lines.append(
314
+ f"Reports from your analysis since your previous decision: {made} (all in inbox/decision-{k:03d}.json)."
315
+ if made == len(reports)
316
+ else f"Reports from your analysis since your previous decision: {made}; the most recent {len(reports)} are "
317
+ f"in inbox/decision-{k:03d}.json."
318
+ )
319
+ if reports:
320
+ lines.append("Most recent report: " + json.dumps(reports[-1], default=str)[:6000])
321
+ if req["inbox"].get("metrics"):
322
+ lines.append(
323
+ "Values the training script chose to show you (most recent 20): "
324
+ + json.dumps(req["inbox"]["metrics"][-20:], default=str)[:3000]
325
+ )
326
+ skipped = req["inbox"].get("skipped") or []
327
+ if skipped:
328
+ shown = ", ".join(f"{u:,}" for u in skipped[:20]) + (", ..." if len(skipped) > 20 else "")
329
+ lines.append(f"Updates your program skipped since your previous decision: {len(skipped)} ({shown}).")
330
+ errs = req["inbox"]["errors"]
331
+ if errs:
332
+ lines.append(
333
+ "Errors since your previous decision:\n"
334
+ + "\n".join(f"- update {e['update']} in {e['where']}:\n{e['error']}" for e in errs[-10:])
335
+ )
336
+ else:
337
+ lines.append("No errors since your previous decision.")
338
+ t = req["timing"]
339
+ if t.get("wall_ms_per_update") is not None:
340
+ lines.append(
341
+ f"Timing: {t['wall_ms_per_update']:.1f} ms of wall time per training update recently; your updater "
342
+ f"{_ms(t.get('program_ms'))} per update (the starting program: {_ms(t.get('program_ms_start'))})."
343
+ )
344
+ lines.append(
345
+ f"Profile of one update: your updater, {audit.describe(t.get('audit'))}; the starting program, "
346
+ f"{audit.describe(t.get('audit_start'))}."
347
+ + (
348
+ f" Your analysis took {_ms(t['analysis_ms'])} at its last call."
349
+ if t.get("analysis_ms") is not None
350
+ else ""
351
+ )
352
+ )
353
+ if req.get("notes"):
354
+ lines.append("Note from the person running this training: " + req["notes"])
355
+ so_far = f"Decisions so far: {k - 1}; scheduled decisions left: {req['scheduled_left']}"
356
+ neutral = req.get("neutral") # the neutral prompts: no cost
357
+ lines.append(so_far + ("." if neutral else f"; agent cost so far: ${req['cost_so_far']:.2f}."))
358
+ lines.append(
359
+ "Your programs are in this folder ("
360
+ + ", ".join(sorted(req["files"]))
361
+ + "); inbox/ holds every input so far"
362
+ + (
363
+ (
364
+ " and reference/ read-only copies of the training script and user code"
365
+ if req.get("script_shared", True)
366
+ else " and reference/ read-only copies of user code"
367
+ )
368
+ if req.get("shared_code", True)
369
+ else ""
370
+ )
371
+ + "; put scratch scripts in scratch/ (only "
372
+ "updater.py, analysis.py and the modules they import are the program). "
373
+ + (aggressive.EDIT_SENTENCE if req.get("aggressive") else "Edit the programs in place, or keep them (a valid decision). ")
374
+ + (
375
+ aggressive.RATIONALE_SENTENCE
376
+ if req.get("aggressive") # OPAL 0.1.2-aggressive
377
+ else "End with your rationale in the four parts of DECISIONS: observed, changed or kept, prediction, rules."
378
+ if req.get("neutral") # the neutral prompt
379
+ else "End with a brief rationale: what you observed, what you changed or kept, the effect you predict, and "
380
+ "whether it is a fixed schedule or a feedback rule."
381
+ )
382
+ )
383
+ return "\n".join(lines)
384
+
385
+
386
+ def now_stamp():
387
+ return time.strftime("%Y%m%d-%H%M%S")
388
+
389
+
390
+ def new_run_dir(root: Path) -> Path:
391
+ """A fresh run folder: time, process id and a random suffix, so runs started in the same second never share one."""
392
+ path = Path(root) / f"{time.strftime('%Y%m%d-%H%M%S')}-{os.getpid()}-{secrets.token_hex(2)}"
393
+ path.mkdir(parents=True, exist_ok=False)
394
+ return path
@@ -0,0 +1,31 @@
1
+ """Agent backends. Each one runs one decision: the agent reads and edits the files in `workspace` and answers in text.
2
+
3
+ decide(workspace, message, system, record_dir) -> dict(text, cost_usd, returncode, error)
4
+ `system` is the system prompt, passed every time; each backend keeps one persistent conversation across decisions.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from .base import Backend, CallableBackend, KeepBackend
10
+
11
+
12
+ def make_backend(spec, **kw) -> Backend:
13
+ if isinstance(spec, Backend):
14
+ return spec
15
+ if callable(spec):
16
+ return CallableBackend(spec)
17
+ if spec == "keep":
18
+ return KeepBackend()
19
+ if spec == "claude":
20
+ from .claude import ClaudeBackend
21
+
22
+ return ClaudeBackend(**kw)
23
+ if spec == "codex":
24
+ from .codex import CodexBackend
25
+
26
+ return CodexBackend(**kw)
27
+ if isinstance(spec, str) and spec.startswith("mailbox:"):
28
+ from .mailbox import MailboxBackend
29
+
30
+ return MailboxBackend(spec, **kw)
31
+ raise ValueError(f"unknown backend {spec!r}: use 'claude', 'codex', 'mailbox:<dir>', 'keep', or a callable")