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
@@ -0,0 +1,161 @@
1
+ """Waiting for an agent that is unavailable for reasons that are not its own doing.
2
+
3
+ An agent is UNAVAILABLE when its CLI cannot run a turn at all: Claude Code is not logged in (or its key is invalid), a
4
+ usage, spend or rate limit is reached, the API is overloaded or the network is down, the CLI is missing; with a mailbox,
5
+ no broker is answering. Such a decision is not failed: it is kept and waited for, so that a run whose agent is down for
6
+ a while (a subscription switched, a laptop with the broker asleep) makes the same decisions as one whose agent never
7
+ was. The wait has no deadline unless `unavailable_timeout` (seconds) is given; it does not count against the decision's
8
+ own `timeout`, and the agent is told nothing about it. One line says when the wait starts (what is wrong and what to
9
+ do), a reminder follows every REMIND seconds, and one line says when it ends.
10
+
11
+ Failures of the agent's own turn (a crash inside the call, a time limit, a budget it spent) are not this: they keep the
12
+ handling of backends/claude.py (calls made again while the decision's time allows, then a failed decision).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import datetime
18
+ import re
19
+ import time
20
+
21
+ RECHECK, RECHECK_MAX = 60.0, 300.0 # seconds before the first new try, and the longest gap between tries
22
+ REMIND = 600.0 # seconds between reminders that the run is still waiting
23
+ RESET_MARGIN = 30.0 # seconds after a reset time the message names before trying again
24
+
25
+ # Claude Code's words for the kinds of unavailability (2.1.30x; "You've hit your org's monthly spend limit · run
26
+ # /usage-credits ... · your session limit resets 1:10am (America/New_York)", "Not logged in · Please run /login",
27
+ # "API Error: 529 overloaded")
28
+ LOGIN = re.compile(
29
+ r"not logged in|run /login|invalid api key|authentication[_ ]error|oauth token (?:has )?expired", re.I
30
+ )
31
+ # Claude Code's own words for a usage or spend limit: a call whose answer is one of these did not run, also when the CLI
32
+ # exits 0 with it as its answer
33
+ LIMIT = re.compile(
34
+ r"hit your (?:[\w']+ ){0,3}limit|usage limit reached|spend limit reached|credit balance (?:is )?too low", re.I
35
+ )
36
+ RATE = re.compile(r"rate[_ ]limit|too many requests|\b429\b", re.I) # in the error of a failed call only
37
+ DOWN = re.compile(
38
+ r"overloaded|API Error: 5\d\d|\b529\b|ECONNREFUSED|ECONNRESET|ENOTFOUND|ETIMEDOUT|EAI_AGAIN|fetch failed"
39
+ r"|socket hang up|connection (?:error|refused|reset|timed out)|network (?:error|is unreachable)|unable to connect",
40
+ re.I,
41
+ )
42
+ HINTS = dict(
43
+ login="log in on this machine (run `claude`, then /login); the decision goes on by itself",
44
+ limit="wait for the limit to reset, raise it, or log in to another account; the decision goes on by itself",
45
+ down="the API or the network is down; the decision goes on by itself once it is back",
46
+ missing="install Claude Code or put it on PATH; the decision goes on by itself",
47
+ broker="start a broker for this run; the decision goes on by itself once one answers",
48
+ )
49
+
50
+
51
+ def classify(texts):
52
+ """('login' | 'limit' | 'down', the CLI's words) for the first short text that says the agent is unavailable, else
53
+ None. Only short texts count, so that an agent's own long answer that mentions a rate limit is never taken for
54
+ one."""
55
+ texts = [str(t).strip() for t in texts if t and len(str(t).strip()) < 500]
56
+ for kind, pattern in (("login", LOGIN), ("limit", LIMIT), ("limit", RATE), ("down", DOWN)):
57
+ for t in texts:
58
+ if pattern.search(t):
59
+ return kind, " ".join(t.split())[:300]
60
+ return None
61
+
62
+
63
+ _RESET = re.compile(r"resets\s+(\d{1,2})(?::(\d{2}))?\s*([ap]m)?(?:\s*\(([A-Za-z_]+/[A-Za-z_]+)\))?", re.I)
64
+
65
+
66
+ def seconds_to_reset(text, now=None):
67
+ """Seconds until just after the reset time a limit message names ("resets 1:10am (America/New_York)", "resets
68
+ 3pm"), the next such time from now; None if it names none."""
69
+ m = _RESET.search(str(text or ""))
70
+ if not m:
71
+ return None
72
+ hour, minute, ampm, zone = int(m.group(1)), int(m.group(2) or 0), (m.group(3) or "").lower(), m.group(4)
73
+ if ampm == "pm" and hour != 12:
74
+ hour += 12
75
+ elif ampm == "am" and hour == 12:
76
+ hour = 0
77
+ if not (0 <= hour < 24 and 0 <= minute < 60):
78
+ return None
79
+ tz = None
80
+ if zone:
81
+ try:
82
+ from zoneinfo import ZoneInfo
83
+
84
+ tz = ZoneInfo(zone)
85
+ except Exception:
86
+ tz = None
87
+ now_dt = datetime.datetime.fromtimestamp(time.time() if now is None else now, tz=tz)
88
+ at = now_dt.replace(hour=hour, minute=minute, second=0, microsecond=0)
89
+ if at <= now_dt:
90
+ at += datetime.timedelta(days=1)
91
+ return (at - now_dt).total_seconds() + RESET_MARGIN
92
+
93
+
94
+ def _minutes(seconds):
95
+ m = seconds / 60
96
+ return f"{seconds:.0f} s" if seconds < 90 else f"{m:.0f} min" if m < 90 else f"{m / 60:.1f} h"
97
+
98
+
99
+ class Wait:
100
+ """One wait for an unavailable agent. note(kind, reason) at each failed try: prints the first line, then a reminder
101
+ every REMIND seconds (and when the reason changes), and returns the seconds to wait before the next try (None once
102
+ `timeout` is over). end() prints the last line and returns the seconds waited. every: the caller checks again at
103
+ this fixed interval (a mailbox's poll) instead of after 60 s, then at most every 5 min."""
104
+
105
+ def __init__(self, say, what="Claude Code", training_waits=None, timeout=None, every=None):
106
+ self.say, self.what, self.timeout, self.every = say, what, timeout, every
107
+ self.training_waits = training_waits
108
+ self.since = self.reminded = time.time()
109
+ self.tries = 0
110
+ self._retry_wait = min(RECHECK, RECHECK_MAX)
111
+ self.kind = self.reason = None
112
+
113
+ @property
114
+ def seconds(self):
115
+ return time.time() - self.since
116
+
117
+ def over(self):
118
+ return self.timeout is not None and self.seconds >= self.timeout
119
+
120
+ def note(self, kind, reason):
121
+ """Seconds to wait before the next try, or None when unavailable_timeout is over (the decision then fails)."""
122
+ self.tries += 1
123
+ changed = (kind, reason) != (self.kind, self.reason)
124
+ self.kind, self.reason = kind, reason
125
+ if self.tries == 1:
126
+ run = (
127
+ "training waits for the agent"
128
+ if self.training_waits
129
+ else "training continues with the current programs"
130
+ if self.training_waits is False
131
+ else "the request is kept"
132
+ )
133
+ limit = "" if self.timeout is None else f", for at most {_minutes(self.timeout)} (unavailable_timeout)"
134
+ again = (
135
+ f"checks again every {self.every:g} s"
136
+ if self.every is not None
137
+ else f"tries again in {RECHECK:.0f} s, then at most every {RECHECK_MAX / 60:.0f} min"
138
+ )
139
+ self.say(
140
+ f"{self.what} is unavailable ({reason}); {run} and {again}{limit}. To go on: "
141
+ f"{HINTS.get(kind, HINTS['down'])}"
142
+ )
143
+ elif time.time() - self.reminded >= REMIND or (changed and kind != "down"):
144
+ self.reminded = time.time()
145
+ self.say(f"still waiting for {self.what} ({_minutes(self.seconds)} so far): {reason}")
146
+ if self.over():
147
+ self.say(f"{self.what} stayed unavailable for {_minutes(self.seconds)} (unavailable_timeout); giving up")
148
+ return None
149
+ wait = self._retry_wait
150
+ self._retry_wait = min(2 * wait, RECHECK_MAX)
151
+ reset = seconds_to_reset(reason)
152
+ if reset is not None:
153
+ wait = min(wait, reset)
154
+ if self.timeout is not None:
155
+ wait = min(wait, max(0.0, self.timeout - self.seconds))
156
+ return wait
157
+
158
+ def end(self):
159
+ waited = self.seconds
160
+ self.say(f"{self.what} is available again after {_minutes(waited)}; the decision goes on")
161
+ return waited
opal/cli.py ADDED
@@ -0,0 +1,396 @@
1
+ """opal broker | say | report | show | doctor | estimate"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+
11
+ def _backend(args):
12
+ from .backends import make_backend
13
+
14
+ opts = {}
15
+ if args.model:
16
+ opts["model"] = args.model
17
+ if args.effort:
18
+ opts["effort"] = args.effort
19
+ if getattr(args, "timeout", None):
20
+ opts["timeout"] = args.timeout
21
+ if getattr(args, "retries", None) is not None:
22
+ opts["retries"] = args.retries
23
+ if getattr(args, "unavailable_timeout", None) is not None:
24
+ opts["unavailable_timeout"] = args.unavailable_timeout
25
+ if getattr(args, "max_tokens", None) is not None:
26
+ opts["max_tokens"] = args.max_tokens
27
+ return make_backend(args.backend, **opts)
28
+
29
+
30
+ def _run_folder(path):
31
+ run = Path(path)
32
+ if (run / "events.jsonl").exists():
33
+ return run
34
+ inside = sorted(d.name for d in run.iterdir() if (d / "events.jsonl").exists()) if run.is_dir() else []
35
+ hint = f"; runs inside: {', '.join(inside[-5:])}" if inside else ""
36
+ sys.exit(f"opal: not a run folder: {run} (no events.jsonl){hint}")
37
+
38
+
39
+ def _show(run):
40
+ from .report import _jsonl, _response
41
+
42
+ events = _jsonl(run / "events.jsonl") # a line being written is skipped
43
+ start = next((e for e in events if e["kind"] == "start"), {})
44
+ print(f"run {run}")
45
+ if start.get("goal"):
46
+ print(f"goal: {start['goal']}")
47
+ from .report import decision_counts, describe_counts, rank_repair_summary
48
+
49
+ mode = start.get("blocking")
50
+ mode = "auto-blocking" if mode == "auto" else "blocking" if mode else "non-blocking"
51
+ print(f"{start.get('total_updates', '?')} updates; backend {start.get('backend')}; {mode}")
52
+ repaired = rank_repair_summary(events)
53
+ if repaired:
54
+ print(f"WARNING: {repaired}; the run's trajectory was changed.")
55
+ print(describe_counts(decision_counts(events)))
56
+ print(f"{'k':>4} {'asked at':>9} {'outcome':<11} {'at':>9} {'secs':>6} {'cost':>6} rationale")
57
+ req = {e["k"]: e for e in events if e["kind"] == "requested"}
58
+ out = {
59
+ e["k"]: e
60
+ for e in events
61
+ if e["kind"] in ("applied", "kept", "rejected", "load_failed", "cancelled") and e.get("k")
62
+ }
63
+ for k in sorted(req):
64
+ o = out.get(k, {})
65
+ resp = _response(run, k)
66
+ said = " ".join((resp.get("text") or "").split())[:70]
67
+ kind = o.get("kind", "pending")
68
+ if kind == "pending" and any(
69
+ e["kind"] in ("resumed", "end") and e.get("utc", "") > req[k].get("utc", "") for e in events
70
+ ):
71
+ kind = "lost"
72
+ if kind == "applied":
73
+ kind = "changed"
74
+ elif o.get("failed") and not o.get("gave_up"):
75
+ kind = "failed"
76
+ cost = resp.get("cost_usd")
77
+ print(
78
+ f"{k:>4} {req[k]['update']:>9,} {kind:<11} {o.get('applied_at', ''):>9} "
79
+ f"{resp.get('seconds', ''):>6} {('$%.2f' % cost) if cost else '':>6} {said}"
80
+ )
81
+ for e in events:
82
+ if e["kind"] in ("reverted", "rank_divergence", "ranks_resynced", "resumed", "interrupted", "crashed"):
83
+ print(f"{e['kind']} at update {e.get('update')}")
84
+ end = next((e for e in reversed(events) if e["kind"] == "end"), None)
85
+ if end:
86
+ tokens = "usage" in end or start.get("backend") == "codex"
87
+ cost = "USD unavailable" if tokens and end.get("cost_usd") is None else f"${end.get('cost_usd') or 0:.2f}"
88
+ print(
89
+ f"finished at update {end['update']:,}; {end.get('decisions')} decisions; {cost}"
90
+ )
91
+
92
+
93
+ def doctor_mailbox(spec):
94
+ """A mailbox setup: no Claude Code here; the folder must be usable, and a broker should be serving it."""
95
+ import json
96
+ import shutil
97
+ import subprocess
98
+ import time
99
+ import uuid
100
+
101
+ from .backends import mailbox
102
+
103
+ box = Path(spec.split(":", 1)[1]).expanduser()
104
+ print(f"mailbox {box}: the agent runs on the broker's machine; no Claude Code is needed here")
105
+ python = shutil.which("python3")
106
+ if python is None:
107
+ print("the mailbox host needs python3 (Python 3.6+) on its non-interactive SSH PATH; install it or fix PATH")
108
+ return 1
109
+ try:
110
+ probe = subprocess.run([python, "-c", "import sys; sys.exit(sys.version_info < (3, 6))"],
111
+ capture_output=True, timeout=10)
112
+ except (OSError, subprocess.TimeoutExpired) as exc:
113
+ print(f"the mailbox host's python3 could not run: {exc}")
114
+ return 1
115
+ if probe.returncode:
116
+ print("the mailbox host needs python3 version 3.6 or newer on its non-interactive SSH PATH")
117
+ return 1
118
+ print("the mailbox host's python3: Python 3.6+ available (also required on the broker's SSH PATH)")
119
+ try:
120
+ box.mkdir(mode=0o700, parents=True, exist_ok=True)
121
+ probe = box / f".doctor-{uuid.uuid4().hex[:8]}"
122
+ probe.write_text("ok")
123
+ probe.unlink()
124
+ except OSError as exc:
125
+ print(f"the mailbox folder cannot be written: {exc}")
126
+ return 1
127
+ print("the mailbox folder: writable")
128
+ beats = [box / "broker-alive"] + [d / "broker-alive" for d in box.iterdir() if d.is_dir()]
129
+ alive = []
130
+ for f in beats:
131
+ try:
132
+ if f.exists() and time.time() - f.stat().st_mtime < mailbox.HEARTBEAT_STALE:
133
+ alive.append(json.loads(f.read_text() or "{}").get("broker") or "a broker")
134
+ except (OSError, ValueError):
135
+ continue
136
+ command = (
137
+ f"opal broker {box} --ssh <you>@<this host> --backend claude --model claude-opus-5-5 --effort high "
138
+ "--keep-serving"
139
+ )
140
+ if alive:
141
+ print(f"a broker is serving this mailbox ({', '.join(sorted(set(alive)))})")
142
+ return 0
143
+ print(
144
+ "no broker is serving this mailbox yet. On a machine with Claude Code logged in, start one, and keep that "
145
+ f"machine awake for the whole run (on a Mac: caffeinate -i {command})"
146
+ )
147
+ return 1
148
+
149
+
150
+ def doctor(a):
151
+ import platform
152
+ import shutil
153
+ import subprocess
154
+ import tempfile
155
+
156
+ import torch
157
+
158
+ ok = True
159
+ if a.backend.startswith("mailbox:"):
160
+ return doctor_mailbox(a.backend)
161
+ print(f"python {sys.version.split()[0]} ({sys.executable})")
162
+ print(
163
+ f"torch {torch.__version__}; CUDA available: {torch.cuda.is_available()}"
164
+ + (f" ({torch.cuda.device_count()} x {torch.cuda.get_device_name(0)})" if torch.cuda.is_available() else "")
165
+ )
166
+ for name in ("codex" if a.backend == "codex" else "claude",):
167
+ exe = shutil.which(name)
168
+ ver = ""
169
+ if exe:
170
+ try:
171
+ ver = subprocess.run([exe, "--version"], capture_output=True, text=True, timeout=30).stdout.strip()
172
+ except (OSError, subprocess.TimeoutExpired):
173
+ ver = "(version unknown)"
174
+ print(f"{name}: {exe + ' ' + ver if exe else 'not found'}")
175
+ if platform.system() == "Linux":
176
+ for tool, what in (("bwrap", "bubblewrap"), ("socat", "socat")):
177
+ print(f"{what} (for the sandbox of the agent's shell): {'found' if shutil.which(tool) else 'not found'}")
178
+ from .backends import make_backend
179
+
180
+ # one call: a failed one (not logged in, a limit) is reported at once, not waited for
181
+ be = make_backend(a.backend, retries=0, timeout=300, unavailable_timeout=0)
182
+ try:
183
+ be.preflight()
184
+ if a.backend == "codex":
185
+ print(f"isolated CODEX_HOME: {be.codex_home} (default home and linked auth rejected)")
186
+ logged_in = be.login_status()
187
+ print(f"isolated Codex login: {'ready' if logged_in else 'not logged in'}")
188
+ if not logged_in:
189
+ print("run CODEX_HOME=~/.codex-opal codex login --device-auth")
190
+ return 1
191
+ print(
192
+ f"backend {a.backend}: found"
193
+ + ("" if a.live or a.backend == "codex" else "; login not checked (run `opal doctor --live`, a few cents)")
194
+ )
195
+ except RuntimeError as exc:
196
+ print(str(exc))
197
+ return 1
198
+ problem = getattr(be, "sandbox_problem", None)
199
+ if problem:
200
+ print(f"the sandbox for the agent's shell cannot start ({problem}): the agent gets file tools only, no shell")
201
+ elif getattr(be, "sandbox", None) is not None:
202
+ print(f"the agent's shell: {'in the OS sandbox' if be.sandbox else 'none (file tools only)'}")
203
+ if a.live:
204
+ with tempfile.TemporaryDirectory() as tmp:
205
+ ws = Path(tmp) / "workspace"
206
+ ws.mkdir()
207
+ be.setup(Path(tmp), ws)
208
+ out = be.decide(ws, "Reply with the single word READY and do nothing else.", "You are a test.", Path(tmp))
209
+ reply = (out.get("text") or "").strip()
210
+ ok = out.get("returncode") == 0 and "READY" in reply
211
+ print(
212
+ f"live call: {'ok' if ok else 'failed'} ({out.get('seconds', 0):.0f} s; reply {reply[:80]!r}; error {out.get('error')})"
213
+ )
214
+ if a.backend == "codex":
215
+ print(f"Codex thread: {out.get('session')}; provider rollout: {out.get('rollout')}")
216
+ print(f"tokens (USD unavailable): {json.dumps(out.get('usage'))}")
217
+ return 0 if ok else 1
218
+
219
+
220
+ def estimate_cmd(a):
221
+ from . import estimate
222
+ from .schedule import DEFAULT_DECISIONS, decision_updates
223
+
224
+ explicit = a.decisions is not None
225
+ n = len(decision_updates(a.total_steps, a.decisions if explicit else DEFAULT_DECISIONS, a.every))
226
+ capped_from = None
227
+ if not explicit and a.blocking:
228
+ k = estimate.capped_decisions(n, a.total_steps * a.sec_per_step, a.blocking, a.sec_per_step)
229
+ if k < n:
230
+ capped_from, n = n, len(decision_updates(a.total_steps, k, a.every))
231
+ print(f"OPAL estimate, from our runs of {estimate.MODEL} at effort {estimate.EFFORT} (yours may differ):")
232
+ for line in estimate.lines(
233
+ a.total_steps, a.sec_per_step, n, a.blocking, model=a.model, effort=a.effort, mailbox=a.mailbox,
234
+ capped_from=capped_from, explicit=explicit,
235
+ ): # fmt: skip
236
+ print(" " + line)
237
+ print(" These are estimates from our runs (opal/estimate.py says which); the run prints its own at its first check.")
238
+ return 0
239
+
240
+
241
+ def main(argv=None):
242
+ ap = argparse.ArgumentParser(prog="opal")
243
+ sub = ap.add_subparsers(dest="cmd", required=True)
244
+
245
+ b = sub.add_parser("broker", help="answer a training run's mailbox with a local agent (laptop or login node)")
246
+ b.add_argument("box", help="mailbox folder of the run (on the GPU machine when --ssh is given)")
247
+ b.add_argument("--ssh", help="user@host of the GPU machine; omit when the folder is on a shared filesystem")
248
+ b.add_argument(
249
+ "--backend",
250
+ default="claude",
251
+ choices=["claude", "codex", "keep"],
252
+ help="keep: answer every decision with 'keep the programs' (to test the mailbox path at no cost)",
253
+ )
254
+ b.add_argument("--model")
255
+ b.add_argument("--effort")
256
+ b.add_argument("--max-tokens", type=int, help="Codex only: soft run cap on input + output tokens; an in-flight turn may exceed it")
257
+ b.add_argument(
258
+ "--workdir", help="local folder for the agent's workspace and records (default ~/.opal/broker/<name>)"
259
+ )
260
+ b.add_argument("--poll", type=float, default=2.0, help="seconds between checks of the mailbox")
261
+ b.add_argument("--run", help="run id (subfolder of the box) to serve; default: the newest unfinished run")
262
+ b.add_argument(
263
+ "--timeout",
264
+ type=int,
265
+ help="seconds per decision; by default the limit the run's agent was told (the mailbox timeout of the run); "
266
+ "a shorter value here is kept, and the agent is told it in each message",
267
+ )
268
+ b.add_argument(
269
+ "--retries",
270
+ type=int,
271
+ help="how often a failed agent turn (for example a tool crash) is made again, after 15 s, 30 s, "
272
+ "... (at most 10 min apart) while the decision's time allows; default: as often as that time allows; 0: never",
273
+ )
274
+ b.add_argument(
275
+ "--unavailable-timeout",
276
+ type=float,
277
+ help="seconds an unavailable agent (not logged in, a usage or spend limit, the API overloaded, no network) is "
278
+ "waited for before the request is answered as failed; default: no deadline",
279
+ )
280
+ b.add_argument("--keep-serving", action="store_true", help="after a run ends, wait for the next run in the box")
281
+ b.add_argument(
282
+ "--give-up-after-min",
283
+ type=float,
284
+ help="exit with an error if the mailbox stays unreadable this long (default: retry forever)",
285
+ )
286
+
287
+ s = sub.add_parser("say", help="leave a note for the agent's next decision")
288
+ s.add_argument("run")
289
+ s.add_argument("text")
290
+
291
+ r = sub.add_parser("report", help="write <run>/report.html: loss, decisions, rationales and code diffs")
292
+ r.add_argument("run")
293
+ r.add_argument(
294
+ "--summarize",
295
+ action="store_true",
296
+ help="first describe and tag the code changes that have no summary yet (Claude Code CLI, one small call each)",
297
+ )
298
+ r.add_argument("--model", help="model for --summarize (default claude-opus-5-5 at low effort)")
299
+
300
+ w = sub.add_parser("show", help="print a run's decisions and where its final program is")
301
+ w.add_argument("run")
302
+
303
+ d = sub.add_parser("doctor", help="check that torch and an agent CLI are ready on this machine")
304
+ d.add_argument("--live", action="store_true", help="also make one tiny agent call (costs a few cents)")
305
+ d.add_argument(
306
+ "--backend",
307
+ default="claude",
308
+ help='claude (default), codex, or mailbox:<folder> on a GPU machine whose agent runs elsewhere (checks the folder and '
309
+ "that a broker serves it)",
310
+ )
311
+
312
+ e = sub.add_parser(
313
+ "estimate", help="before a run: how long training will wait for the agent, and what the agent will cost"
314
+ )
315
+ e.add_argument("--total-steps", type=int, required=True, help="updates in the run (wrap's total_steps)")
316
+ e.add_argument(
317
+ "--sec-per-step", type=float, required=True, help="seconds per update without the agent (time the smoke test)"
318
+ )
319
+ e.add_argument(
320
+ "--decisions",
321
+ type=int,
322
+ help="as in wrap(decisions=...); not given: OPAL's default, 50, lowered so that the expected waiting stays "
323
+ "within 4x the run's own time",
324
+ )
325
+ e.add_argument("--every", type=int, default=50, help="as in wrap(every=...): decisions fall on checks")
326
+ e.add_argument(
327
+ "--blocking",
328
+ action=argparse.BooleanOptionalAction,
329
+ default=True,
330
+ help="training waits for each decision (the default); --no-blocking: it goes on while the agent thinks",
331
+ )
332
+ e.add_argument("--model", default="claude-opus-5-5")
333
+ e.add_argument("--effort", default="high")
334
+ e.add_argument("--mailbox", action="store_true", help="the agent answers through a mailbox broker")
335
+
336
+ a = ap.parse_args(argv)
337
+ if a.cmd == "doctor" and a.backend not in ("claude", "codex") and not a.backend.startswith("mailbox:"):
338
+ ap.error("doctor --backend must be claude, codex or mailbox:<folder>")
339
+ if a.cmd == "estimate":
340
+ return estimate_cmd(a)
341
+ if a.cmd == "broker":
342
+ from .backends.mailbox import run_broker
343
+
344
+ name = (a.ssh or "local").replace("@", "_") + "_" + a.box.strip("/").replace("/", "_")
345
+ workdir = Path(a.workdir) if a.workdir else Path("~/.opal/broker").expanduser() / name
346
+ run_broker(
347
+ a.box,
348
+ _backend(a),
349
+ workdir,
350
+ ssh=a.ssh,
351
+ poll=a.poll,
352
+ run=a.run,
353
+ keep_serving=a.keep_serving,
354
+ give_up_after=60 * a.give_up_after_min if a.give_up_after_min else None,
355
+ )
356
+ elif a.cmd == "say":
357
+ run = _run_folder(a.run)
358
+ with open(run / "notes.txt", "a") as f:
359
+ f.write(a.text.strip() + "\n")
360
+ from .report import _jsonl
361
+
362
+ kinds = [e.get("kind") for e in _jsonl(run / "events.jsonl")]
363
+ last = lambda kind: max((i for i, k in enumerate(kinds) if k == kind), default=-1) # noqa: E731
364
+ ended = last("end") > last("resumed")
365
+ print(
366
+ "noted, but this run has finished: no agent will read it"
367
+ if ended
368
+ else f"noted; the agent sees it at its next decision ({run / 'notes.txt'})"
369
+ )
370
+ elif a.cmd == "report":
371
+ from .report import summarize_missing, write_report
372
+
373
+ run = _run_folder(a.run)
374
+ if a.summarize:
375
+ from .summaries import Summarizer
376
+
377
+ failed = summarize_missing(run, Summarizer(model=a.model), log=print)
378
+ if failed:
379
+ print(
380
+ f"{failed} summaries failed (see decisions/NNN/summary-error.json); their rows show the rationale"
381
+ )
382
+ print(write_report(run))
383
+ elif a.cmd == "doctor":
384
+ return doctor(a)
385
+ elif a.cmd == "show":
386
+ from .wrapper import final_program
387
+
388
+ run = _run_folder(a.run)
389
+ _show(run)
390
+ print(f"final program: {final_program(run)}")
391
+ print("rerun it with no agent: opal.replay(<that folder>, optimizer, model, total_steps=...)")
392
+ return 0
393
+
394
+
395
+ if __name__ == "__main__":
396
+ sys.exit(main())
opal/context.py ADDED
@@ -0,0 +1,111 @@
1
+ """What the agent is told about the run, built from the live objects so the user only writes a goal."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ import re
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ import torch
11
+
12
+ _PREFIXES = ("module.", "_orig_mod.", "_fsdp_wrapped_module.")
13
+
14
+
15
+ def clean_name(name: str) -> str:
16
+ """Strip wrapper prefixes (DDP's module., torch.compile's _orig_mod.) so names match the bare model."""
17
+ changed = True
18
+ while changed:
19
+ changed = False
20
+ for p in _PREFIXES:
21
+ if name.startswith(p):
22
+ name, changed = name[len(p) :], True
23
+ return name.replace("._orig_mod.", ".")
24
+
25
+
26
+ def named_params(model, optimizers):
27
+ """(name, parameter) for every parameter the user's optimizers update, in the model's order."""
28
+ in_opt = {id(p) for o in optimizers for g in o.param_groups for p in g["params"]}
29
+ out, seen = [], set()
30
+ for name, p in model.named_parameters():
31
+ if id(p) in in_opt and id(p) not in seen:
32
+ out.append((clean_name(name), p))
33
+ seen.add(id(p))
34
+ extra = [p for o in optimizers for g in o.param_groups for p in g["params"] if id(p) not in seen]
35
+ for i, p in enumerate(extra):
36
+ if id(p) not in seen:
37
+ out.append((f"extra.{i}", p))
38
+ seen.add(id(p))
39
+ return out
40
+
41
+
42
+ def param_table(named, names_by_group=None) -> str:
43
+ lines = []
44
+ for n, p in named:
45
+ g = f" group {names_by_group[n]}" if names_by_group and n in names_by_group else ""
46
+ lines.append(f" {n} {tuple(p.shape)} {str(p.dtype).replace('torch.', '')}{g}")
47
+ return "\n".join(lines)
48
+
49
+
50
+ def _wrappers(model):
51
+ """The model and every wrapper inside it, outermost first: DDP and Accelerate (.module), torch.compile
52
+ (._orig_mod), in any number and order (Accelerate on top of a script's own DDP gives two)."""
53
+ chain, m = [model], model
54
+ for _ in range(16):
55
+ inner = getattr(m, "_orig_mod", None)
56
+ if not isinstance(inner, torch.nn.Module):
57
+ inner = getattr(m, "module", None)
58
+ if not isinstance(inner, torch.nn.Module) or inner is m:
59
+ break
60
+ chain.append(inner)
61
+ m = inner
62
+ return chain
63
+
64
+
65
+ def bare_model(model):
66
+ return _wrappers(model)[-1]
67
+
68
+
69
+ def model_summary(model, named, world_size, device) -> str:
70
+ cls = type(bare_model(model))
71
+ n = sum(p.numel() for _, p in named)
72
+ compiled = any(hasattr(m, "_orig_mod") for m in _wrappers(model))
73
+ gpu = torch.cuda.get_device_name(device) if device.type == "cuda" else str(device)
74
+ return (
75
+ f"Model class {cls.__module__}.{cls.__qualname__}; {n:,} parameters updated by the optimizer in {len(named)} "
76
+ f"tensors; data-parallel world size {world_size}; device {gpu}; torch.compile on the model: "
77
+ f"{'yes' if compiled else 'no'}; torch {torch.__version__}."
78
+ )
79
+
80
+
81
+ def group_index(optimizers, named):
82
+ by_id = {id(p): n for n, p in named}
83
+ out = {}
84
+ for i, o in enumerate(optimizers):
85
+ for j, g in enumerate(o.param_groups):
86
+ for p in g["params"]:
87
+ if id(p) in by_id:
88
+ out[by_id[id(p)]] = f"{i}.{j}" if len(optimizers) > 1 else str(j)
89
+ return out
90
+
91
+
92
+ def reference_files(model, optimizers, schedulers, max_bytes=300_000) -> dict:
93
+ """Read-only copies for the agent: the training script and the source of user-defined classes."""
94
+ out = {}
95
+ main = sys.modules.get("__main__")
96
+ script = getattr(main, "__file__", None)
97
+ if script and Path(script).is_file() and Path(script).stat().st_size <= max_bytes:
98
+ out["training_script_" + Path(script).name] = Path(script).read_text(errors="replace")
99
+ objs = [getattr(getattr(model, "module", model), "_orig_mod", getattr(model, "module", model))]
100
+ objs += list(optimizers) + list(schedulers)
101
+ for obj in objs:
102
+ mod = type(obj).__module__ or ""
103
+ if mod == "torch" or mod.startswith("torch.") or mod == "__main__":
104
+ continue
105
+ try:
106
+ f = inspect.getsourcefile(type(obj))
107
+ except TypeError:
108
+ f = None
109
+ if f and Path(f).is_file() and Path(f).stat().st_size <= max_bytes:
110
+ out["source_" + re.sub(r"[^A-Za-z0-9_.]", "_", mod) + ".py"] = Path(f).read_text(errors="replace")
111
+ return out
opal/defaults.json ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "agent.py": "8851488afd1424a8d7810db9e3c03f8a44bb0cda6c385e1ef79877eab2cad33b",
3
+ "context.py": "8c90b3631f16edb066e7cb4cba67440011f9dd74f10d81ebf21ca19ab9932508",
4
+ "misc/aggressive.py": "f75269eb77ba534f3bb0d0d856c10ba95297ab3cf9c65a882f51d5aaaa3d6332",
5
+ "misc/neutral_analysis.py": "1caa3720dd424c34d6d3076551f6c6f24882e5e6724266cf4fac84b870db2cd2",
6
+ "misc/setup.py": "e52e4f391b40be19c56c76abe6db1e05dbbe494dbc0d3e249a47e1220ee570c6",
7
+ "misc/taskfacts.py": "9ee5148cc7e441bee6a08f01e49e07c0fb8dc67e42843e1813f846c50fcd15dc",
8
+ "programs.py": "9963a97acb49e48aea76e82a63530bea6a64bc2cab234475a4b970984677e130",
9
+ "prompts/example_updater.py": "e7548f935ce808c625bfdceee324173571c3ce2272eab66e490a87394da5911f",
10
+ "prompts/neutral.md": "87e7b83756acffc87c25e5b24fae32b203c3c1cdf9d2452b6716c1cf943ee57d"
11
+ }