outerloop-science 0.1.0.dev0__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 (52) hide show
  1. outerloop/__init__.py +18 -0
  2. outerloop/__main__.py +3 -0
  3. outerloop/appauth.py +213 -0
  4. outerloop/appmanifest.py +198 -0
  5. outerloop/attempt.py +3481 -0
  6. outerloop/brief.py +515 -0
  7. outerloop/cli.py +439 -0
  8. outerloop/climbboard.py +1145 -0
  9. outerloop/compute.py +482 -0
  10. outerloop/contract.py +483 -0
  11. outerloop/contract_cli.py +63 -0
  12. outerloop/disk.py +164 -0
  13. outerloop/dispatch.py +586 -0
  14. outerloop/followup.py +2143 -0
  15. outerloop/github.py +1486 -0
  16. outerloop/harness.py +1449 -0
  17. outerloop/housekeeping.py +167 -0
  18. outerloop/init.py +313 -0
  19. outerloop/intake.py +129 -0
  20. outerloop/limits.py +80 -0
  21. outerloop/markers.py +48 -0
  22. outerloop/measure.py +523 -0
  23. outerloop/orchestrator.py +1901 -0
  24. outerloop/panel.py +188 -0
  25. outerloop/paths.py +27 -0
  26. outerloop/posting.py +160 -0
  27. outerloop/progress.py +170 -0
  28. outerloop/py.typed +0 -0
  29. outerloop/review.py +611 -0
  30. outerloop/review_agent.py +263 -0
  31. outerloop/review_agent_cli.py +209 -0
  32. outerloop/review_post_cli.py +162 -0
  33. outerloop/review_summarize_cli.py +163 -0
  34. outerloop/role_runner.py +229 -0
  35. outerloop/roles.py +247 -0
  36. outerloop/rolespec.py +89 -0
  37. outerloop/runstate.py +385 -0
  38. outerloop/steward.py +852 -0
  39. outerloop/style.py +12 -0
  40. outerloop/syscall.py +977 -0
  41. outerloop/syscall_cli.py +531 -0
  42. outerloop/tick.py +3166 -0
  43. outerloop/verifier.py +403 -0
  44. outerloop/verify_agent.py +149 -0
  45. outerloop/verify_agent_cli.py +95 -0
  46. outerloop/verify_post_cli.py +116 -0
  47. outerloop_science-0.1.0.dev0.dist-info/METADATA +145 -0
  48. outerloop_science-0.1.0.dev0.dist-info/RECORD +52 -0
  49. outerloop_science-0.1.0.dev0.dist-info/WHEEL +4 -0
  50. outerloop_science-0.1.0.dev0.dist-info/entry_points.txt +2 -0
  51. outerloop_science-0.1.0.dev0.dist-info/licenses/LICENSE +202 -0
  52. outerloop_science-0.1.0.dev0.dist-info/licenses/NOTICE +5 -0
