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.
- opal/__init__.py +7 -0
- opal/_version.py +3 -0
- opal/agent.py +394 -0
- opal/backends/__init__.py +31 -0
- opal/backends/_codex_client.py +450 -0
- opal/backends/_privacy.py +116 -0
- opal/backends/base.py +248 -0
- opal/backends/claude.py +665 -0
- opal/backends/codex.py +356 -0
- opal/backends/mailbox.py +1034 -0
- opal/backends/unavailable.py +161 -0
- opal/cli.py +396 -0
- opal/context.py +111 -0
- opal/defaults.json +11 -0
- opal/defaults.py +54 -0
- opal/estimate.py +120 -0
- opal/hf.py +109 -0
- opal/misc/__init__.py +2 -0
- opal/misc/aggressive.py +157 -0
- opal/misc/audit.py +84 -0
- opal/misc/boundary.py +226 -0
- opal/misc/checkpoint.py +399 -0
- opal/misc/ddp.py +147 -0
- opal/misc/integrity.py +263 -0
- opal/misc/nested.py +67 -0
- opal/misc/neutral_analysis.py +166 -0
- opal/misc/precision.py +210 -0
- opal/misc/recovery.py +231 -0
- opal/misc/schedulers.py +214 -0
- opal/misc/setup.py +253 -0
- opal/misc/skip.py +165 -0
- opal/misc/taskfacts.py +293 -0
- opal/misc/waiting.py +58 -0
- opal/programs.py +482 -0
- opal/prompts/example_updater.py +252 -0
- opal/prompts/neutral.md +206 -0
- opal/report.py +1001 -0
- opal/report_page.py +175 -0
- opal/report_privacy.py +159 -0
- opal/results.py +80 -0
- opal/schedule.py +134 -0
- opal/summaries.py +224 -0
- opal/wrapper.py +1674 -0
- opal_optimizer-0.3.5.dist-info/METADATA +232 -0
- opal_optimizer-0.3.5.dist-info/RECORD +49 -0
- opal_optimizer-0.3.5.dist-info/WHEEL +5 -0
- opal_optimizer-0.3.5.dist-info/entry_points.txt +2 -0
- opal_optimizer-0.3.5.dist-info/licenses/LICENSE +21 -0
- 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
|
+
}
|