proteus-evolve 0.2.0__tar.gz → 0.3.0__tar.gz

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 (71) hide show
  1. {proteus_evolve-0.2.0 → proteus_evolve-0.3.0}/PKG-INFO +10 -8
  2. {proteus_evolve-0.2.0 → proteus_evolve-0.3.0}/README.md +9 -7
  3. {proteus_evolve-0.2.0 → proteus_evolve-0.3.0}/proteus/__init__.py +1 -1
  4. {proteus_evolve-0.2.0 → proteus_evolve-0.3.0}/proteus/adapters/aki.py +2 -1
  5. {proteus_evolve-0.2.0 → proteus_evolve-0.3.0}/proteus/adapters/dsh.py +72 -30
  6. {proteus_evolve-0.2.0 → proteus_evolve-0.3.0}/proteus/adapters/llm.py +25 -20
  7. {proteus_evolve-0.2.0 → proteus_evolve-0.3.0}/proteus/adapters/minimal.py +15 -12
  8. {proteus_evolve-0.2.0 → proteus_evolve-0.3.0}/proteus/adapters/pi.py +29 -15
  9. proteus_evolve-0.3.0/proteus/bench/_datasets.py +39 -0
  10. proteus_evolve-0.3.0/proteus/bench/_isolation.py +303 -0
  11. proteus_evolve-0.3.0/proteus/bench/humaneval.py +189 -0
  12. proteus_evolve-0.3.0/proteus/bench/mbpp.py +225 -0
  13. {proteus_evolve-0.2.0 → proteus_evolve-0.3.0}/proteus/bench/sandbox.py +8 -3
  14. {proteus_evolve-0.2.0 → proteus_evolve-0.3.0}/proteus/cli.py +58 -6
  15. {proteus_evolve-0.2.0 → proteus_evolve-0.3.0}/proteus/core/__init__.py +6 -0
  16. {proteus_evolve-0.2.0 → proteus_evolve-0.3.0}/proteus/core/adapter.py +13 -2
  17. proteus_evolve-0.3.0/proteus/core/budget.py +218 -0
  18. {proteus_evolve-0.2.0 → proteus_evolve-0.3.0}/proteus/core/continuity.py +96 -12
  19. {proteus_evolve-0.2.0 → proteus_evolve-0.3.0}/proteus/core/episode.py +106 -59
  20. proteus_evolve-0.3.0/proteus/core/episode_protocol.py +77 -0
  21. {proteus_evolve-0.2.0 → proteus_evolve-0.3.0}/proteus/core/goal.py +6 -5
  22. {proteus_evolve-0.2.0 → proteus_evolve-0.3.0}/proteus/examples/adapter_template.py +19 -15
  23. {proteus_evolve-0.2.0 → proteus_evolve-0.3.0}/proteus/sweep.py +50 -3
  24. {proteus_evolve-0.2.0 → proteus_evolve-0.3.0}/proteus_evolve.egg-info/PKG-INFO +10 -8
  25. {proteus_evolve-0.2.0 → proteus_evolve-0.3.0}/proteus_evolve.egg-info/SOURCES.txt +12 -0
  26. proteus_evolve-0.3.0/tests/test_benchmark_isolation.py +243 -0
  27. {proteus_evolve-0.2.0 → proteus_evolve-0.3.0}/tests/test_continuity.py +35 -0
  28. proteus_evolve-0.3.0/tests/test_datasets.py +99 -0
  29. proteus_evolve-0.3.0/tests/test_envs.py +112 -0
  30. proteus_evolve-0.3.0/tests/test_episode_protocol.py +101 -0
  31. {proteus_evolve-0.2.0 → proteus_evolve-0.3.0}/tests/test_goals.py +32 -0
  32. proteus_evolve-0.3.0/tests/test_humaneval.py +149 -0
  33. {proteus_evolve-0.2.0 → proteus_evolve-0.3.0}/tests/test_instrument.py +52 -0
  34. proteus_evolve-0.3.0/tests/test_mbpp.py +209 -0
  35. {proteus_evolve-0.2.0 → proteus_evolve-0.3.0}/tests/test_selfcode.py +231 -15
  36. {proteus_evolve-0.2.0 → proteus_evolve-0.3.0}/tests/test_smoke.py +4 -2
  37. {proteus_evolve-0.2.0 → proteus_evolve-0.3.0}/LICENSE +0 -0
  38. {proteus_evolve-0.2.0 → proteus_evolve-0.3.0}/proteus/adapters/__init__.py +0 -0
  39. {proteus_evolve-0.2.0 → proteus_evolve-0.3.0}/proteus/adapters/instructions.py +0 -0
  40. {proteus_evolve-0.2.0 → proteus_evolve-0.3.0}/proteus/bench/__init__.py +0 -0
  41. {proteus_evolve-0.2.0 → proteus_evolve-0.3.0}/proteus/bench/local.py +0 -0
  42. {proteus_evolve-0.2.0 → proteus_evolve-0.3.0}/proteus/bench/polyglot.py +0 -0
  43. {proteus_evolve-0.2.0 → proteus_evolve-0.3.0}/proteus/bench/swe.py +0 -0
  44. {proteus_evolve-0.2.0 → proteus_evolve-0.3.0}/proteus/bench/task.py +0 -0
  45. {proteus_evolve-0.2.0 → proteus_evolve-0.3.0}/proteus/core/disposition.py +0 -0
  46. {proteus_evolve-0.2.0 → proteus_evolve-0.3.0}/proteus/core/evaluators.py +0 -0
  47. {proteus_evolve-0.2.0 → proteus_evolve-0.3.0}/proteus/core/snapshot.py +0 -0
  48. {proteus_evolve-0.2.0 → proteus_evolve-0.3.0}/proteus/envs.py +0 -0
  49. {proteus_evolve-0.2.0 → proteus_evolve-0.3.0}/proteus/examples/__init__.py +0 -0
  50. {proteus_evolve-0.2.0 → proteus_evolve-0.3.0}/proteus/examples/benchmark_template.py +0 -0
  51. {proteus_evolve-0.2.0 → proteus_evolve-0.3.0}/proteus/examples/no_goal_vs_review.py +0 -0
  52. {proteus_evolve-0.2.0 → proteus_evolve-0.3.0}/proteus/measure/__init__.py +0 -0
  53. {proteus_evolve-0.2.0 → proteus_evolve-0.3.0}/proteus/measure/audit.py +0 -0
  54. {proteus_evolve-0.2.0 → proteus_evolve-0.3.0}/proteus/measure/crystallize.py +0 -0
  55. {proteus_evolve-0.2.0 → proteus_evolve-0.3.0}/proteus/measure/distance.py +0 -0
  56. {proteus_evolve-0.2.0 → proteus_evolve-0.3.0}/proteus/measure/stream.py +0 -0
  57. {proteus_evolve-0.2.0 → proteus_evolve-0.3.0}/proteus/report.py +0 -0
  58. {proteus_evolve-0.2.0 → proteus_evolve-0.3.0}/proteus/sandbox/__init__.py +0 -0
  59. {proteus_evolve-0.2.0 → proteus_evolve-0.3.0}/proteus/sandbox/docker.py +0 -0
  60. {proteus_evolve-0.2.0 → proteus_evolve-0.3.0}/proteus/scaffold.py +0 -0
  61. {proteus_evolve-0.2.0 → proteus_evolve-0.3.0}/proteus/testing.py +0 -0
  62. {proteus_evolve-0.2.0 → proteus_evolve-0.3.0}/proteus_evolve.egg-info/dependency_links.txt +0 -0
  63. {proteus_evolve-0.2.0 → proteus_evolve-0.3.0}/proteus_evolve.egg-info/entry_points.txt +0 -0
  64. {proteus_evolve-0.2.0 → proteus_evolve-0.3.0}/proteus_evolve.egg-info/requires.txt +0 -0
  65. {proteus_evolve-0.2.0 → proteus_evolve-0.3.0}/proteus_evolve.egg-info/top_level.txt +0 -0
  66. {proteus_evolve-0.2.0 → proteus_evolve-0.3.0}/pyproject.toml +0 -0
  67. {proteus_evolve-0.2.0 → proteus_evolve-0.3.0}/setup.cfg +0 -0
  68. {proteus_evolve-0.2.0 → proteus_evolve-0.3.0}/tests/test_aki_adapter.py +0 -0
  69. {proteus_evolve-0.2.0 → proteus_evolve-0.3.0}/tests/test_bench.py +0 -0
  70. {proteus_evolve-0.2.0 → proteus_evolve-0.3.0}/tests/test_conformance.py +0 -0
  71. {proteus_evolve-0.2.0 → proteus_evolve-0.3.0}/tests/test_polyglot.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proteus-evolve
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: A harness-agnostic driver for measuring agent self-evolution: plug in any harness × model, evolve it under goal or no-goal conditions, and measure how the harness changes.
5
5
  Author: Proteus Authors
