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
opal/backends/base.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import signal
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from ._privacy import redact_secrets, redact_stream
|
|
14
|
+
|
|
15
|
+
# The agent CLI's environment is built from an allowlist: what a CLI needs to run and log in, and what the training
|
|
16
|
+
# interpreter needs to start in the agent's shell. Everything else of the user's (database URLs, tokens in oddly named
|
|
17
|
+
# variables, ssh agents, other tools' homes) stays out, since the agent's shell could print it into the provider's
|
|
18
|
+
# context.
|
|
19
|
+
_ALLOWED = frozenset(
|
|
20
|
+
(
|
|
21
|
+
"PATH",
|
|
22
|
+
"HOME",
|
|
23
|
+
"USER",
|
|
24
|
+
"LOGNAME",
|
|
25
|
+
"SHELL",
|
|
26
|
+
"LANG",
|
|
27
|
+
"LANGUAGE",
|
|
28
|
+
"TERM",
|
|
29
|
+
"TZ",
|
|
30
|
+
"TMPDIR",
|
|
31
|
+
"NO_COLOR",
|
|
32
|
+
"COLORTERM",
|
|
33
|
+
"__CF_USER_TEXT_ENCODING",
|
|
34
|
+
# network access of the CLI itself (the agent's shell has none)
|
|
35
|
+
"HTTP_PROXY",
|
|
36
|
+
"HTTPS_PROXY",
|
|
37
|
+
"ALL_PROXY",
|
|
38
|
+
"NO_PROXY",
|
|
39
|
+
"http_proxy",
|
|
40
|
+
"https_proxy",
|
|
41
|
+
"all_proxy",
|
|
42
|
+
"no_proxy",
|
|
43
|
+
"SSL_CERT_FILE",
|
|
44
|
+
"SSL_CERT_DIR",
|
|
45
|
+
"NODE_EXTRA_CA_CERTS",
|
|
46
|
+
"REQUESTS_CA_BUNDLE",
|
|
47
|
+
"CURL_CA_BUNDLE",
|
|
48
|
+
# the training interpreter in the agent's shell (module systems, conda, thread caps)
|
|
49
|
+
"LD_LIBRARY_PATH",
|
|
50
|
+
"DYLD_LIBRARY_PATH",
|
|
51
|
+
"PYTHONPATH",
|
|
52
|
+
"PYTHONHOME",
|
|
53
|
+
"PYTHONNOUSERSITE",
|
|
54
|
+
"VIRTUAL_ENV",
|
|
55
|
+
"CONDA_PREFIX",
|
|
56
|
+
"CONDA_DEFAULT_ENV",
|
|
57
|
+
"OMP_NUM_THREADS",
|
|
58
|
+
"MKL_NUM_THREADS",
|
|
59
|
+
"OPENBLAS_NUM_THREADS",
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
_ALLOWED_PREFIXES = ("LC_", "XDG_")
|
|
63
|
+
# a kept variable whose value holds a password in a URL (user:pass@host) is still dropped, proxies excepted
|
|
64
|
+
_URL_PASSWORD = re.compile(r"://[^/\s:@]+:[^/\s@]+@")
|
|
65
|
+
_PROXIES = frozenset(n for n in _ALLOWED if n.lower().endswith("_proxy"))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def error_tail(text, n=300) -> str:
|
|
69
|
+
"""The end of an agent CLI's error text on one line, for logs and records: at most n characters, with anything
|
|
70
|
+
shaped like an API key or a bearer token masked."""
|
|
71
|
+
return redact_secrets(" ".join(str(text or "").split()))[-n:]
|
|
72
|
+
|
|
73
|
+
# Started next to each agent process: stops the agent (its whole process group) if the training process dies
|
|
74
|
+
# without close() (SIGTERM from a scheduler, SIGKILL, a crash), so no agent keeps spending after its run is gone.
|
|
75
|
+
_WATCHDOG = """
|
|
76
|
+
import os, signal, sys, time
|
|
77
|
+
parent, child = int(sys.argv[1]), int(sys.argv[2])
|
|
78
|
+
def alive(pid):
|
|
79
|
+
try:
|
|
80
|
+
os.kill(pid, 0)
|
|
81
|
+
return True
|
|
82
|
+
except OSError:
|
|
83
|
+
return False
|
|
84
|
+
while alive(child):
|
|
85
|
+
if not alive(parent):
|
|
86
|
+
try:
|
|
87
|
+
os.killpg(child, signal.SIGTERM)
|
|
88
|
+
time.sleep(5)
|
|
89
|
+
os.killpg(child, signal.SIGKILL)
|
|
90
|
+
except OSError:
|
|
91
|
+
pass
|
|
92
|
+
break
|
|
93
|
+
time.sleep(2)
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class Cancelled(RuntimeError):
|
|
98
|
+
"""The run ended (or cancelled this decision) before the agent started."""
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def agent_env(keep_prefixes=(), extra=None):
|
|
102
|
+
"""The environment for an agent CLI: an allowlist of the user's variables (PATH, HOME, locale, proxies and CA
|
|
103
|
+
files, what the Python interpreter needs) plus the backend's own login variables, kept by prefix. Variables the
|
|
104
|
+
agent's shell could otherwise print into the provider's context (keys, tokens, database URLs, ssh agents) are
|
|
105
|
+
left out."""
|
|
106
|
+
env = {}
|
|
107
|
+
for k, v in os.environ.items():
|
|
108
|
+
if k.startswith(tuple(keep_prefixes)):
|
|
109
|
+
env[k] = v
|
|
110
|
+
elif (k in _ALLOWED or k.startswith(_ALLOWED_PREFIXES)) and (k in _PROXIES or not _URL_PASSWORD.search(v)):
|
|
111
|
+
env[k] = v
|
|
112
|
+
env.update(extra or {})
|
|
113
|
+
return env
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def kill_group(pgid, grace=3.0):
|
|
117
|
+
"""SIGTERM, then SIGKILL, to a process group; quiet if it is gone already."""
|
|
118
|
+
try:
|
|
119
|
+
os.killpg(pgid, signal.SIGTERM)
|
|
120
|
+
except (ProcessLookupError, PermissionError):
|
|
121
|
+
return
|
|
122
|
+
t0 = time.time()
|
|
123
|
+
while time.time() - t0 < grace:
|
|
124
|
+
try:
|
|
125
|
+
os.killpg(pgid, 0)
|
|
126
|
+
except (ProcessLookupError, PermissionError):
|
|
127
|
+
return
|
|
128
|
+
time.sleep(0.05)
|
|
129
|
+
try:
|
|
130
|
+
os.killpg(pgid, signal.SIGKILL)
|
|
131
|
+
except (ProcessLookupError, PermissionError):
|
|
132
|
+
pass
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class Backend:
|
|
136
|
+
name = "backend"
|
|
137
|
+
log = None # fn(text): where a backend reports what it does about a failed call (the run's or the broker's log)
|
|
138
|
+
|
|
139
|
+
def setup(self, run_dir: Path, workspace: Path) -> None:
|
|
140
|
+
"""Called before the first decision (and again after a resume)."""
|
|
141
|
+
|
|
142
|
+
def preflight(self) -> None:
|
|
143
|
+
"""Called when the run starts: fail now, not at the first decision, if the agent cannot run."""
|
|
144
|
+
|
|
145
|
+
def decide(self, workspace: Path, message: str, system: str | None, record_dir: Path) -> dict:
|
|
146
|
+
raise NotImplementedError
|
|
147
|
+
|
|
148
|
+
def state(self) -> dict:
|
|
149
|
+
return {}
|
|
150
|
+
|
|
151
|
+
def restore(self, state: dict) -> None:
|
|
152
|
+
pass
|
|
153
|
+
|
|
154
|
+
def finish(self, interrupted=False) -> None:
|
|
155
|
+
"""The run ended, or stopped early without close() (a mailbox tells its broker)."""
|
|
156
|
+
|
|
157
|
+
def _run(self, cmd, input, stdout, cwd, timeout, env=None):
|
|
158
|
+
"""Run the agent CLI in its own process group and wait for the CLI itself, at most `timeout` seconds (then
|
|
159
|
+
subprocess.TimeoutExpired). Whatever is left of its process group afterwards (a background job of the agent's
|
|
160
|
+
shell, a process holding its output open) is stopped, after a normal exit too; cancel() and a watchdog (if
|
|
161
|
+
this process dies) stop the whole group as well. stderr goes to a temporary file, so no descendant holding it
|
|
162
|
+
open can make this wait."""
|
|
163
|
+
if getattr(self, "cancelled", False):
|
|
164
|
+
raise Cancelled("the run ended before the agent started")
|
|
165
|
+
with tempfile.TemporaryFile(mode="w+") as err, tempfile.TemporaryFile(mode="w+") as captured:
|
|
166
|
+
record = hasattr(stdout, "write")
|
|
167
|
+
proc = self.proc = subprocess.Popen(
|
|
168
|
+
cmd,
|
|
169
|
+
stdin=subprocess.PIPE,
|
|
170
|
+
stdout=captured if record else stdout,
|
|
171
|
+
stderr=err,
|
|
172
|
+
text=True,
|
|
173
|
+
cwd=cwd,
|
|
174
|
+
env=env,
|
|
175
|
+
start_new_session=True,
|
|
176
|
+
)
|
|
177
|
+
try:
|
|
178
|
+
subprocess.Popen(
|
|
179
|
+
[sys.executable, "-c", _WATCHDOG, str(os.getpid()), str(proc.pid)],
|
|
180
|
+
stdin=subprocess.DEVNULL,
|
|
181
|
+
stdout=subprocess.DEVNULL,
|
|
182
|
+
stderr=subprocess.DEVNULL,
|
|
183
|
+
start_new_session=True,
|
|
184
|
+
)
|
|
185
|
+
if getattr(self, "cancelled", False): # cancel() came while the CLI was starting
|
|
186
|
+
kill_group(proc.pid)
|
|
187
|
+
|
|
188
|
+
def feed():
|
|
189
|
+
try:
|
|
190
|
+
proc.stdin.write(input or "")
|
|
191
|
+
proc.stdin.close()
|
|
192
|
+
except (BrokenPipeError, OSError, ValueError):
|
|
193
|
+
pass
|
|
194
|
+
|
|
195
|
+
threading.Thread(target=feed, name="opal-agent-stdin", daemon=True).start()
|
|
196
|
+
try:
|
|
197
|
+
proc.wait(timeout=timeout)
|
|
198
|
+
except subprocess.TimeoutExpired:
|
|
199
|
+
kill_group(proc.pid)
|
|
200
|
+
proc.wait()
|
|
201
|
+
raise
|
|
202
|
+
finally:
|
|
203
|
+
kill_group(proc.pid, grace=1.0) # the CLI's leftovers, also after a normal exit
|
|
204
|
+
if self.proc is proc:
|
|
205
|
+
self.proc = None
|
|
206
|
+
if record:
|
|
207
|
+
captured.seek(0)
|
|
208
|
+
redact_stream(captured, stdout)
|
|
209
|
+
stdout.flush()
|
|
210
|
+
err.seek(0)
|
|
211
|
+
captured.seek(0)
|
|
212
|
+
captured.truncate()
|
|
213
|
+
redact_stream(err, captured)
|
|
214
|
+
captured.seek(0)
|
|
215
|
+
text = captured.read()
|
|
216
|
+
return subprocess.CompletedProcess(cmd, proc.returncode, None, text)
|
|
217
|
+
|
|
218
|
+
def _kill(self):
|
|
219
|
+
p = getattr(self, "proc", None)
|
|
220
|
+
if p is not None:
|
|
221
|
+
kill_group(p.pid)
|
|
222
|
+
|
|
223
|
+
def cancel(self) -> None:
|
|
224
|
+
self.cancelled = True
|
|
225
|
+
self._kill()
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class KeepBackend(Backend):
|
|
229
|
+
"""No agent: every decision keeps the programs. For smoke tests and for measuring the wrapper's own cost."""
|
|
230
|
+
|
|
231
|
+
name = "keep"
|
|
232
|
+
|
|
233
|
+
def decide(self, workspace, message, system, record_dir):
|
|
234
|
+
return dict(text="kept (backend='keep')", cost_usd=0.0, returncode=0, error=None, seconds=0.0)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class CallableBackend(Backend):
|
|
238
|
+
"""fn(workspace: Path, message: str, system: str | None) -> str | None. For tests and custom agents."""
|
|
239
|
+
|
|
240
|
+
name = "callable"
|
|
241
|
+
|
|
242
|
+
def __init__(self, fn):
|
|
243
|
+
self.fn = fn
|
|
244
|
+
|
|
245
|
+
def decide(self, workspace, message, system, record_dir):
|
|
246
|
+
t0 = time.time()
|
|
247
|
+
text = self.fn(Path(workspace), message, system)
|
|
248
|
+
return dict(text=text or "", cost_usd=0.0, returncode=0, error=None, seconds=time.time() - t0)
|