outerloop/cli.py ADDED
@@ -0,0 +1,439 @@
1
+ """The one launch command: `outerloop start`.
2
+
3
+ With `sbatch` on PATH it submits the resident tick
4
+ (docs/design/resident-tick.md) and returns; without it, or with
5
+ AUTORESEARCH_COMPUTE=local, it runs the local loop in the foreground.
6
+ Settings come from flags, then the process environment, then
7
+ ~/.config/outerloop/.env, read once here at launch. The running chain
8
+ never takes identity or placement from that file (tick_deploy.sh reads an
9
+ allowlist of author knobs per tick), so editing it later cannot move a chain.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import os
16
+ import shlex
17
+ import shutil
18
+ import stat
19
+ import subprocess
20
+ import sys
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+
24
+ from outerloop import paths
25
+
26
+ RESIDENT_JOB_NAME = "autoresearch-resident"
27
+ DEFAULT_RESIDENT_MINUTES = 360 # cpu_short's ceiling on Torch; the loop hands over to itself
28
+ DEFAULT_LOCAL_ROOT = Path.home() / ".autoresearch"
29
+ ENV_FILE = paths.ENV_FILE # ~/.config/outerloop/.env, or the pre-rename dir (see paths.py)
30
+
31
+ # What start itself decides from: mode, placement, root, cadence, walltime.
32
+ START_KEYS = (
33
+ "AUTORESEARCH_COMPUTE",
34
+ "AUTORESEARCH_ROOT",
35
+ "AUTORESEARCH_ACCOUNT",
36
+ "AUTORESEARCH_PARTITION",
37
+ "AUTORESEARCH_CADENCE_MIN",
38
+ "AUTORESEARCH_RESIDENT_MINUTES",
39
+ "AUTORESEARCH_PAT_FILE",
40
+ )
41
+ # The author knobs the chain's deploy step exports from .env every tick. The
42
+ # local loop has no deploy step, so start exports them once at launch; a test
43
+ # keeps this list identical to tick_deploy.sh's.
44
+ TICK_ENV_KEYS = (
45
+ "AUTORESEARCH_AUTHOR_BACKEND",
46
+ "AUTORESEARCH_AUTHOR_MODEL",
47
+ "AUTORESEARCH_CODEX_BIN",
48
+ "AUTORESEARCH_CODEX_KEY_FILE",
49
+ "AUTORESEARCH_HARNESS_KEY_FILE",
50
+ "AUTORESEARCH_VERTEX_PROJECT",
51
+ "AUTORESEARCH_VERTEX_REGION",
52
+ "AUTORESEARCH_VERTEX_ADC",
53
+ "AUTORESEARCH_TARGET",
54
+ "AUTORESEARCH_GITHUB_APP_FILE",
55
+ "AUTORESEARCH_BOT_LOGIN",
56
+ "AUTORESEARCH_BOT_ALIASES",
57
+ "AUTORESEARCH_GPU_PARTITION",
58
+ "AUTORESEARCH_GPU_ACCOUNT",
59
+ "AUTORESEARCH_PANEL",
60
+ "AUTORESEARCH_PANEL_KEY_FILE",
61
+ "AUTORESEARCH_PANEL_CODEX_KEY_FILE",
62
+ "AUTORESEARCH_PANEL_HERMES_KEY_FILE",
63
+ "REVIEW_HERMES_REPO",
64
+ "REVIEW_HERMES_PROVIDER",
65
+ )
66
+
67
+
68
+ class StartError(Exception):
69
+ """A start that cannot proceed; the message is the whole diagnosis."""
70
+
71
+
72
+ def env_file_values(path: Path = ENV_FILE, keys: tuple[str, ...] = START_KEYS) -> dict[str, str]:
73
+ """`keys` from the operator's .env under the deploy step's trust rule: the
74
+ file must be ours and not group/world-writable, or it is refused. Last
75
+ assignment wins; surrounding quotes and a CR are stripped; a key set to
76
+ an empty value is present (an off-switch), an absent key is absent.
77
+ No file: nothing."""
78
+ try:
79
+ st = path.stat()
80
+ except OSError:
81
+ return {}
82
+ if st.st_uid != os.getuid() or st.st_mode & (stat.S_IWGRP | stat.S_IWOTH):
83
+ raise StartError(
84
+ f"refusing to read {path}: it must be owned by you and not group/world-writable"
85
+ )
86
+ try:
87
+ text = path.read_text()
88
+ except OSError as e:
89
+ raise StartError(f"cannot read {path}: {e}") from None
90
+ out: dict[str, str] = {}
91
+ for raw in text.splitlines():
92
+ line = raw.rstrip("\r").strip()
93
+ if not line or line.startswith("#") or "=" not in line:
94
+ continue
95
+ key, value = line.split("=", 1)
96
+ key = key.strip()
97
+ if key.startswith("OUTERLOOP_"): # the public name; `keys` are canonical
98
+ key = "AUTORESEARCH_" + key[len("OUTERLOOP_") :]
99
+ if key not in keys:
100
+ continue
101
+ value = value.strip()
102
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
103
+ value = value[1:-1]
104
+ out[key] = value
105
+ return out
106
+
107
+
108
+ @dataclass(frozen=True)
109
+ class StartPlan:
110
+ mode: str # "slurm" | "local"
111
+ root: Path
112
+ home: Path = Path(".")
113
+ account: str = ""
114
+ partition: str = ""
115
+ cadence_min: str = ""
116
+ resident_minutes: int = DEFAULT_RESIDENT_MINUTES
117
+ pat_file: str = ""
118
+
119
+ def export_env(self) -> dict[str, str]:
120
+ """The knobs the resident job needs. They ride the inherited environment
121
+ (sbatch `--export=ALL`), NOT a comma-joined `--export=K=V,K=V` list — so a
122
+ value may itself contain a comma (e.g. a multi-partition `a,b`, which Slurm
123
+ reads as "whichever frees up first") without corrupting the export
124
+ delimiter. start() merges these into the environment it hands sbatch."""
125
+ env = {
126
+ "AUTORESEARCH_RESIDENT": "1",
127
+ "AUTORESEARCH_HOME": str(self.home),
128
+ "AUTORESEARCH_ROOT": str(self.root),
129
+ "AUTORESEARCH_ACCOUNT": self.account,
130
+ "AUTORESEARCH_RESIDENT_MINUTES": str(self.resident_minutes), # successors reuse it
131
+ }
132
+ if self.partition:
133
+ env["AUTORESEARCH_PARTITION"] = self.partition
134
+ if self.cadence_min:
135
+ env["AUTORESEARCH_CADENCE_MIN"] = self.cadence_min
136
+ if self.pat_file:
137
+ env["AUTORESEARCH_PAT_FILE"] = self.pat_file
138
+ return env
139
+
140
+ def command(self) -> list[str]:
141
+ if self.mode == "local":
142
+ return [sys.executable, "-m", "outerloop.tick", "--root", str(self.root), "--loop"]
143
+ argv = [
144
+ "sbatch",
145
+ "--parsable",
146
+ "--dependency=singleton", # two starts can both submit; only one ever runs
147
+ f"--time={self.resident_minutes}",
148
+ f"--job-name={RESIDENT_JOB_NAME}",
149
+ f"--account={self.account}",
150
+ ]
151
+ if self.partition: # unset lets Slurm choose its default partition
152
+ argv.append(f"--partition={self.partition}")
153
+ # --export=ALL carries export_env() from the inherited environment; a
154
+ # comma-joined K=V list here would break on any value containing a comma.
155
+ argv += ["--export=ALL", str(self.home / "scripts" / "tick_chain.sbatch")]
156
+ return argv
157
+
158
+
159
+ def _setting(key: str, flag: str, environ: dict[str, str], from_file: dict[str, str]) -> str:
160
+ """Flag, then the process environment, then .env."""
161
+ if flag:
162
+ return flag
163
+ if key in environ:
164
+ return environ[key]
165
+ return from_file.get(key, "")
166
+
167
+
168
+ def _home(environ: dict[str, str], cwd: Path) -> Path:
169
+ """The checkout the loop runs from: AUTORESEARCH_HOME, else the current
170
+ directory when it is one. Both modes need it; the tick's launch lanes
171
+ and GitHub servicing switch off without it."""
172
+ home = (
173
+ Path(environ["AUTORESEARCH_HOME"]).expanduser() if environ.get("AUTORESEARCH_HOME") else cwd
174
+ )
175
+ if not (home / "scripts" / "tick_chain.sbatch").is_file():
176
+ raise StartError(
177
+ f"{home} is not an autoresearch checkout (no scripts/tick_chain.sbatch); "
178
+ "run start from the checkout the chain should deploy from, or set AUTORESEARCH_HOME"
179
+ )
180
+ return home
181
+
182
+
183
+ def plan_start(
184
+ *,
185
+ root: str,
186
+ account: str,
187
+ partition: str,
188
+ local: bool,
189
+ environ: dict[str, str],
190
+ from_file: dict[str, str],
191
+ sbatch_on_path: bool,
192
+ cwd: Path,
193
+ ) -> StartPlan:
194
+ compute = _setting("AUTORESEARCH_COMPUTE", "local" if local else "", environ, from_file)
195
+ mode = "local" if compute.strip().lower() == "local" or not sbatch_on_path else "slurm"
196
+ root_s = _setting("AUTORESEARCH_ROOT", root, environ, from_file)
197
+ cadence = _setting("AUTORESEARCH_CADENCE_MIN", "", environ, from_file)
198
+ if cadence:
199
+ # the chain divides by it and the loop sleeps on it: a bad value would
200
+ # only surface after the job started
201
+ try:
202
+ cadence_ok = float(cadence) > 0
203
+ except ValueError:
204
+ cadence_ok = False
205
+ if not cadence_ok:
206
+ raise StartError(
207
+ f"AUTORESEARCH_CADENCE_MIN must be a positive number of minutes, got {cadence!r}"
208
+ )
209
+ pat = _setting("AUTORESEARCH_PAT_FILE", "", environ, from_file)
210
+ # both modes run from a checkout: the tick's launch lanes and GitHub
211
+ # servicing switch off without AUTORESEARCH_HOME
212
+ home = _home(environ, cwd)
213
+ if mode == "local":
214
+ return StartPlan(
215
+ mode="local",
216
+ root=Path(root_s).expanduser() if root_s else DEFAULT_LOCAL_ROOT,
217
+ home=home,
218
+ cadence_min=cadence,
219
+ pat_file=pat,
220
+ )
221
+ if not root_s:
222
+ raise StartError(
223
+ "Slurm mode needs the state root on the shared filesystem: "
224
+ "--root, AUTORESEARCH_ROOT, or AUTORESEARCH_ROOT= in ~/.config/outerloop/.env"
225
+ )
226
+ acc = _setting("AUTORESEARCH_ACCOUNT", account, environ, from_file)
227
+ part = _setting("AUTORESEARCH_PARTITION", partition, environ, from_file)
228
+ # Partition is optional: left unset, Slurm places the job on its default
229
+ # partition. Account stays required (clusters bill by it).
230
+ if not acc:
231
+ raise StartError("Slurm mode needs --account / AUTORESEARCH_ACCOUNT")
232
+ minutes_s = _setting("AUTORESEARCH_RESIDENT_MINUTES", "", environ, from_file)
233
+ try:
234
+ minutes = int(minutes_s) if minutes_s else DEFAULT_RESIDENT_MINUTES
235
+ except ValueError:
236
+ raise StartError(
237
+ f"AUTORESEARCH_RESIDENT_MINUTES must be a whole number of minutes, got {minutes_s!r}"
238
+ ) from None
239
+ if minutes <= 0:
240
+ raise StartError("AUTORESEARCH_RESIDENT_MINUTES must be positive")
241
+ # These ride the inherited environment (sbatch --export=ALL), so a comma is
242
+ # safe now (a multi-partition `a,b` is valid) — only a newline would corrupt
243
+ # the environment or the sbatch argv.
244
+ for name, value in (
245
+ ("root", root_s),
246
+ ("account", acc),
247
+ ("partition", part),
248
+ ("cadence", cadence),
249
+ ("PAT file", pat),
250
+ ("checkout path", str(home)),
251
+ ):
252
+ if "\n" in value or "\r" in value:
253
+ raise StartError(f"{name} {value!r} cannot contain a newline")
254
+ return StartPlan(
255
+ mode="slurm",
256
+ root=Path(root_s).expanduser(),
257
+ home=home,
258
+ account=acc,
259
+ partition=part,
260
+ cadence_min=cadence,
261
+ resident_minutes=minutes,
262
+ pat_file=pat,
263
+ )
264
+
265
+
266
+ def _resident_jobs() -> list[str] | None:
267
+ """Ids of queued or running resident ticks, lowest first; None when the
268
+ scheduler could not be asked (a failed lookup must never read as 'none')."""
269
+ try:
270
+ proc = subprocess.run(
271
+ [
272
+ "squeue",
273
+ "-u",
274
+ os.environ.get("USER", ""),
275
+ f"--name={RESIDENT_JOB_NAME}",
276
+ "-h",
277
+ "-o",
278
+ "%i",
279
+ ],
280
+ capture_output=True,
281
+ text=True,
282
+ timeout=30,
283
+ )
284
+ except (OSError, subprocess.SubprocessError):
285
+ return None
286
+ if proc.returncode != 0:
287
+ return None
288
+ ids = [line.strip() for line in proc.stdout.splitlines() if line.strip()]
289
+ return sorted(ids, key=lambda s: (len(s), s))
290
+
291
+
292
+ def _cancel(job: str) -> bool:
293
+ try:
294
+ proc = subprocess.run(["scancel", job], capture_output=True, text=True, timeout=30)
295
+ except (OSError, subprocess.SubprocessError):
296
+ return False
297
+ return proc.returncode == 0
298
+
299
+
300
+ def _exec(cmd: list[str], env: dict[str, str]) -> int:
301
+ os.execvpe(cmd[0], cmd, env)
302
+ return 1 # unreachable; keeps the signature honest for tests that stub this
303
+
304
+
305
+ def start(args: argparse.Namespace) -> int:
306
+ try:
307
+ values = env_file_values(ENV_FILE, START_KEYS + TICK_ENV_KEYS) # one read for everything
308
+ from_file = {k: v for k, v in values.items() if k in START_KEYS}
309
+ plan = plan_start(
310
+ root=args.root or "",
311
+ account=args.account or "",
312
+ partition=args.partition or "",
313
+ local=args.local,
314
+ environ=dict(os.environ),
315
+ from_file=from_file,
316
+ sbatch_on_path=shutil.which("sbatch") is not None,
317
+ cwd=Path.cwd(),
318
+ )
319
+ except StartError as e:
320
+ print(f"outerloop start: {e}", file=sys.stderr)
321
+ return 2
322
+ cmd = plan.command()
323
+ if args.dry_run:
324
+ print(shlex.join(cmd))
325
+ return 0
326
+ if plan.mode == "local":
327
+ # the loop has no deploy step, so the author knobs the chain would
328
+ # export from .env each tick are exported here once; the shell wins
329
+ env = dict(os.environ)
330
+ for key, value in values.items():
331
+ if key in TICK_ENV_KEYS:
332
+ env.setdefault(key, value)
333
+ env["AUTORESEARCH_COMPUTE"] = "local"
334
+ env["AUTORESEARCH_ROOT"] = str(plan.root)
335
+ env["AUTORESEARCH_HOME"] = str(plan.home)
336
+ if plan.cadence_min:
337
+ env["AUTORESEARCH_CADENCE_MIN"] = plan.cadence_min
338
+ if plan.pat_file:
339
+ env["AUTORESEARCH_PAT_FILE"] = plan.pat_file
340
+ print(
341
+ f"local loop: state in {plan.root}; Ctrl-C stops it, the records resume it",
342
+ file=sys.stderr,
343
+ )
344
+ return _exec(cmd, env)
345
+ existing = _resident_jobs()
346
+ if existing is None:
347
+ print(
348
+ "outerloop start: could not ask the scheduler whether a resident tick "
349
+ "exists (squeue failed); nothing submitted. Retry, or check "
350
+ f"`squeue --name {RESIDENT_JOB_NAME}`.",
351
+ file=sys.stderr,
352
+ )
353
+ return 1
354
+ if existing:
355
+ print(
356
+ f"a resident tick is already queued or running (job {existing[0]}); nothing "
357
+ f"submitted. Stop it with `scancel --name {RESIDENT_JOB_NAME}`, or pause it "
358
+ f"with `touch {plan.root}/PAUSE`.",
359
+ file=sys.stderr,
360
+ )
361
+ return 0
362
+ # sbatch --export=ALL carries these to the resident job from the environment
363
+ # we hand it here (so a comma in a value never breaks a --export delimiter).
364
+ submit_env = {**os.environ, **plan.export_env()}
365
+ proc = subprocess.run(cmd, capture_output=True, text=True, env=submit_env)
366
+ if proc.returncode != 0:
367
+ print(
368
+ f"outerloop start: sbatch failed: {(proc.stderr or proc.stdout).strip()}",
369
+ file=sys.stderr,
370
+ )
371
+ return 1
372
+ job = proc.stdout.strip().split(";")[0]
373
+ # two starts can pass the check above together; singleton keeps them from
374
+ # running at once, and the later submission withdraws so one chain remains
375
+ after = _resident_jobs()
376
+ if after and after[0] != job and job in after:
377
+ if _cancel(job):
378
+ print(
379
+ f"another resident tick (job {after[0]}) was submitted at the same time; "
380
+ f"withdrew this one (job {job}).",
381
+ file=sys.stderr,
382
+ )
383
+ return 0
384
+ # a queued loser would run after the winner and start a second chain
385
+ print(
386
+ f"another resident tick (job {after[0]}) was submitted at the same time and "
387
+ f"this one (job {job}) could not be cancelled; cancel it by hand: scancel {job}",
388
+ file=sys.stderr,
389
+ )
390
+ return 1
391
+ print(
392
+ f"resident tick submitted: job {job} on {plan.partition}, "
393
+ f"{plan.resident_minutes} min walltime, hands over to itself. "
394
+ f"Logs: {plan.root}/logs. Pause: touch {plan.root}/PAUSE. "
395
+ f"Stop: scancel --name {RESIDENT_JOB_NAME}."
396
+ )
397
+ return 0
398
+
399
+
400
+ def main(argv: list[str] | None = None) -> int:
401
+ parser = argparse.ArgumentParser(
402
+ prog="outerloop", description="autonomous research agents in an outer loop"
403
+ )
404
+ sub = parser.add_subparsers(dest="command", required=True)
405
+ p = sub.add_parser(
406
+ "start", help="start the loop: the resident tick on Slurm, the local loop elsewhere"
407
+ )
408
+ p.add_argument(
409
+ "--root", help="state root (shared filesystem on Slurm; default ~/.autoresearch locally)"
410
+ )
411
+ p.add_argument("--account", help="Slurm account")
412
+ p.add_argument("--partition", help="Slurm partition for the tick")
413
+ p.add_argument(
414
+ "--local", action="store_true", help="run the local loop even where sbatch exists"
415
+ )
416
+ p.add_argument("--dry-run", action="store_true", help="print the command and exit")
417
+ sub.add_parser("tick", help="one tick, or --loop; the chain's own entry", add_help=False)
418
+ sub.add_parser(
419
+ "init",
420
+ help="guided setup: write ~/.config/outerloop/.env and the PAT file",
421
+ add_help=False,
422
+ )
423
+ argv = sys.argv[1:] if argv is None else list(argv)
424
+ if argv[:1] == ["tick"]:
425
+ # the tick entry owns its own parser; hand it the rest untouched
426
+ from outerloop import tick
427
+
428
+ sys.argv = ["outerloop tick", *argv[1:]]
429
+ return tick.main()
430
+ if argv[:1] == ["init"]:
431
+ # init owns its own parser too; hand it the args after "init"
432
+ from outerloop import init
433
+
434
+ return init.main(argv[1:])
435
+ return start(parser.parse_args(argv))
436
+
437
+
438
+ if __name__ == "__main__":
439
+ sys.exit(main())