6
6
  License-Expression: MIT
@@ -47,7 +47,7 @@ Dynamic: license-file
47
47
  <a href="https://github.com/proteus-evolve/Proteus/actions/workflows/release-smoke.yml"><img src="https://github.com/proteus-evolve/Proteus/actions/workflows/release-smoke.yml/badge.svg" alt="release smoke"></a>
48
48
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-green.svg" alt="MIT License"></a>
49
49
  <img src="https://img.shields.io/badge/python-3.10%2B-blue.svg" alt="Python 3.10+">
50
- <img src="https://img.shields.io/badge/version-0.2.0-informational.svg" alt="v0.2.0">
50
+ <img src="https://img.shields.io/badge/version-0.3.0-informational.svg" alt="v0.3.0">
51
51
  <img src="https://img.shields.io/badge/status-research%20preview-orange.svg" alt="research preview">
52
52
  </p>
53
53
 
@@ -60,7 +60,7 @@ Dynamic: license-file
60
60
  <a href="docs/RECIPES.md">Recipes</a> •
61
61
  <a href="docs/BENCHMARKS.md">Bring a Benchmark</a> •
62
62
  <a href="docs/MEASUREMENTS.md">Add a Measurement</a> •
63
- <a href="docs/releases/v0.2.0.md">v0.2.0 Notes</a> •
63
+ <a href="docs/releases/v0.3.0.md">v0.3.0 Notes</a> •
64
64
  <a href="environments/README.md">Environments</a> •
65
65
  <a href="#-measurement">Measurement</a>
66
66
  </p>
@@ -307,11 +307,12 @@ roots, so the evolving agent can never read its own condition.
307
307
 
308
308
  ## 📊 Status
309
309
 
310
- `v0.2.0` (research preview). Working today: the offline `minimal` harness; the live `llm`
310
+ `v0.3.0` (research preview). Working today: the offline `minimal` harness; the live `llm`
311
311
  harness; pinned, source-evolving DeepSeek Harness and Pi adapters with frozen per-episode
312
312
  activation, automatic rollback, exact-tree boundary gates, rebuild caching, turn budgets,
