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/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)