nethackers 0.6.22__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 (40) hide show
  1. nethackers/__init__.py +6 -0
  2. nethackers/_runtime_pin.py +3 -0
  3. nethackers/bootstrap.py +507 -0
  4. nethackers/cli.py +1949 -0
  5. nethackers/constants.py +89 -0
  6. nethackers/eval/__init__.py +1 -0
  7. nethackers/eval/evaluation.py +1087 -0
  8. nethackers/eval/runner.py +600 -0
  9. nethackers/eval/trace_supervisor.py +61 -0
  10. nethackers/harness/__init__.py +49 -0
  11. nethackers/harness/command.py +620 -0
  12. nethackers/harness/conversation.py +381 -0
  13. nethackers/harness/evolution/__init__.py +1 -0
  14. nethackers/harness/evolution/agent.py +471 -0
  15. nethackers/harness/evolution/driver.py +766 -0
  16. nethackers/harness/evolution/kernel.py +295 -0
  17. nethackers/harness/evolution/session.py +358 -0
  18. nethackers/harness/evolution/workflow.py +1916 -0
  19. nethackers/harness/history.py +396 -0
  20. nethackers/harness/protocol.py +955 -0
  21. nethackers/harness/recovery.py +874 -0
  22. nethackers/harness/runner.py +3352 -0
  23. nethackers/harness/search.py +367 -0
  24. nethackers/harness/server.py +218 -0
  25. nethackers/harness/spec.py +552 -0
  26. nethackers/harness/supervisor.py +125 -0
  27. nethackers/repository.py +680 -0
  28. nethackers/sdk/__init__.py +46 -0
  29. nethackers/sdk/candidate.py +388 -0
  30. nethackers/sdk/github.py +1106 -0
  31. nethackers/sdk/objective.py +211 -0
  32. nethackers/sdk/starting_conditions.py +254 -0
  33. nethackers/sdk/submission.py +783 -0
  34. nethackers/sdk/verification.py +118 -0
  35. nethackers-0.6.22.dist-info/METADATA +181 -0
  36. nethackers-0.6.22.dist-info/RECORD +40 -0
  37. nethackers-0.6.22.dist-info/WHEEL +5 -0
  38. nethackers-0.6.22.dist-info/entry_points.txt +2 -0
  39. nethackers-0.6.22.dist-info/licenses/LICENSE +202 -0
  40. nethackers-0.6.22.dist-info/top_level.txt +1 -0