313
- and task mounts; the Aki research adapter;
314
- local, Polyglot, and SWE-bench task integrations; resume-safe sweeps; the full measurement,
313
+ phase-aware act-priority budget plans and agent-authored checkpoint tracking, and task
314
+ mounts; the Aki research adapter; local, Polyglot, and SWE-bench task integrations;
315
+ resume-safe sweeps; the full measurement,
315
316
  audit, reliability, report, and repository-export paths; and adapter/environment tooling.
316
317
  CI covers Python 3.10–3.14. The separate release-smoke workflow runs two episodes across
317
318
  the public release set (`minimal`, `llm`, `dsh`, `pi`), exercises the benchmark path, and
@@ -341,8 +342,9 @@ Where help is wanted, in one line each — the full list with difficulty tags is
341
342
 
342
343
  - **More harnesses** — Hermes Agent first (Python, built-in self-improvement surfaces),
343
344
  then SWE-agent, OpenClaw, Codex CLI, OpenHands, OpenCode, Goose.
344
- - **More benchmarks** — lightweight offline packs (HumanEval, MBPP, BigCodeBench-lite),
345
- SWE-bench Lite/Verified wiring, and finishing sandboxed grading for `swe`.
345
+ - **More benchmarks** — BigCodeBench-lite and a LiveCodeBench subset, SWE-bench
346
+ Lite/Verified wiring, and finishing sandboxed grading for `swe` (HumanEval and MBPP
347
+ are shipped).
346
348
  - **Analysis** — `proteus compare` for side-by-side arms/runs; an episode-atlas view.
347
349
  - **Reproducibility & cost** — per-episode token/cost accounting; one-command reproduce.
348
350
 
@@ -13,7 +13,7 @@
13
13
  <a href="https://github.com/proteus-evolve/Proteus/actions/workflows/release-smoke.yml"><img src="https://github.com/proteus-evolve/Proteus/actions/workflows/release-smoke.yml/badge.svg" alt="release smoke"></a>
14
14
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-green.svg" alt="MIT License"></a>
15
15
  <img src="https://img.shields.io/badge/python-3.10%2B-blue.svg" alt="Python 3.10+">
16
- <img src="https://img.shields.io/badge/version-0.2.0-informational.svg" alt="v0.2.0">
16
+ <img src="https://img.shields.io/badge/version-0.3.0-informational.svg" alt="v0.3.0">
17
17
  <img src="https://img.shields.io/badge/status-research%20preview-orange.svg" alt="research preview">
18
18
  </p>
19
19
 
@@ -26,7 +26,7 @@
26
26
  <a href="docs/RECIPES.md">Recipes</a> •
27
27
  <a href="docs/BENCHMARKS.md">Bring a Benchmark</a> •
28
28
  <a href="docs/MEASUREMENTS.md">Add a Measurement</a> •
29
- <a href="docs/releases/v0.2.0.md">v0.2.0 Notes</a> •
29
+ <a href="docs/releases/v0.3.0.md">v0.3.0 Notes</a> •
30
30
  <a href="environments/README.md">Environments</a> •
31
31
  <a href="#-measurement">Measurement</a>
32
32
  </p>
@@ -273,11 +273,12 @@ roots, so the evolving agent can never read its own condition.
273
273
 
274
274
  ## 📊 Status
275
275
 
276
- `v0.2.0` (research preview). Working today: the offline `minimal` harness; the live `llm`
276
+ `v0.3.0` (research preview). Working today: the offline `minimal` harness; the live `llm`
277
277
  harness; pinned, source-evolving DeepSeek Harness and Pi adapters with frozen per-episode
278
278
  activation, automatic rollback, exact-tree boundary gates, rebuild caching, turn budgets,
279
- and task mounts; the Aki research adapter;
280
- local, Polyglot, and SWE-bench task integrations; resume-safe sweeps; the full measurement,
279
+ phase-aware act-priority budget plans and agent-authored checkpoint tracking, and task
280
+ mounts; the Aki research adapter; local, Polyglot, and SWE-bench task integrations;
281
+ resume-safe sweeps; the full measurement,
281
282
  audit, reliability, report, and repository-export paths; and adapter/environment tooling.
282
283
  CI covers Python 3.10–3.14. The separate release-smoke workflow runs two episodes across
283
284
  the public release set (`minimal`, `llm`, `dsh`, `pi`), exercises the benchmark path, and
@@ -307,8 +308,9 @@ Where help is wanted, in one line each — the full list with difficulty tags is
307
308
 
308
309
  - **More harnesses** — Hermes Agent first (Python, built-in self-improvement surfaces),
309
310
  then SWE-agent, OpenClaw, Codex CLI, OpenHands, OpenCode, Goose.
310
- - **More benchmarks** — lightweight offline packs (HumanEval, MBPP, BigCodeBench-lite),
311
- SWE-bench Lite/Verified wiring, and finishing sandboxed grading for `swe`.
311
+ - **More benchmarks** — BigCodeBench-lite and a LiveCodeBench subset, SWE-bench
312
+ Lite/Verified wiring, and finishing sandboxed grading for `swe` (HumanEval and MBPP
313
+ are shipped).
312
314
  - **Analysis** — `proteus compare` for side-by-side arms/runs; an episode-atlas view.
313
315
  - **Reproducibility & cost** — per-episode token/cost accounting; one-command reproduce.
314
316
 
@@ -1,2 +1,2 @@
1
1
  """Proteus — a harness-agnostic framework for agent self-evolution."""
2
- __version__ = "0.2.0"
2
+ __version__ = "0.3.0"
@@ -19,7 +19,8 @@ The adapter never writes into the Aki checkout; all state lands in the Proteus r
19
19
  Known limits, stated rather than papered over:
