outerloop-science 0.1.0.dev0__py3-none-any.whl → 0.1.0.dev2__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.
outerloop/harness.py CHANGED
@@ -142,7 +142,7 @@ def _rmtree_at(dir_fd: int, name: str) -> None:
142
142
  class VertexConfig:
143
143
  """Claude-on-Vertex auth (ADC): set to bill Anthropic sessions to a GCP
144
144
  project instead of an Anthropic API key. The single owner of the
145
- AUTORESEARCH_VERTEX_* env contract is `vertex_from_env`."""
145
+ OUTERLOOP_VERTEX_* env contract is `vertex_from_env`."""
146
146
 
147
147
  project: str
148
148
  region: str = "global"
@@ -166,12 +166,12 @@ class VertexConfig:
166
166
 
167
167
  def vertex_from_env() -> VertexConfig | None:
168
168
  """The deployment's Vertex coordinates, or None (Anthropic-direct). A live
169
- flip needs only the env: set AUTORESEARCH_VERTEX_PROJECT to route every
169
+ flip needs only the env: set OUTERLOOP_VERTEX_PROJECT to route every
170
170
  claude session through Vertex; unset it to fall back to the API key."""
171
- project = os.environ.get("AUTORESEARCH_VERTEX_PROJECT", "").strip()
171
+ project = os.environ.get("OUTERLOOP_VERTEX_PROJECT", "").strip()
172
172
  if not project:
173
173
  return None
174
- adc = os.path.expanduser(os.environ.get("AUTORESEARCH_VERTEX_ADC", "").strip())
174
+ adc = os.path.expanduser(os.environ.get("OUTERLOOP_VERTEX_ADC", "").strip())
175
175
  if not adc:
176
176
  # resolve the gcloud default NOW, under the real HOME — the session's
177
177
  # HOME is a scrubbed per-run directory where ambient discovery would
@@ -181,7 +181,7 @@ def vertex_from_env() -> VertexConfig | None:
181
181
  adc = default
182
182
  return VertexConfig(
183
183
  project=project,
184
- region=os.environ.get("AUTORESEARCH_VERTEX_REGION", "global").strip() or "global",
184
+ region=os.environ.get("OUTERLOOP_VERTEX_REGION", "global").strip() or "global",
185
185
  adc_file=adc,
186
186
  )
187
187
 
@@ -199,6 +199,17 @@ def redact(text: str, secrets: tuple[str, ...]) -> str:
199
199
  return text
200
200
 
201
201
 
202
+ def default_binary(backend: str) -> str:
203
+ """The host CLI a job spawns for `backend`: the path init recorded
204
+ (`OUTERLOOP_<BACKEND>_BIN`), else `~/.local/bin/<backend>` where the native
205
+ installers put it. One rule for the climb, the follow-up and the steward,
206
+ so a CLI installed anywhere else works in every lane."""
207
+ name = backend.strip().lower() or "claude"
208
+ return os.path.expanduser(
209
+ os.environ.get(f"OUTERLOOP_{name.upper()}_BIN") or f"~/.local/bin/{name}"
210
+ )
211
+
212
+
202
213
  def _error_result(stop_reason: str, transcript_path: str = "", detail: str = "") -> SessionResult:
203
214
  return SessionResult(
204
215
  stop_reason=stop_reason,
@@ -609,7 +620,7 @@ class ClaudeCodeHarness:
609
620
  )
610
621
  except OSError as exc:
611
622
  log.warning("could not spawn %s: %s", self.binary, exc)
612
- return _error_result("spawn-error")
623
+ return _error_result("spawn-error", detail=f"could not spawn {self.binary}: {exc}")
613
624
 
614
625
  try:
615
626
  stdout, stderr = process.communicate(input=brief_text, timeout=self.timeout_s)
@@ -1061,7 +1072,7 @@ class CodexHarness:
1061
1072
  except OSError as exc:
1062
1073
  log.warning("could not spawn %s: %s", self.binary, exc)
1063
1074
  self._purge_auth(session_home)
1064
- return _error_result("spawn-error")
1075
+ return _error_result("spawn-error", detail=f"could not spawn {self.binary}: {exc}")
1065
1076
  try:
1066
1077
  stdout, stderr = process.communicate(input=brief_text, timeout=self.timeout_s)
1067
1078
  except subprocess.TimeoutExpired:
outerloop/image.py ADDED
@@ -0,0 +1,372 @@
1
+ """The agent image on an adopter's machine: where it lives, and fetching the
2
+ published one. The tick reads `~/outerloop-images/agent-py312.sif` unless
3
+ `OUTERLOOP_IMAGE` names another file (tick._default_image); `init` fills that
4
+ default on Linux when Apptainer is installed, so local mode runs contained
5
+ without a hand step."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import os
11
+ import platform
12
+ import shutil
13
+ import subprocess
14
+ import sys
15
+ import tempfile
16
+ import time
17
+ import urllib.error
18
+ import urllib.request
19
+ from collections.abc import Callable
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ IMAGE_NAME = "agent-py312.sif"
24
+ IMAGE_URL = f"https://huggingface.co/outerloop-science/agent-image/resolve/main/{IMAGE_NAME}"
25
+ IMAGE_DIR_NAME = "outerloop-images"
26
+ LEGACY_IMAGE_DIR_NAME = "autoresearch-images" # pre-rename; honored until the release after 0.1
27
+ _CHUNK = 1 << 20
28
+ _PROBE_TIMEOUT_S = 60
29
+ APPTAINER_RELEASES = "https://github.com/apptainer/apptainer/releases"
30
+ APPTAINER_VERSION = "1.5.3"
31
+ # the GitHub release ships Debian packages for x86-64 only; the project's PPA
32
+ # builds amd64 and arm64 for every current Ubuntu
33
+ APPTAINER_DEB = f"apptainer_{APPTAINER_VERSION}_amd64.deb"
34
+ APPTAINER_DEB_URL = f"{APPTAINER_RELEASES}/download/v{APPTAINER_VERSION}/{APPTAINER_DEB}"
35
+ APPTAINER_PPA = "ppa:apptainer/ppa"
36
+ # the unprivileged installer, pinned to the release tag (never a moving branch)
37
+ APPTAINER_UNPRIV_URL = (
38
+ "https://raw.githubusercontent.com/apptainer/apptainer/"
39
+ f"v{APPTAINER_VERSION}/tools/install-unprivileged.sh"
40
+ )
41
+ PROBE_CMD = "apptainer exec docker://alpine:3.20 cat /etc/alpine-release"
42
+
43
+
44
+ def image_dir(home: Path | None = None) -> Path:
45
+ return (home or Path.home()) / IMAGE_DIR_NAME
46
+
47
+
48
+ def find_image(home: Path | None = None) -> str:
49
+ """An existing image on this machine, new location first, or ""."""
50
+ home = home or Path.home()
51
+ for d in (IMAGE_DIR_NAME, LEGACY_IMAGE_DIR_NAME):
52
+ p = home / d / IMAGE_NAME
53
+ if p.is_file():
54
+ return str(p)
55
+ return ""
56
+
57
+
58
+ def _linux_flavor() -> tuple[str, str]:
59
+ """(distribution id, version) from os-release, or ("", "")."""
60
+ try:
61
+ info = platform.freedesktop_os_release()
62
+ except OSError:
63
+ return "", ""
64
+ return info.get("ID", ""), info.get("VERSION_ID", "")
65
+
66
+
67
+ def install_hint() -> str:
68
+ """The exact steps for THIS machine, written for someone installing Apptainer
69
+ for the first time. Every path is a root operation or an admin request, which
70
+ is why init never runs it."""
71
+ if sys.platform == "darwin":
72
+ return (
73
+ "macOS has no Apptainer; runs stay uncontained here (a macOS containment is on the\n"
74
+ " roadmap). A Linux machine, or Slurm, runs contained."
75
+ )
76
+ dist, _version = _linux_flavor()
77
+ apparmor_note = (
78
+ " Ubuntu 23.10+ blocks unprivileged user namespaces unless an AppArmor profile allows\n"
79
+ " them; the official package carries that profile (the unprivileged installer does\n"
80
+ " not, and fails with 'Could not write info to setgroups').\n"
81
+ )
82
+ check = (
83
+ f" 3. {PROBE_CMD}\n"
84
+ " (prints a version number)\n"
85
+ " then run `outerloop init --force` again to download the image."
86
+ )
87
+ if dist in ("ubuntu", "linuxmint", "pop"):
88
+ return (
89
+ "Install Apptainer from the project's Ubuntu PPA (amd64 and arm64):\n"
90
+ + apparmor_note
91
+ + f" 1. sudo add-apt-repository -y {APPTAINER_PPA}\n"
92
+ " 2. sudo apt-get update && sudo apt-get install -y apptainer\n" + check
93
+ )
94
+ if dist == "debian":
95
+ if platform.machine() == "x86_64":
96
+ return (
97
+ "Install the official Apptainer package (Debian, x86-64):\n"
98
+ + apparmor_note
99
+ + f" 1. curl -fsSLO {APPTAINER_DEB_URL}\n"
100
+ f" 2. sudo apt-get install -y ./{APPTAINER_DEB}\n" + check
101
+ )
102
+ return (
103
+ f"No Apptainer Debian package is published for this machine ({platform.machine()}).\n"
104
+ " Build from source (https://apptainer.org/docs/admin/main/installation.html) or use\n"
105
+ " the unprivileged installer. Download it, read it, then run it:\n"
106
+ f" curl -fsSLO {APPTAINER_UNPRIV_URL}\n"
107
+ " less install-unprivileged.sh && bash install-unprivileged.sh ~/apptainer\n"
108
+ " export PATH=$HOME/apptainer/bin:$PATH\n"
109
+ f" Check with `{PROBE_CMD}`, then run `outerloop init --force` again."
110
+ )
111
+ if dist == "fedora":
112
+ return (
113
+ "Install Apptainer from the Fedora repositories:\n"
114
+ " sudo dnf install -y apptainer\n"
115
+ f" {PROBE_CMD} # prints a version number\n"
116
+ " then run `outerloop init --force` again to download the image."
117
+ )
118
+ if dist in ("rhel", "centos", "rocky", "almalinux"):
119
+ return (
120
+ "Install Apptainer from EPEL:\n"
121
+ " sudo dnf install -y epel-release\n"
122
+ " sudo dnf install -y apptainer\n"
123
+ f" {PROBE_CMD} # prints a version number\n"
124
+ " then run `outerloop init --force` again to download the image."
125
+ )
126
+ return (
127
+ "Install Apptainer for your distribution:\n"
128
+ " https://apptainer.org/docs/admin/main/installation.html\n"
129
+ " Without root, the project's unprivileged installer works on most systems.\n"
130
+ " Download it, read it, then run it:\n"
131
+ f" curl -fsSLO {APPTAINER_UNPRIV_URL}\n"
132
+ " less install-unprivileged.sh && bash install-unprivileged.sh ~/apptainer\n"
133
+ " export PATH=$HOME/apptainer/bin:$PATH\n"
134
+ " On a Slurm cluster, ask the administrators; most already provide it.\n"
135
+ f" Check with `{PROBE_CMD}`, then run\n"
136
+ " `outerloop init --force` again to download the image."
137
+ )
138
+
139
+
140
+ def containment_check(*, runner: Callable[..., Any] = subprocess.run) -> str:
141
+ """Why contained runs are NOT possible here, or "" when they are. Finds
142
+ apptainer and actually runs a container: being on PATH is not enough
143
+ (Ubuntu 24.04 lets any program create a user namespace but strips its
144
+ capabilities unless an AppArmor profile allows it, so a hand-installed
145
+ apptainer fails at exec time with `Could not write info to setgroups`).
146
+ The probe needs no network: a throwaway sandbox with the standard mount
147
+ points, the host's /bin, /lib and /usr bound in, running /bin/true —
148
+ about 0.1 s on a working install."""
149
+ if sys.platform == "darwin":
150
+ return "apptainer does not exist on macOS"
151
+ if not sys.platform.startswith("linux"):
152
+ return f"apptainer is not available on {sys.platform}"
153
+ binary = shutil.which("apptainer")
154
+ if binary is None:
155
+ return "apptainer is not installed (not on PATH)"
156
+ binds = ",".join(d for d in ("/bin", "/lib", "/lib64", "/usr") if os.path.exists(d))
157
+ with tempfile.TemporaryDirectory(prefix="outerloop-probe-") as sandbox:
158
+ for d in (
159
+ "dev",
160
+ "proc",
161
+ "sys",
162
+ "tmp",
163
+ "etc",
164
+ "bin",
165
+ "lib",
166
+ "lib64",
167
+ "usr",
168
+ "root",
169
+ "home",
170
+ "var/tmp",
171
+ ):
172
+ os.makedirs(os.path.join(sandbox, d), exist_ok=True)
173
+ for f in ("etc/passwd", "etc/group"):
174
+ open(os.path.join(sandbox, f), "a").close()
175
+ argv = [binary, "exec", "--containall", "--cleanenv", "--no-home", "--pwd", "/"]
176
+ if binds:
177
+ argv += ["-B", binds]
178
+ argv += [sandbox, "/bin/true"]
179
+ try:
180
+ proc = runner(argv, capture_output=True, text=True, timeout=_PROBE_TIMEOUT_S)
181
+ except (OSError, subprocess.TimeoutExpired) as exc:
182
+ return f"apptainer failed to start: {exc}"
183
+ err = (proc.stderr or "").strip()
184
+ if "setgroups" in err or "user namespace" in err.lower() or "unshare" in err.lower():
185
+ last = err.splitlines()[-1] if err else "user namespace refused"
186
+ return f"apptainer cannot create containers here ({last})"
187
+ if proc.returncode != 0:
188
+ last = err.splitlines()[-1] if err else f"exit {proc.returncode}"
189
+ return f"apptainer exec failed ({last})"
190
+ return ""
191
+
192
+
193
+ def containment_available() -> bool:
194
+ return containment_check() == ""
195
+
196
+
197
+ def _fmt_mb(n: int) -> str:
198
+ return f"{n / (1 << 20):,.0f} MB"
199
+
200
+
201
+ class _Progress:
202
+ """Download progress: a redrawn bar with size, speed and ETA when stdout is
203
+ a terminal; one line per 10% when it is not (a log, a CI step). `report`
204
+ receives the non-tty lines and the final summary."""
205
+
206
+ def __init__(self, url: str, report: Callable[[str], None]) -> None:
207
+ self.url = url
208
+ self.report = report
209
+ self.total = 0
210
+ self.t0 = 0.0
211
+ self.tty = sys.stdout.isatty()
212
+ self.next_mark = 10
213
+ self.last_draw = 0.0
214
+
215
+ def start(self, total: int) -> None:
216
+ self.total = total
217
+ self.t0 = time.monotonic()
218
+ size = f" ({_fmt_mb(total)})" if total else ""
219
+ self.report(f"downloading {self.url}{size}")
220
+
221
+ def update(self, done: int) -> None:
222
+ now = time.monotonic()
223
+ if self.tty:
224
+ if now - self.last_draw < 0.2:
225
+ return
226
+ self.last_draw = now
227
+ sys.stdout.write("\r" + self._line(done, now) + "\033[K")
228
+ sys.stdout.flush()
229
+ elif self.total:
230
+ pct = done * 100 // self.total
231
+ while pct >= self.next_mark and self.next_mark <= 100:
232
+ self.report(f" {self.next_mark:3d}% {_fmt_mb(done)} of {_fmt_mb(self.total)}")
233
+ self.next_mark += 10
234
+
235
+ def _line(self, done: int, now: float) -> str:
236
+ elapsed = max(now - self.t0, 1e-6)
237
+ speed = done / elapsed
238
+ if self.total:
239
+ frac = min(done / self.total, 1.0)
240
+ width = 30
241
+ filled = int(frac * width)
242
+ bar = "#" * filled + "-" * (width - filled)
243
+ eta = (self.total - done) / speed if speed > 0 else 0
244
+ return (
245
+ f" [{bar}] {frac * 100:3.0f}% {_fmt_mb(done)} of {_fmt_mb(self.total)}"
246
+ f" {speed / (1 << 20):.1f} MB/s ETA {int(eta) // 60}:{int(eta) % 60:02d}"
247
+ )
248
+ return f" {_fmt_mb(done)} {speed / (1 << 20):.1f} MB/s"
249
+
250
+ def finish(self, done: int) -> None:
251
+ if self.tty:
252
+ sys.stdout.write("\r" + self._line(done, time.monotonic()) + "\033[K\n")
253
+ sys.stdout.flush()
254
+ elapsed = time.monotonic() - self.t0
255
+ self.report(f" downloaded {_fmt_mb(done)} in {int(elapsed) // 60}:{int(elapsed) % 60:02d}")
256
+
257
+ def abort(self) -> None:
258
+ if self.tty:
259
+ sys.stdout.write("\n")
260
+ sys.stdout.flush()
261
+
262
+
263
+ def download_image(
264
+ url: str = IMAGE_URL,
265
+ dest: Path | None = None,
266
+ *,
267
+ opener: Callable[..., Any] = urllib.request.urlopen,
268
+ report: Callable[[str], None] = print,
269
+ checksum_url: str = "",
270
+ ) -> Path:
271
+ """Stream `url` to `dest` (default: the image dir), through a `.part`
272
+ file renamed into place only after its sha256 matches the checksum the
273
+ build published beside it (`<url>.sha256`); any failure, mismatch or
274
+ interruption removes the part file. Progress: a live bar on a terminal,
275
+ one line per 10% otherwise."""
276
+ dest = dest or image_dir() / IMAGE_NAME
277
+ dest.parent.mkdir(parents=True, exist_ok=True)
278
+ part = dest.with_name(dest.name + ".part")
279
+ digest = hashlib.sha256()
280
+ bar = _Progress(url, report)
281
+ try:
282
+ with opener(url, timeout=60) as resp:
283
+ total = int(resp.headers.get("Content-Length") or 0)
284
+ bar.start(total)
285
+ done = 0
286
+ with part.open("wb") as out:
287
+ while True:
288
+ chunk = resp.read(_CHUNK)
289
+ if not chunk:
290
+ break
291
+ out.write(chunk)
292
+ digest.update(chunk)
293
+ done += len(chunk)
294
+ bar.update(done)
295
+ bar.finish(done)
296
+ if total and done != total:
297
+ raise OSError(f"short download: {done} of {total} bytes")
298
+ # the checksum the build published beside the image: an image that does
299
+ # not match it is never installed (it would run with the bound harness
300
+ # binary and the run's credentials)
301
+ expected = _published_checksum(checksum_url or url + ".sha256", opener)
302
+ if digest.hexdigest() != expected:
303
+ raise OSError(
304
+ f"checksum mismatch for {url}: expected {expected}, got {digest.hexdigest()}"
305
+ )
306
+ report(" checksum verified")
307
+ except BaseException:
308
+ # a failed or interrupted download never leaves a partial image where
309
+ # a later run would find it
310
+ bar.abort()
311
+ part.unlink(missing_ok=True)
312
+ raise
313
+ os.replace(part, dest)
314
+ report(f" saved {dest}")
315
+ return dest
316
+
317
+
318
+ def _published_checksum(url: str, opener: Callable[..., Any]) -> str:
319
+ """The sha256 hex the build published at `url` (`<hex> <file>` as sha256sum
320
+ writes it). No readable checksum means no image."""
321
+ try:
322
+ with opener(url, timeout=60) as resp:
323
+ text = resp.read(4096).decode("ascii", "replace")
324
+ except (OSError, urllib.error.URLError) as exc:
325
+ raise OSError(f"no checksum published at {url}: {exc}") from exc
326
+ token = text.split()[0].strip().lower() if text.split() else ""
327
+ if len(token) != 64 or any(c not in "0123456789abcdef" for c in token):
328
+ raise OSError(f"unreadable checksum at {url}")
329
+ return token
330
+
331
+
332
+ def ensure_image(
333
+ *,
334
+ interactive: bool,
335
+ want: bool = True,
336
+ probe: bool = True,
337
+ home: Path | None = None,
338
+ ask: Callable[[str], str] = input,
339
+ report: Callable[[str], None] = print,
340
+ fetch: Callable[..., Path] = download_image,
341
+ ) -> str:
342
+ """The image path `init` records: an existing image, else the published
343
+ one downloaded to the image dir, when this machine can run it and the
344
+ adopter did not opt out. "" means uncontained (the loop says so at start);
345
+ when that is because Apptainer is missing or cannot run containers, the
346
+ exact install steps for this machine are printed, and an image already on
347
+ disk is NOT recorded (a contained run would only fail later). `probe=False`
348
+ skips the container check: on Slurm the image runs on compute nodes, which
349
+ the login node cannot speak for. A failed download is a warning, never a
350
+ failed setup."""
351
+ if not want:
352
+ return ""
353
+ problem = containment_check() if probe else ""
354
+ found = find_image(home)
355
+ if problem:
356
+ report(f"Runs will be UNCONTAINED on this machine: {problem}.")
357
+ report(" " + install_hint())
358
+ if found:
359
+ report(f" (the image at {found} will be used once apptainer works)")
360
+ return ""
361
+ if found:
362
+ return found
363
+ dest = image_dir(home) / IMAGE_NAME
364
+ if interactive:
365
+ answer = ask(f"Download the agent image to {dest} so runs are contained? [Y/n] ")
366
+ if answer.strip().lower().startswith("n"):
367
+ return ""
368
+ try:
369
+ return str(fetch(IMAGE_URL, dest, report=report))
370
+ except (OSError, urllib.error.URLError) as exc:
371
+ report(f" image download failed ({exc}); continuing without one, pass --image later")
372
+ return ""