nethackers/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """NetHackers distributed symbolic-agent evolution infrastructure."""
2
+
3
+ from nethackers.constants import BASELINE_COMMIT, BASELINE_REPOSITORY
4
+
5
+ __all__ = ["BASELINE_COMMIT", "BASELINE_REPOSITORY"]
6
+ __version__ = "0.6.22"
@@ -0,0 +1,3 @@
1
+ """Generated release pin for the source runtime fetched by the compact wheel."""
2
+
3
+ SOURCE_RUNTIME_DIGEST = "sha256:826b9a11f2fb1aa48bbd5f7e6d940437ba942adef3d112187f434ff96bfa5bd6"
@@ -0,0 +1,507 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import os
5
+ import re
6
+ import shutil
7
+ import signal
8
+ import subprocess
9
+ import sys
10
+ import tarfile
11
+ import tempfile
12
+ import urllib.error
13
+ import urllib.request
14
+ from pathlib import Path, PurePosixPath
15
+
16
+ from rich.console import Console
17
+ from rich.panel import Panel
18
+
19
+ from nethackers import __version__
20
+ from nethackers._runtime_pin import SOURCE_RUNTIME_DIGEST
21
+
22
+ SOURCE_REPOSITORY = "https://github.com/dunnolab/nethackers.git"
23
+ SOURCE_REVISION = f"v{__version__}"
24
+ RUNTIME_ARCHIVE_URL = (
25
+ f"https://nethackers.dunnolab.ai/dist/nethackers-runtime-{__version__}.tar.gz"
26
+ )
27
+ _RUNTIME_PIN_PATH = "src/nethackers/_runtime_pin.py"
28
+ _DIGEST_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
29
+ _MAX_RUNTIME_ARCHIVE_BYTES = 16 * 1024 * 1024
30
+ _MAX_RUNTIME_MEMBERS = 512
31
+ _MAX_RUNTIME_UNPACKED_BYTES = 64 * 1024 * 1024
32
+ _COMMANDS = {
33
+ "ascend",
34
+ "baseline",
35
+ "candidate",
36
+ "evaluate",
37
+ "join",
38
+ "login",
39
+ "logs",
40
+ "logout",
41
+ "search",
42
+ "session",
43
+ "status",
44
+ "stop",
45
+ "verify",
46
+ "whoami",
47
+ }
48
+
49
+
50
+ class BootstrapError(RuntimeError):
51
+ """Raised when the compact runtime checkout cannot be prepared."""
52
+
53
+
54
+ def _is_runtime_checkout(path: Path) -> bool:
55
+ return all(
56
+ (path / relative).is_file()
57
+ for relative in (
58
+ "roots/autoascend/agent.py",
59
+ "harness/evolution/problem/task.txt",
60
+ "template/README.md",
61
+ "pyproject.toml",
62
+ )
63
+ )
64
+
65
+
66
+ def _source_checkout() -> Path | None:
67
+ candidate = Path(__file__).resolve().parents[2]
68
+ if _is_runtime_checkout(candidate):
69
+ return candidate.resolve()
70
+ return None
71
+
72
+
73
+ def _runtime_home() -> Path:
74
+ configured = os.environ.get("NETHACKERS_HOME")
75
+ if configured:
76
+ return Path(configured).expanduser().resolve()
77
+ cache_home = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache"))
78
+ return (cache_home / "nethackers").resolve()
79
+
80
+
81
+ def _state_home() -> Path:
82
+ configured = os.environ.get("NETHACKERS_STATE_DIR")
83
+ if configured:
84
+ return Path(configured).expanduser().resolve()
85
+ state_home = Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local" / "state"))
86
+ return (state_home / "nethackers").resolve()
87
+
88
+
89
+ def _clone_command(source: str, revision: str, destination: Path) -> list[str]:
90
+ github_source = source.rstrip("/").removesuffix(".git")
91
+ gh_authenticated = False
92
+ if github_source == "https://github.com/dunnolab/nethackers" and shutil.which("gh"):
93
+ try:
94
+ status = subprocess.run(
95
+ ["gh", "auth", "status", "--hostname", "github.com"],
96
+ check=False,
97
+ capture_output=True,
98
+ text=True,
99
+ )
100
+ gh_authenticated = status.returncode == 0
101
+ except OSError:
102
+ pass
103
+ if gh_authenticated:
104
+ return [
105
+ "gh",
106
+ "repo",
107
+ "clone",
108
+ "dunnolab/nethackers",
109
+ str(destination),
110
+ "--",
111
+ "--branch",
112
+ revision,
113
+ "--depth",
114
+ "1",
115
+ "--filter=blob:none",
116
+ ]
117
+ return [
118
+ "git",
119
+ "clone",
120
+ "--branch",
121
+ revision,
122
+ "--depth",
123
+ "1",
124
+ "--filter=blob:none",
125
+ source,
126
+ str(destination),
127
+ ]
128
+
129
+
130
+ def _run_checked(command: list[str]) -> None:
131
+ try:
132
+ result = subprocess.run(command, capture_output=True, text=True, check=False)
133
+ except OSError as exc:
134
+ raise BootstrapError(f"could not run {command[0]!r}: {exc}") from exc
135
+ if result.returncode == 0:
136
+ return
137
+ detail = (result.stderr or result.stdout).strip().splitlines()
138
+ reason = detail[-1] if detail else f"exit status {result.returncode}"
139
+ raise BootstrapError(f"{command[0]} failed: {reason}")
140
+
141
+
142
+ def runtime_digest(path: Path) -> str:
143
+ """Digest the exact Git index while excluding only the generated pin itself."""
144
+ try:
145
+ result = subprocess.run(
146
+ ["git", "-C", str(path), "ls-files", "--stage", "-z"],
147
+ capture_output=True,
148
+ check=False,
149
+ )
150
+ except OSError as exc:
151
+ raise BootstrapError(f"could not inspect the source runtime: {exc}") from exc
152
+ if result.returncode != 0:
153
+ detail = result.stderr.decode(errors="replace").strip().splitlines()
154
+ reason = detail[-1] if detail else f"exit status {result.returncode}"
155
+ raise BootstrapError(f"could not inspect the source runtime: {reason}")
156
+ digest = hashlib.sha256()
157
+ entries = 0
158
+ for record in result.stdout.split(b"\0"):
159
+ if not record:
160
+ continue
161
+ metadata, separator, path_bytes = record.partition(b"\t")
162
+ fields = metadata.split()
163
+ if not separator or len(fields) != 3:
164
+ raise BootstrapError("source runtime has an invalid Git index")
165
+ try:
166
+ relative = path_bytes.decode("utf-8")
167
+ except UnicodeDecodeError as exc:
168
+ raise BootstrapError("source runtime contains a non-UTF-8 path") from exc
169
+ if relative == _RUNTIME_PIN_PATH:
170
+ continue
171
+ mode, object_id, stage = fields
172
+ if stage != b"0" or mode not in {b"100644", b"100755"}:
173
+ raise BootstrapError("source runtime contains an unsupported Git index entry")
174
+ digest.update(mode + b"\0" + object_id + b"\0" + path_bytes + b"\0")
175
+ entries += 1
176
+ if entries < 1:
177
+ raise BootstrapError("source runtime Git index is empty")
178
+ return f"sha256:{digest.hexdigest()}"
179
+
180
+
181
+ def runtime_directory_digest(path: Path) -> str:
182
+ """Digest a release tree with the same entries used by Git's index."""
183
+ digest = hashlib.sha256()
184
+ entries = 0
185
+ for item in sorted(
186
+ path.rglob("*"),
187
+ key=lambda value: value.relative_to(path).as_posix(),
188
+ ):
189
+ relative = item.relative_to(path).as_posix()
190
+ if relative == _RUNTIME_PIN_PATH:
191
+ continue
192
+ if item.is_symlink():
193
+ raise BootstrapError(f"source runtime contains a symbolic link: {relative}")
194
+ if item.is_dir():
195
+ continue
196
+ if not item.is_file():
197
+ raise BootstrapError(f"source runtime contains an unsupported entry: {relative}")
198
+ try:
199
+ path_bytes = relative.encode("utf-8")
200
+ except UnicodeEncodeError as exc:
201
+ raise BootstrapError("source runtime contains a non-UTF-8 path") from exc
202
+ payload = item.read_bytes()
203
+ header = f"blob {len(payload)}\0".encode()
204
+ object_id = hashlib.sha1(
205
+ header + payload,
206
+ usedforsecurity=False,
207
+ ).hexdigest().encode()
208
+ mode = b"100755" if item.stat().st_mode & 0o111 else b"100644"
209
+ digest.update(mode + b"\0" + object_id + b"\0" + path_bytes + b"\0")
210
+ entries += 1
211
+ if entries < 1:
212
+ raise BootstrapError("source runtime directory is empty")
213
+ return f"sha256:{digest.hexdigest()}"
214
+
215
+
216
+ def _download_runtime_archive(source: str, destination: Path) -> None:
217
+ if not source.startswith("https://"):
218
+ raise BootstrapError("runtime archive URL must use HTTPS")
219
+ request = urllib.request.Request(
220
+ source,
221
+ headers={"User-Agent": f"nethackers/{__version__}"},
222
+ )
223
+ try:
224
+ response = urllib.request.urlopen(request, timeout=60)
225
+ except (OSError, urllib.error.URLError) as exc:
226
+ raise BootstrapError(f"could not download the NetHackers runtime: {exc}") from exc
227
+ with response:
228
+ final_url = response.geturl()
229
+ if not final_url.startswith("https://"):
230
+ raise BootstrapError("runtime archive redirected away from HTTPS")
231
+ content_length = response.headers.get("Content-Length")
232
+ if content_length is not None:
233
+ try:
234
+ declared_size = int(content_length)
235
+ except ValueError as exc:
236
+ raise BootstrapError("runtime archive has an invalid Content-Length") from exc
237
+ if not 0 < declared_size <= _MAX_RUNTIME_ARCHIVE_BYTES:
238
+ raise BootstrapError("runtime archive exceeds the download limit")
239
+ downloaded = 0
240
+ with destination.open("xb") as output:
241
+ while chunk := response.read(1024 * 1024):
242
+ downloaded += len(chunk)
243
+ if downloaded > _MAX_RUNTIME_ARCHIVE_BYTES:
244
+ raise BootstrapError("runtime archive exceeds the download limit")
245
+ output.write(chunk)
246
+ if downloaded == 0:
247
+ raise BootstrapError("runtime archive is empty")
248
+
249
+
250
+ def _archive_relative_path(name: str) -> Path | None:
251
+ if "\\" in name or "\0" in name or ":" in name:
252
+ raise BootstrapError("runtime archive contains an unsafe path")
253
+ pure = PurePosixPath(name)
254
+ if pure.is_absolute() or ".." in pure.parts:
255
+ raise BootstrapError("runtime archive contains an unsafe path")
256
+ parts = tuple(part for part in pure.parts if part not in {"", "."})
257
+ return Path(*parts) if parts else None
258
+
259
+
260
+ def _extract_runtime_archive(archive: Path, destination: Path) -> None:
261
+ try:
262
+ destination.mkdir(mode=0o700)
263
+ with tarfile.open(archive, mode="r|gz") as bundle:
264
+ member_count = 0
265
+ unpacked_size = 0
266
+ normalized: set[Path] = set()
267
+ for member in bundle:
268
+ member_count += 1
269
+ if member_count > _MAX_RUNTIME_MEMBERS:
270
+ raise BootstrapError("runtime archive contains too many entries")
271
+ relative = _archive_relative_path(member.name)
272
+ if relative is None:
273
+ if not member.isdir():
274
+ raise BootstrapError("runtime archive contains an invalid root entry")
275
+ continue
276
+ if relative in normalized:
277
+ raise BootstrapError("runtime archive contains duplicate paths")
278
+ normalized.add(relative)
279
+ target = destination / relative
280
+ if member.isdir():
281
+ target.mkdir(parents=True, exist_ok=True, mode=0o755)
282
+ target.chmod(0o755)
283
+ continue
284
+ if not member.isfile() or (member.mode & 0o777) not in {0o644, 0o755}:
285
+ raise BootstrapError("runtime archive contains an unsupported entry")
286
+ unpacked_size += member.size
287
+ if unpacked_size > _MAX_RUNTIME_UNPACKED_BYTES:
288
+ raise BootstrapError("runtime archive exceeds the unpacked size limit")
289
+ target.parent.mkdir(parents=True, exist_ok=True, mode=0o755)
290
+ source = bundle.extractfile(member)
291
+ if source is None:
292
+ raise BootstrapError("runtime archive file cannot be read")
293
+ with source, target.open("xb") as output:
294
+ shutil.copyfileobj(source, output)
295
+ target.chmod(member.mode & 0o777)
296
+ except BootstrapError:
297
+ raise
298
+ except (OSError, tarfile.TarError) as exc:
299
+ raise BootstrapError(f"runtime archive is invalid: {exc}") from exc
300
+
301
+
302
+ def _verify_runtime(path: Path) -> None:
303
+ if not _DIGEST_RE.fullmatch(SOURCE_RUNTIME_DIGEST):
304
+ raise BootstrapError("this NetHackers wheel has no immutable source-runtime pin")
305
+ actual = (
306
+ runtime_digest(path)
307
+ if (path / ".git").exists()
308
+ else runtime_directory_digest(path)
309
+ )
310
+ if actual != SOURCE_RUNTIME_DIGEST:
311
+ raise BootstrapError(
312
+ f"source runtime digest is {actual}, expected {SOURCE_RUNTIME_DIGEST}"
313
+ )
314
+ runtime_pin = path / _RUNTIME_PIN_PATH
315
+ installed_pin = Path(__file__).with_name("_runtime_pin.py").read_bytes()
316
+ if not runtime_pin.is_file() or runtime_pin.read_bytes() != installed_pin:
317
+ raise BootstrapError("source runtime pin does not match the installed wheel")
318
+
319
+
320
+ def prepare_runtime(*, console: Console | None = None) -> Path:
321
+ override = os.environ.get("NETHACKERS_RUNTIME_ROOT")
322
+ if override:
323
+ runtime = Path(override).expanduser().resolve()
324
+ if not _is_runtime_checkout(runtime):
325
+ raise BootstrapError(f"NETHACKERS_RUNTIME_ROOT is not a NetHackers checkout: {runtime}")
326
+ return runtime
327
+
328
+ source_checkout = _source_checkout()
329
+ if source_checkout is not None:
330
+ return source_checkout
331
+
332
+ runtime = _runtime_home() / "runtime" / __version__
333
+ if _is_runtime_checkout(runtime):
334
+ try:
335
+ _verify_runtime(runtime)
336
+ except BootstrapError:
337
+ shutil.rmtree(runtime)
338
+ else:
339
+ return runtime
340
+ if runtime.exists():
341
+ shutil.rmtree(runtime)
342
+
343
+ source = os.environ.get("NETHACKERS_SOURCE_URL")
344
+ archive_source = os.environ.get("NETHACKERS_RUNTIME_URL", RUNTIME_ARCHIVE_URL)
345
+ revision = os.environ.get("NETHACKERS_SOURCE_REVISION", SOURCE_REVISION)
346
+ runtime.parent.mkdir(parents=True, exist_ok=True)
347
+ temporary = Path(tempfile.mkdtemp(prefix=f"{__version__}-", dir=runtime.parent))
348
+ shutil.rmtree(temporary)
349
+ status = (console or Console()).status(
350
+ "[bold green]Preparing the compact AutoAscend evolution runtime..."
351
+ )
352
+ archive = temporary.with_name(f"{temporary.name}.tar.gz")
353
+ try:
354
+ with status:
355
+ if source is not None:
356
+ if shutil.which("git") is None:
357
+ raise BootstrapError("git is required for NETHACKERS_SOURCE_URL")
358
+ clone_command = _clone_command(source, revision, temporary)
359
+ try:
360
+ _run_checked(clone_command)
361
+ except BootstrapError:
362
+ if clone_command[0] != "gh":
363
+ raise
364
+ shutil.rmtree(temporary, ignore_errors=True)
365
+ _run_checked(
366
+ [
367
+ "git",
368
+ "clone",
369
+ "--branch",
370
+ revision,
371
+ "--depth",
372
+ "1",
373
+ "--filter=blob:none",
374
+ source,
375
+ str(temporary),
376
+ ]
377
+ )
378
+ else:
379
+ _download_runtime_archive(archive_source, archive)
380
+ _extract_runtime_archive(archive, temporary)
381
+ if not temporary.exists():
382
+ raise BootstrapError("runtime preparation produced no checkout")
383
+ _verify_runtime(temporary)
384
+ if runtime.exists():
385
+ shutil.rmtree(temporary)
386
+ else:
387
+ temporary.replace(runtime)
388
+ except Exception:
389
+ shutil.rmtree(temporary, ignore_errors=True)
390
+ raise
391
+ finally:
392
+ archive.unlink(missing_ok=True)
393
+ if not _is_runtime_checkout(runtime):
394
+ raise BootstrapError("the downloaded NetHackers runtime is incomplete")
395
+ return runtime
396
+
397
+
398
+ def _run_without_runtime(arguments: list[str]) -> bool:
399
+ if not arguments:
400
+ return False
401
+ if any(argument in {"-h", "--help"} for argument in arguments):
402
+ return True
403
+ if arguments[0] == "session":
404
+ return len(arguments) > 1 and arguments[1] != "start"
405
+ return arguments[0] in {
406
+ "-h",
407
+ "--help",
408
+ "--version",
409
+ "logs",
410
+ "login",
411
+ "logout",
412
+ "search",
413
+ "status",
414
+ "stop",
415
+ "whoami",
416
+ }
417
+
418
+
419
+ def _normalize_arguments(arguments: list[str]) -> list[str]:
420
+ if not arguments:
421
+ return ["session", "start"]
422
+ if arguments[0] in _COMMANDS or arguments[0] in {"-h", "--help", "--version"}:
423
+ return arguments
424
+ return ["session", "start", *arguments]
425
+
426
+
427
+ def _signal_runtime(process: subprocess.Popen[bytes], signal_number: int) -> None:
428
+ try:
429
+ if os.name == "posix":
430
+ os.killpg(process.pid, signal_number)
431
+ else:
432
+ process.send_signal(signal_number)
433
+ except ProcessLookupError:
434
+ pass
435
+
436
+
437
+ def _wait_for_runtime(process: subprocess.Popen[bytes], console: Console) -> int:
438
+ try:
439
+ returncode = process.wait()
440
+ except KeyboardInterrupt:
441
+ console.print(
442
+ "[yellow][STOP][/] Finishing and publishing the best checkpoint; "
443
+ "press Ctrl+C again to force exit."
444
+ )
445
+ _signal_runtime(process, signal.SIGINT)
446
+ try:
447
+ returncode = process.wait()
448
+ except KeyboardInterrupt:
449
+ console.print("[red][STOP][/] Forcing the NetHackers runtime to exit.")
450
+ _signal_runtime(process, signal.SIGTERM)
451
+ try:
452
+ returncode = process.wait(timeout=10)
453
+ except subprocess.TimeoutExpired:
454
+ _signal_runtime(process, signal.SIGKILL)
455
+ returncode = process.wait()
456
+ return 130
457
+ return 130 if returncode < 0 else returncode
458
+
459
+
460
+ def main(argv: list[str] | None = None) -> int:
461
+ if argv is None and "NETHACKERS_LAUNCHER" not in os.environ:
462
+ resolved_launcher = shutil.which(sys.argv[0])
463
+ if resolved_launcher is not None:
464
+ os.environ["NETHACKERS_LAUNCHER"] = str(
465
+ Path(resolved_launcher).expanduser().resolve()
466
+ )
467
+ arguments = _normalize_arguments(list(sys.argv[1:] if argv is None else argv))
468
+ invocation_directory = Path.cwd().resolve()
469
+ if _run_without_runtime(arguments):
470
+ from nethackers.cli import main as cli_main
471
+
472
+ return cli_main(arguments)
473
+
474
+ source_checkout = _source_checkout()
475
+ console = Console()
476
+ try:
477
+ runtime = prepare_runtime(console=console)
478
+ except BootstrapError as exc:
479
+ console.print(Panel(str(exc), title="[bold red]NETHACKERS SETUP FAILED[/]"))
480
+ return 2
481
+
482
+ if source_checkout is not None and runtime == source_checkout:
483
+ from nethackers.cli import main as cli_main
484
+
485
+ return cli_main(arguments)
486
+
487
+ environment = dict(os.environ)
488
+ environment["NETHACKERS_RUNTIME_ROOT"] = str(runtime)
489
+ environment["NETHACKERS_STATE_DIR"] = str(_state_home())
490
+ environment.setdefault("NETHACKERS_WORKSPACE", str(invocation_directory))
491
+ try:
492
+ process = subprocess.Popen(
493
+ [sys.executable, "-I", "-m", "nethackers.cli", *arguments],
494
+ cwd=invocation_directory,
495
+ env=environment,
496
+ start_new_session=os.name == "posix",
497
+ )
498
+ except OSError as exc:
499
+ console.print(
500
+ Panel(str(exc), title="[bold red]NETHACKERS START FAILED[/]")
501
+ )
502
+ return 2
503
+ return _wait_for_runtime(process, console)
504
+
505
+
506
+ if __name__ == "__main__":
507
+ raise SystemExit(main())