20
20
  - Aki's phase prompts live inside the harness (`loop.py`), so `spec.phase_prompts` is not
21
21
  injected into the episode yet; no-goal runs — the paper's primary regime — are fully
22
- supported, goal-text injection into Aki episodes is not wired.
22
+ supported, while the framework default epistemic protocol and goal-text injection are
23
+ not wired into Aki episodes. Other bundled adapters consume `spec.phase_prompts`.
23
24
  - Aki couples seeding and disposition install in one `init_run`, so this adapter performs
24
25
  both inside `install_disposition` (the framework calls `seed` first; it only records state).
25
26
  """
@@ -34,9 +34,9 @@ from pathlib import Path
34
34
  from typing import Optional, Sequence
35
35
 
36
36
  from proteus.core.adapter import ActionEvent, EpisodeResult, EpisodeSpec, Surface
37
+ from proteus.core.budget import PHASES, budget_plan, phase_prompt
37
38
  from proteus.core.continuity import CONTAINER_ROOT, HandoffStore
38
39
  from proteus.core.disposition import Disposition
39
- from proteus.core.episode import PHASES
40
40
 
41
41
  IMAGE = os.environ.get("PROTEUS_DSH_IMAGE", "proteus-env-dsh-src:0.1.0-rc.7")
42
42
  PHASE_TIMEOUT_S = 600
@@ -49,11 +49,15 @@ PHASE_TIMEOUT_S = 600
49
49
  SOURCE_TAR = "/opt/dsh-source.tar"
50
50
  #: A full build:lib is ~330s; the gate's timeout must cover one on a changed source.
51
51
  BOOT_TIMEOUT_S = 900
52
+ #: A 246-package DSH workspace takes roughly 90s to relink and cold-start on Docker
53
+ #: Desktop even with a warm offline store. Keep enough headroom for slower hosts while
54
+ #: still treating a genuine hang as a runtime viability failure.
55
+ COLD_BOOT_TIMEOUT_S = 300
52
56
  SEED_INSTRUCTIONS = """\
53
57
  # Agent instructions
54
58
 
55
- You maintain and improve your own harness. During a Proteus episode, the harness currently
56
- running is a frozen, read-only snapshot at `/workspace`; the writable candidate that
59
+ You inhabit, inspect, and may change your own harness. During a Proteus episode, the harness
60
+ currently running is a frozen, read-only snapshot at `/workspace`; the writable candidate that
57
61
  persists across phases is `/workspace/candidate`. Make every edit in that candidate. Your
58
62
  candidate surfaces are:
59
63
 
@@ -70,12 +74,14 @@ Proteus supplies the cross-phase operational handoff at
70
74
  runtime context outside the evolving snapshot; do not copy credentials or raw tool output
71
75
  into it.
72
76
 
73
- The image already contains an installed, built copy at `/opt/src`. Do not run `pnpm
74
- install` in either workspace, and do not create `node_modules` or package-manager caches
75
- in the candidate: those are generated dependencies, not evolution, and would pollute the
76
- persistent snapshot. `/opt/src` is the build of the frozen active snapshot. Do not sync,
77
- reload, or execute candidate source during a phase; Proteus owns the model-free boundary
78
- build and viability gate after reflect.
77
+ The image already contains an installed, built copy at `/opt/src`. Do not create or persist
78
+ `node_modules` or package-manager caches in the candidate: those are generated dependencies,
79
+ not evolution, and would pollute the snapshot. You may add workspace packages or change
80
+ package manifests, but keep `pnpm-lock.yaml` aligned. The boundary gate recreates links with
81
+ a frozen offline install, so an inconsistent lockfile or a dependency absent from the baked
82
+ store is rejected for repair. `/opt/src` is the build of the frozen active snapshot. Do not
83
+ sync, reload, or execute candidate source during a phase; Proteus owns the model-free
84
+ boundary build and viability gate after reflect.
79
85
 
80
86
  Each session is one phase of an episode. Harness files and the bounded Proteus handoff
81
87
  carry over; the raw conversation does not.
@@ -245,20 +251,42 @@ class DshHarness:
245
251
  return ((str(task), "/workspace/task"),) if task.is_dir() else ()
246
252
 
247
253
  def check_boot(self, harness_root: Path) -> str:
248
- """Viability gate: sync + rebuild + `--version` through the image's boot wrapper.
249
-
250
- The wrapper rebuilds from /workspace/src, so a type error the agent wrote into
251
- its own source surfaces here as a legible build failure (exit 97 with the build
252
- log tail), before any API spend. The timeout covers one full build:lib."""
254
+ """Build once, then cold-start the exact headless runtime in a fresh container.
255
+
256
+ The first probe exact-syncs the candidate, validates its dependency graph offline,
257
+ rebuilds it, writes the dist cache, and runs ``--version``. A second sandbox call
258
+ starts from a clean image, reloads that cache, and boots the headless plugin tree
259
+ without provider credentials. Keeping the probes in separate containers is
260
+ essential: a newly-added workspace package can compile in the build container yet
261
+ be absent from the cached outputs or runtime links used by episode N+1.
262
+ """
253
263
  harness = Path(harness_root)
254
264
  state = harness.parent / ".dsh-state"
255
265
  state.mkdir(exist_ok=True)
256
- proc = self.sandbox.run(
257
- harness.parent, ["--version"], env={}, timeout_s=BOOT_TIMEOUT_S,
258
- mounts=((str(harness), "/workspace"), (str(state), "/state")))
266
+ mounts = ((str(harness), "/workspace"), (str(state), "/state"))
267
+ try:
268
+ proc = self.sandbox.run(
269
+ harness.parent, ["--version"], env={}, timeout_s=BOOT_TIMEOUT_S,
270
+ mounts=mounts)
271
+ except subprocess.TimeoutExpired:
272
+ return f"self-edited source build timed out after {BOOT_TIMEOUT_S}s"
259
273
  if proc.returncode != 0:
260
274
  return (f"self-edited source does not boot (exit {proc.returncode}): "
261
- f"{(proc.stderr or proc.stdout)[-400:]}")
275
+ f"{(proc.stderr or proc.stdout)[-1200:]}")
276
+ try:
277
+ cold = self.sandbox.run(
278
+ harness.parent, ["--proteus-headless-smoke"], env={},
279
+ timeout_s=COLD_BOOT_TIMEOUT_S, mounts=mounts)
280
+ except subprocess.TimeoutExpired:
281
+ return ("self-edited source headless cold start timed out after "
282
+ f"{COLD_BOOT_TIMEOUT_S}s")
283
+ if cold.returncode != 0:
284
+ detail = (cold.stderr or cold.stdout)[-1200:]
285
+ if "proteus-headless-smoke" in detail and "unknown option" in detail.lower():
286
+ return ("DSH source image predates the headless cold-start contract; "
287
+ "rebuild environments/dsh-src before running evolution")
288
+ return (f"self-edited source fails headless cold start "
289
+ f"(exit {cold.returncode}): {detail}")
262
290
  return ""
263
291
 
264
292
  def validate_candidate(self, harness_root: Path) -> str:
@@ -327,7 +355,9 @@ class DshHarness:
327
355
  mapping: dict[str, list[str]] = {}
328
356
  error = ""
329
357
  capped = False
330
- budget = int(spec.max_turns or 0)
358
+ checkpoint_misses = 0
359
+ plan = budget_plan(spec)
360
+ budget = plan.hard_limit
331
361
  episode_dirs: set = set()
332
362
  active = Path(spec.active_root) if spec.active_root is not None else harness
333
363
  # Core-managed staged episodes already execute a previously validated snapshot.
@@ -347,18 +377,20 @@ class DshHarness:
347
377
  workspace_mounts = ((str(active), "/workspace", "ro"),
348
378
  (str(harness), "/workspace/candidate")) \
349
379
  if spec.active_root is not None else ((str(harness), "/workspace"),)
350
- min_pp = int(getattr(spec, "min_turns_per_phase", 0) or 0)
351
- for idx, phase in enumerate(PHASES if not error else ()):
380
+ for phase in PHASES if not error else ():
352
381
  # the budget is enforced twice, both harness-agnostically: exactly, between
353
382
  # phases (no new phase once it is spent) and approximately, mid-phase (the
354
383
  # session log is polled and the container stopped at the phase's stop line).
355
- # The stop line reserves min_turns_per_phase for every later phase, so a
356
- # reservation stop moves to the next phase; only a spent budget ends the
384
+ # BudgetPlan preserves the legacy later-phase reserve or applies the explicit
385
+ # act-priority plan. A phase stop moves on; only the hard ceiling caps the
357
386
  # episode.
358
- if budget and self._live_calls(state, episode_dirs, set()) >= budget:
387
+ used = self._live_calls(state, episode_dirs, set()) if plan.enabled else 0
388
+ if budget and used >= budget:
359
389
  capped = True
360
390
  break
361
- stop_at = budget - min_pp * (len(PHASES) - idx - 1) if budget else 0
391
+ stop_at = plan.stop_at(phase, used)
392
+ if budget and used >= stop_at:
393
+ continue
362
394
  handoff_start = handoffs.begin(spec.episode, phase)
363
395
  before = self._session_dirs(state)
364
396
  fired = [False]
@@ -374,14 +406,14 @@ class DshHarness:
374
406
  try:
375
407
  proc = self.sandbox.run(
376
408
  run_root,
377
- ["--profile", "headless", spec.phase_prompts.get(phase, phase)],
409
+ ["--profile", "headless", phase_prompt(spec, phase, used)],
378
410
  env={"DEEPSEEK_API_KEY": self.key,
379
411
  "DSH_PERMISSION_MODE": self.permission_mode},
380
412
  timeout_s=self.phase_timeout_s,
381
413
  mounts=workspace_mounts + ((str(state), "/state"),
382
414
  (str(handoffs.root), CONTAINER_ROOT))
383
415
  + self._task_mount(run_root),
384
- stop_check=stop_check if budget else None,
416
+ stop_check=stop_check if plan.enabled else None,
385
417
  )
386
418
  except subprocess.TimeoutExpired:
387
419
  timed_out = True
@@ -395,8 +427,10 @@ class DshHarness:
395
427
  for session_dir in session_dirs:
396
428
  phase_events.extend(
397
429
  self._session_trace(session_dir, phase, partial=True))
398
- handoffs.finish(handoff_start, phase_events,
399
- interrupted=timed_out or fired[0])
430
+ handoff = handoffs.finish(handoff_start, phase_events,
431
+ interrupted=timed_out or fired[0])
432
+ if spec.checkpoint_turns and handoff["source"] != "agent":
433
+ checkpoint_misses += 1
400
434
  if timed_out:
401
435
  error = f"phase {phase}: timeout after {self.phase_timeout_s}s"
402
436
  break
@@ -414,10 +448,18 @@ class DshHarness:
414
448
  (run_root / "traces" / f"ep{spec.episode:03d}.json").write_text(
415
449
  json.dumps(mapping, indent=1))
416
450
  trace = self.read_trace(run_root, spec.episode)
451
+ phase_counts = {
452
+ phase: sum(1 for event in trace if event.phase == phase and event.tool)
453
+ for phase in PHASES
454
+ }
455
+ counters = {"phases": len(mapping), "turn_capped": capped,
456
+ "checkpoint_misses": checkpoint_misses}
457
+ counters.update({f"phase_{phase}_turns": count
458
+ for phase, count in phase_counts.items()})
417
459
  return EpisodeResult(
418
460
  episode=spec.episode, ok=not error,
419
461
  turns=sum(1 for e in trace if e.tool), error=error,
420
- counters={"phases": len(mapping), "turn_capped": capped},
462
+ counters=counters,
421
463
  )
422
464
 
423
465
  def _live_calls(self, state: Path, episode_dirs: set, extra: set) -> int:
@@ -1,10 +1,11 @@
1
1
  """A real-LLM harness on the minimal surface set — any OpenAI-compatible endpoint.
2
2
 
3
3
  The `minimal` harness with the mock policy replaced by a live model: each phase, the model
4
- is shown its phase prompt (disposition and goal already folded in by the framework) plus the
5
- current state of its own harness (the files on its surfaces), and returns the actions to
6
- take as JSON. Only files cross the episode boundary, so this is genuine self-evolution: what
7
- the model wrote in episode t is the state it wakes up to in episode t+1.
4
+ is shown its phase prompt (default episode protocol, goal, and visible evaluator feedback
5
+ already folded in by the framework) plus the current state of its own harness (the files on
6
+ its surfaces), and returns the actions to take as JSON. Only files cross the episode
7
+ boundary, so this is genuine self-evolution: what the model wrote in episode t is the state
8
+ it wakes up to in episode t+1.
8
9
 
9
10
  Works against any OpenAI-compatible chat endpoint via stdlib HTTP (no SDK dependency).
10
11
  Defaults target DeepSeek:
@@ -26,11 +27,11 @@ from pathlib import Path
26
27
 
27
28
  from proteus.adapters.minimal import MinimalHarness
28
29
  from proteus.core.adapter import EpisodeResult, EpisodeSpec
29
- from proteus.core.episode import PHASES
30
+ from proteus.core.budget import PHASES, budget_plan, phase_prompt
30
31
 
31
32
  SYSTEM = """\
32
- You are an agent that maintains and improves its own harness — the set of files it wakes
33
- up with each episode. You have two surfaces:
33
+ You are an agent that can inspect and change its own harness — the set of files it wakes up
34
+ with each episode. You have two surfaces:
34
35
  - notes/ markdown files (observations, plans, knowledge you want to keep)
35
36
  - tools/ small python files (utilities you may want later)
36
37
 
@@ -117,21 +118,20 @@ class LLMHarness(MinimalHarness):
117
118
  turn = 0
118
119
  writes = {"notes": 0, "tools": 0}
119
120
  tokens_in = tokens_out = 0
121
+ phase_counts = {phase: 0 for phase in PHASES}
120
122
  error = ""
121
123
  capped = False
122
- min_pp = int(getattr(spec, "min_turns_per_phase", 0) or 0)
124
+ plan = budget_plan(spec)
123
125
  with trace_path.open("w", encoding="utf-8") as sink:
124
- for idx, phase in enumerate(PHASES):
125
- # `max_turns` bounds the whole episode, model calls included; the phase
126
- # stop line additionally reserves min_turns_per_phase for later phases,
127
- # and reaching it ends the phase, not the episode
128
- if spec.max_turns and turn >= spec.max_turns:
126
+ for phase in PHASES:
127
+ if plan.enabled and turn >= plan.hard_limit:
129
128
  capped = True
130
129
  if capped:
131
130
  break
132
- stop_at = (spec.max_turns - min_pp * (len(PHASES) - idx - 1)
133
- if spec.max_turns else 0)
134
- prompt = spec.phase_prompts.get(phase, "")
131
+ stop_at = plan.stop_at(phase, turn)
132
+ if plan.enabled and turn >= stop_at:
133
+ continue
134
+ prompt = phase_prompt(spec, phase, turn)
135
135
  user = (f"Episode {spec.episode}, phase: {phase}.\n\n"
136
136
  f"{prompt}\n\nCurrent harness state:\n{_render_state(harness)}")
137
137
  try:
@@ -144,11 +144,12 @@ class LLMHarness(MinimalHarness):
144
144
  tokens_in += int(usage.get("prompt_tokens") or 0)
145
145
  tokens_out += int(usage.get("completion_tokens") or 0)
146
146
  turn += 1
147
+ phase_counts[phase] += 1
147
148
  sink.write(json.dumps({"turn": turn, "phase": phase, "tool": None,
148
149
  "surface": None, "text": reply[:500]}) + "\n")
149
150
  for act in _parse_actions(reply):
150
- if spec.max_turns and turn >= stop_at:
151
- capped = turn >= spec.max_turns
151
+ if plan.enabled and turn >= stop_at:
152
+ capped = turn >= plan.hard_limit
152
153
  break
153
154
  tool = act.get("tool", "")
154
155
  name = "".join(c for c in str(act.get("name", "unnamed"))
@@ -165,8 +166,12 @@ class LLMHarness(MinimalHarness):
165
166
  surface = "tools"
166
167
  else:
167
168
  continue
169
+ phase_counts[phase] += 1
168
170
  sink.write(json.dumps({"turn": turn, "phase": phase, "tool": tool,
169
171
  "surface": surface, "text": name}) + "\n")
172
+ counters = {"writes": writes, "turn_capped": capped,
173
+ "tokens_in": tokens_in, "tokens_out": tokens_out}
174
+ counters.update({f"phase_{phase}_turns": count
175
+ for phase, count in phase_counts.items()})
170
176
  return EpisodeResult(episode=spec.episode, ok=not error, turns=turn, error=error,
171
- counters={"writes": writes, "turn_capped": capped,
172
- "tokens_in": tokens_in, "tokens_out": tokens_out})
177
+ counters=counters)
@@ -19,8 +19,8 @@ from pathlib import Path
19
19
  from typing import Callable, Optional, Sequence, Tuple
20
20
 
21
21
  from proteus.core.adapter import ActionEvent, EpisodeResult, EpisodeSpec, Surface
22
+ from proteus.core.budget import PHASES, budget_plan, phase_prompt
22
23
  from proteus.core.disposition import Disposition
23
- from proteus.core.episode import PHASES
24
24
 
25
25
  # A policy maps (phase, prompt, episode, rng) -> list of (tool, surface, text) actions.
26
26
  Action = Tuple[str, Optional[str], str]
@@ -94,22 +94,23 @@ class MinimalHarness:
94
94
  trace_path.parent.mkdir(parents=True, exist_ok=True)
95
95
  turn = 0
96
96
  writes = {"notes": 0, "tools": 0}
97
+ phase_counts = {phase: 0 for phase in PHASES}
97
98
  capped = False
98
- min_pp = int(getattr(spec, "min_turns_per_phase", 0) or 0)
99
+ plan = budget_plan(spec)
99
100
  with trace_path.open("w", encoding="utf-8") as sink:
100
- for idx, phase in enumerate(PHASES):
101
+ for phase in PHASES:
102
+ if plan.enabled and turn >= plan.hard_limit:
103
+ capped = True
101
104
  if capped:
102
105
  break
103
- # the phase's stop line reserves min_turns_per_phase for every later
104
- # phase; reaching it ends THIS phase, only a spent budget ends the episode
105
- stop_at = (spec.max_turns - min_pp * (len(PHASES) - idx - 1)
106
- if spec.max_turns else 0)
107
- prompt = spec.phase_prompts.get(phase, "")
106
+ stop_at = plan.stop_at(phase, turn)
107
+ prompt = phase_prompt(spec, phase, turn)
108
108
  for tool, surface, text in self.policy(phase, prompt, spec.episode, rng):
109
- if spec.max_turns and turn >= stop_at:
110
- capped = turn >= spec.max_turns
109
+ if plan.enabled and turn >= stop_at:
110
+ capped = turn >= plan.hard_limit
111
111
  break
112
112
  turn += 1
113
+ phase_counts[phase] += 1
113
114
  if tool == "write_note":
114
115
  (harness / "notes" / f"{text}.md").write_text(
115
116
  f"episode {spec.episode}: {text}\n")
@@ -122,8 +123,10 @@ class MinimalHarness:
122
123
  "turn": turn, "phase": phase, "tool": tool,
123
124
  "surface": surface, "text": text,
124
125
  }) + "\n")
125
- return EpisodeResult(episode=spec.episode, ok=True, turns=turn,
126
- counters={"writes": writes, "turn_capped": capped})
126
+ counters = {"writes": writes, "turn_capped": capped}
127
+ counters.update({f"phase_{phase}_turns": count
128
+ for phase, count in phase_counts.items()})
129
+ return EpisodeResult(episode=spec.episode, ok=True, turns=turn, counters=counters)
127
130
 
128
131
  def read_trace(self, root: Path, episode: int) -> Sequence[ActionEvent]:
129
132
  path = root / "traces" / f"ep{episode:03d}.jsonl"
@@ -27,9 +27,9 @@ from typing import Optional, Sequence
27
27
 
28
28
  from proteus.adapters import instructions
29
29
  from proteus.core.adapter import ActionEvent, EpisodeResult, EpisodeSpec, Surface
30
+ from proteus.core.budget import PHASES, budget_plan, phase_prompt
30
31
  from proteus.core.continuity import CONTAINER_ROOT, HandoffStore
31
32
  from proteus.core.disposition import Disposition
32
- from proteus.core.episode import PHASES
33
33
 
34
34
  IMAGE = os.environ.get("PROTEUS_PI_IMAGE", "proteus-env-pi-src:0.84.2")
35
35
  PHASE_TIMEOUT_S = 600
@@ -44,8 +44,8 @@ SOURCE_TAR = "/opt/pi-source.tar"
44
44
  SEED_INSTRUCTIONS = """\
45
45
  # Agent instructions
46
46
 
47
- You maintain and improve your own harness. During a Proteus episode, the harness currently
48
- running is a frozen, read-only snapshot at `/workspace`; the writable candidate that
47
+ You inhabit, inspect, and may change your own harness. During a Proteus episode, the harness
48
+ currently running is a frozen, read-only snapshot at `/workspace`; the writable candidate that
49
49
  persists across phases is `/workspace/candidate`. Make every edit in that candidate. Your
50
50
  candidate surfaces are:
51
51
 
@@ -234,7 +234,9 @@ class PiHarness:
234
234
  mapping: dict[str, list[str]] = {}
235
235
  error = ""
236
236
  capped = False
237
- budget = int(spec.max_turns or 0)
237
+ checkpoint_misses = 0
238
+ plan = budget_plan(spec)
239
+ budget = plan.hard_limit
238
240
  episode_files: set = set()
239
241
  active = Path(spec.active_root) if spec.active_root is not None else harness
240
242
  # Core-managed staged episodes already execute a previously validated snapshot.
@@ -252,18 +254,20 @@ class PiHarness:
252
254
  workspace_mounts = ((str(active), "/workspace", "ro"),
253
255
  (str(harness), "/workspace/candidate")) \
254
256
  if spec.active_root is not None else ((str(harness), "/workspace"),)
255
- min_pp = int(getattr(spec, "min_turns_per_phase", 0) or 0)
256
- for idx, phase in enumerate(PHASES if not error else ()):
257
+ for phase in PHASES if not error else ():
257
258
  # the budget is enforced twice, both harness-agnostically: exactly, between
258
259
  # phases (no new phase once it is spent) and approximately, mid-phase (the
259
260
  # session log is polled and the container stopped at the phase's stop line).
260
- # The stop line reserves min_turns_per_phase for every later phase, so a
261
- # reservation stop moves to the next phase; only a spent budget ends the
261
+ # BudgetPlan preserves the legacy later-phase reserve or applies the explicit
262
+ # act-priority plan. A phase stop moves on; only the hard ceiling caps the
262
263
  # episode.
263
- if budget and self._live_calls(state, episode_files, set()) >= budget:
264
+ used = self._live_calls(state, episode_files, set()) if plan.enabled else 0
265
+ if budget and used >= budget:
264
266
  capped = True
265
267
  break
266
- stop_at = budget - min_pp * (len(PHASES) - idx - 1) if budget else 0
268
+ stop_at = plan.stop_at(phase, used)
269
+ if budget and used >= stop_at:
270
+ continue
267
271
  handoff_start = handoffs.begin(spec.episode, phase)
268
272
  before = self._sessions(state)
269
273
  fired = [False]
@@ -281,13 +285,13 @@ class PiHarness:
281
285
  run_root,
282
286
  ["--provider", self.provider, "--model", spec.model or self.model,
283
287
  "--session-dir", "/state", "--skill", "/workspace/skills",
284
- "-p", spec.phase_prompts.get(phase, phase)],
288
+ "-p", phase_prompt(spec, phase, used)],
285
289
  env={"DEEPSEEK_API_KEY": self.key},
286
290
  timeout_s=self.phase_timeout_s,
287
291
  mounts=workspace_mounts + ((str(state), "/state"),
288
292
  (str(handoffs.root), CONTAINER_ROOT))
289
293
  + self._task_mount(run_root),
290
- stop_check=stop_check if budget else None,
294
+ stop_check=stop_check if plan.enabled else None,
291
295
  )
292
296
  except subprocess.TimeoutExpired:
293
297
  timed_out = True
@@ -300,8 +304,10 @@ class PiHarness:
300
304
  episode_files |= new
301
305
  for session_path in session_paths:
302
306
  phase_events.extend(self._session_trace(session_path, phase))
303
- handoffs.finish(handoff_start, phase_events,
304
- interrupted=timed_out or fired[0])
307
+ handoff = handoffs.finish(handoff_start, phase_events,
308
+ interrupted=timed_out or fired[0])
309
+ if spec.checkpoint_turns and handoff["source"] != "agent":
310
+ checkpoint_misses += 1
305
311
  if timed_out:
306
312
  error = f"phase {phase}: timeout after {self.phase_timeout_s}s"
307
313
  break
@@ -319,10 +325,18 @@ class PiHarness:
319
325
  (run_root / "traces" / f"ep{spec.episode:03d}.json").write_text(
320
326
  json.dumps(mapping, indent=1))
321
327
  trace = self.read_trace(run_root, spec.episode)
328
+ phase_counts = {
329
+ phase: sum(1 for event in trace if event.phase == phase and event.tool)
330
+ for phase in PHASES
331
+ }
332
+ counters = {"phases": len(mapping), "turn_capped": capped,
333
+ "checkpoint_misses": checkpoint_misses}
334
+ counters.update({f"phase_{phase}_turns": count
335
+ for phase, count in phase_counts.items()})
322
336
  return EpisodeResult(
323
337
  episode=spec.episode, ok=not error,
324
338
  turns=sum(1 for e in trace if e.tool), error=error,
325
- counters={"phases": len(mapping), "turn_capped": capped},
339
+ counters=counters,
326
340
  )
327
341
 
328
342
  def _live_calls(self, state: Path, episode_files: set, extra: set) -> int:
@@ -0,0 +1,39 @@
1
+ """Verified, atomic downloads for official benchmark datasets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import tempfile
7
+ from pathlib import Path
8
+ from typing import Callable
9
+ from urllib import request
10
+
11
+
12
+ def download_verified(
13
+ *, name: str, url: str, expected_sha256: str, cache: Path, validate: Callable[[Path], object]
14
+ ) -> Path:
15
+ """Download one official dataset, verify bytes and format, then publish atomically."""
16
+ cache = Path(cache)
17
+ if cache.exists():
18
+ return cache
19
+ cache.parent.mkdir(parents=True, exist_ok=True)
20
+ with request.urlopen(url, timeout=30) as response:
21
+ payload = response.read()
22
+ actual = hashlib.sha256(payload).hexdigest()
23
+ if actual != expected_sha256:
24
+ raise ValueError(
25
+ f"{name} checksum mismatch: expected {expected_sha256}, got {actual}"
26
+ )
27
+ temporary = None
28
+ try:
29
+ with tempfile.NamedTemporaryFile(
30
+ dir=cache.parent, suffix="".join(cache.suffixes), delete=False
31
+ ) as stream:
32
+ stream.write(payload)
33
+ temporary = Path(stream.name)
34
+ validate(temporary)
35
+ temporary.replace(cache)
36
+ finally:
37
+ if temporary is not None:
38
+ temporary.unlink(missing_ok=True)
39
+ return cache