proteus-evolve 0.1.0__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.
@@ -0,0 +1,172 @@
1
+ """A real-LLM harness on the minimal surface set — any OpenAI-compatible endpoint.
2
+
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.
8
+
9
+ Works against any OpenAI-compatible chat endpoint via stdlib HTTP (no SDK dependency).
10
+ Defaults target DeepSeek:
11
+
12
+ export DEEPSEEK_KEY=... # or PROTEUS_LLM_KEY / OPENAI_API_KEY
13
+ proteus run --harness llm --model deepseek-chat \
14
+ --arm neutral --arm review:notes --seeds 2 --episodes 5 --out runs/llm-demo
15
+
16
+ PROTEUS_LLM_BASE_URL endpoint base (default https://api.deepseek.com)
17
+ PROTEUS_LLM_MODEL fallback model when the run does not name one
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import os
24
+ import urllib.request
25
+ from pathlib import Path
26
+
27
+ from proteus.adapters.minimal import MinimalHarness
28
+ from proteus.core.adapter import EpisodeResult, EpisodeSpec
29
+ from proteus.core.episode import PHASES
30
+
31
+ 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:
34
+ - notes/ markdown files (observations, plans, knowledge you want to keep)
35
+ - tools/ small python files (utilities you may want later)
36
+
37
+ Each episode has four phases; you are in one phase at a time. Respond ONLY with a JSON
38
+ array of actions (possibly empty). Allowed actions:
39
+ {"tool": "write_note", "name": "<filename-no-ext>", "text": "<markdown body>"}
40
+ {"tool": "write_tool", "name": "<filename-no-ext>", "text": "<python source>"}
41
+ Nothing outside the JSON array. At most 4 actions per phase."""
42
+
43
+ MAX_ACTIONS_PER_PHASE = 4
44
+ STATE_CHARS = 4000
45
+
46
+
47
+ def _render_state(harness: Path) -> str:
48
+ lines = []
49
+ for sub in ("notes", "tools"):
50
+ entries = sorted((harness / sub).glob("*")) if (harness / sub).exists() else []
51
+ lines.append(f"{sub}/ ({len(entries)} files)")
52
+ for p in entries:
53
+ head = p.read_text(encoding="utf-8", errors="replace").splitlines()
54
+ first = head[0][:80] if head else ""
55
+ lines.append(f" {p.name}: {first}")
56
+ text = "\n".join(lines)
57
+ return text[:STATE_CHARS]
58
+
59
+
60
+ def _chat(base_url: str, key: str, model: str, messages: list[dict]) -> str:
61
+ req = urllib.request.Request(
62
+ f"{base_url.rstrip('/')}/chat/completions",
63
+ data=json.dumps({"model": model, "messages": messages,
64
+ "temperature": 0.7, "max_tokens": 1200}).encode(),
65
+ headers={"Content-Type": "application/json", "Authorization": f"Bearer {key}"},
66
+ method="POST",
67
+ )
68
+ with urllib.request.urlopen(req, timeout=180) as resp:
69
+ body = json.loads(resp.read().decode())
70
+ usage = body.get("usage") or {}
71
+ return body["choices"][0]["message"]["content"], usage
72
+
73
+
74
+ def _parse_actions(reply: str) -> list[dict]:
75
+ """Extract the first JSON array from the reply; malformed output means no actions."""
76
+ start = reply.find("[")
77
+ if start < 0:
78
+ return []
79
+ depth = 0
80
+ for i, ch in enumerate(reply[start:], start):
81
+ if ch == "[":
82
+ depth += 1
83
+ elif ch == "]":
84
+ depth -= 1
85
+ if depth == 0:
86
+ try:
87
+ parsed = json.loads(reply[start:i + 1])
88
+ except json.JSONDecodeError:
89
+ return []
90
+ return [a for a in parsed if isinstance(a, dict)][:MAX_ACTIONS_PER_PHASE]
91
+ return []
92
+
93
+
94
+ class LLMHarness(MinimalHarness):
95
+ """`minimal`'s surfaces and trace format, driven by a live model."""
96
+
97
+ name = "llm"
98
+
99
+ def __init__(self, model: str | None = None, base_url: str | None = None,
100
+ key: str | None = None) -> None:
101
+ super().__init__(policy=None) # the policy hook is unused; phases call the model
102
+ self.base_url = base_url or os.environ.get(
103
+ "PROTEUS_LLM_BASE_URL", "https://api.deepseek.com")
104
+ self.model = model or os.environ.get("PROTEUS_LLM_MODEL", "deepseek-chat")
105
+ self.key = key or (os.environ.get("PROTEUS_LLM_KEY")
106
+ or os.environ.get("DEEPSEEK_KEY")
107
+ or os.environ.get("OPENAI_API_KEY", ""))
108
+
109
+ def run_episode(self, spec: EpisodeSpec) -> EpisodeResult:
110
+ if not self.key:
111
+ return EpisodeResult(episode=spec.episode, ok=False, turns=0,
112
+ error="no API key: set DEEPSEEK_KEY / PROTEUS_LLM_KEY")
113
+ model = spec.model or self.model
114
+ harness = spec.root / "harness"
115
+ trace_path = spec.root / "traces" / f"ep{spec.episode:03d}.jsonl"
116
+ trace_path.parent.mkdir(parents=True, exist_ok=True)
117
+ turn = 0
118
+ writes = {"notes": 0, "tools": 0}
119
+ tokens_in = tokens_out = 0
120
+ error = ""
121
+ capped = False
122
+ min_pp = int(getattr(spec, "min_turns_per_phase", 0) or 0)
123
+ 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:
129
+ capped = True
130
+ if capped:
131
+ 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, "")
135
+ user = (f"Episode {spec.episode}, phase: {phase}.\n\n"
136
+ f"{prompt}\n\nCurrent harness state:\n{_render_state(harness)}")
137
+ try:
138
+ reply, usage = _chat(self.base_url, self.key, model,
139
+ [{"role": "system", "content": SYSTEM},
140
+ {"role": "user", "content": user}])
141
+ except Exception as exc: # noqa: BLE001 - recorded, episode marked failed
142
+ error = f"{type(exc).__name__}: {exc}"
143
+ break
144
+ tokens_in += int(usage.get("prompt_tokens") or 0)
145
+ tokens_out += int(usage.get("completion_tokens") or 0)
146
+ turn += 1
147
+ sink.write(json.dumps({"turn": turn, "phase": phase, "tool": None,
148
+ "surface": None, "text": reply[:500]}) + "\n")
149
+ for act in _parse_actions(reply):
150
+ if spec.max_turns and turn >= stop_at:
151
+ capped = turn >= spec.max_turns
152
+ break
153
+ tool = act.get("tool", "")
154
+ name = "".join(c for c in str(act.get("name", "unnamed"))
155
+ if c.isalnum() or c in "-_")[:60] or "unnamed"
156
+ text = str(act.get("text", ""))
157
+ turn += 1
158
+ if tool == "write_note":
159
+ (harness / "notes" / f"{name}.md").write_text(text, encoding="utf-8")
160
+ writes["notes"] += 1
161
+ surface = "notes"
162
+ elif tool == "write_tool":
163
+ (harness / "tools" / f"{name}.py").write_text(text, encoding="utf-8")
164
+ writes["tools"] += 1
165
+ surface = "tools"
166
+ else:
167
+ continue
168
+ sink.write(json.dumps({"turn": turn, "phase": phase, "tool": tool,
169
+ "surface": surface, "text": name}) + "\n")
170
+ 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})
@@ -0,0 +1,141 @@
1
+ """A minimal, dependency-free reference harness — proof that Proteus is harness-agnostic.
2
+
3
+ This harness is deliberately tiny: two surfaces (`notes`, `tools`), a four-phase loop, and
4
+ a pluggable policy. It exists to (a) let the whole framework run end-to-end with no API key
5
+ and no Docker, so tests and demos work offline, and (b) demonstrate that a harness with
6
+ *its own* surface set plugs in with no change to the framework or the measurement layer.
7
+
8
+ The default `MockPolicy` is deterministic and biases its writes toward whichever surface the
9
+ installed disposition names, so even offline a `review_notes` run diverges from `neutral` in
10
+ a way the distance instrument can measure. `proteus.adapters.llm.LLMHarness` is the same
11
+ harness driven by a live model instead of the mock policy.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import random
18
+ from pathlib import Path
19
+ from typing import Callable, Optional, Sequence, Tuple
20
+
21
+ from proteus.core.adapter import ActionEvent, EpisodeResult, EpisodeSpec, Surface
22
+ from proteus.core.disposition import Disposition
23
+ from proteus.core.episode import PHASES
24
+
25
+ # A policy maps (phase, prompt, episode, rng) -> list of (tool, surface, text) actions.
26
+ Action = Tuple[str, Optional[str], str]
27
+ Policy = Callable[[str, str, int, random.Random], list[Action]]
28
+
29
+
30
+ def mock_policy(phase: str, prompt: str, episode: int, rng: random.Random) -> list[Action]:
31
+ """Deterministic offline policy. Writes to the surface the prompt leans on.
32
+
33
+ It reads the disposition's leaning straight from the phase prompt text (the prompt is
34
+ all the real agent would see too), so a notes-disposition produces notes and a
35
+ tools-disposition produces tools — a measurable, reproducible effect without any model.
36
+ """
37
+ low = prompt.lower()
38
+ leans_notes = "notes" in low
39
+ leans_tools = "tools" in low
40
+ acts: list[Action] = []
41
+ if phase == "observe":
42
+ acts.append(("read_state", None, "surveyed the harness"))
43
+ elif phase == "act":
44
+ # baseline: occasionally write a note; disposition tilts the surface + rate.
45
+ if leans_tools or (not leans_notes and rng.random() < 0.3):
46
+ acts.append(("write_tool", "tools", f"tool_e{episode}"))
47
+ if leans_notes or rng.random() < 0.5:
48
+ acts.append(("write_note", "notes", f"note_e{episode}_{rng.randint(0,3)}"))
49
+ elif phase == "reflect" and leans_notes and rng.random() < 0.6:
50
+ acts.append(("write_note", "notes", f"reflection_e{episode}"))
51
+ return acts
52
+
53
+
54
+ class MinimalHarness:
55
+ """A `HarnessAdapter` for the minimal reference harness."""
56
+
57
+ name = "minimal"
58
+ disposition_in_files = False # the perturbation reaches this harness via prompts
59
+
60
+ def __init__(self, policy: Policy = mock_policy) -> None:
61
+ self.policy = policy
62
+
63
+ def surfaces(self) -> Sequence[Surface]:
64
+ return [
65
+ Surface("notes", "notes", unit="file", write_tools=frozenset({"write_note"})),
66
+ Surface("tools", "tools", unit="file", write_tools=frozenset({"write_tool"}),
67
+ is_code=True),
68
+ ]
69
+
70
+ def required_edit_tools(self) -> frozenset[str]:
71
+ return frozenset({"write_note", "write_tool"})
72
+
73
+ def seed(self, harness_root: Path, rng_seed: int = 0) -> None:
74
+ for sub in ("notes", "tools"):
75
+ (harness_root / sub).mkdir(parents=True, exist_ok=True)
76
+ (harness_root / "STATE.md").write_text("# minimal harness\nepisode 0\n")
77
+
78
+ def install_disposition(self, harness_root: Path, disposition: Disposition) -> None:
79
+ (harness_root / ".disposition.json").write_text(json.dumps({
80
+ "label": disposition.label,
81
+ "config": dict(disposition.config),
82
+ "empty": disposition.is_empty,
83
+ }))
84
+
85
+ def run_episode(self, spec: EpisodeSpec) -> EpisodeResult:
86
+ harness = spec.root / "harness"
87
+ for sub in ("notes", "tools"):
88
+ # git snapshots do not track empty directories, so a restore after a rejected
89
+ # episode may drop them; an episode must tolerate waking up without them
90
+ (harness / sub).mkdir(exist_ok=True)
91
+ rng = random.Random(f"{spec.seed}:{spec.root.name}:{spec.episode}")
92
+ trace_path = spec.root / "traces" / f"ep{spec.episode:03d}.jsonl"
93
+ trace_path.parent.mkdir(parents=True, exist_ok=True)
94
+ turn = 0
95
+ writes = {"notes": 0, "tools": 0}
96
+ capped = False
97
+ min_pp = int(getattr(spec, "min_turns_per_phase", 0) or 0)
98
+ with trace_path.open("w", encoding="utf-8") as sink:
99
+ for idx, phase in enumerate(PHASES):
100
+ if capped:
101
+ break
102
+ # the phase's stop line reserves min_turns_per_phase for every later
103
+ # phase; reaching it ends THIS phase, only a spent budget ends the episode
104
+ stop_at = (spec.max_turns - min_pp * (len(PHASES) - idx - 1)
105
+ if spec.max_turns else 0)
106
+ prompt = spec.phase_prompts.get(phase, "")
107
+ for tool, surface, text in self.policy(phase, prompt, spec.episode, rng):
108
+ if spec.max_turns and turn >= stop_at:
109
+ capped = turn >= spec.max_turns
110
+ break
111
+ turn += 1
112
+ if tool == "write_note":
113
+ (harness / "notes" / f"{text}.md").write_text(
114
+ f"episode {spec.episode}: {text}\n")
115
+ writes["notes"] += 1
116
+ elif tool == "write_tool":
117
+ (harness / "tools" / f"{text}.py").write_text(
118
+ f"# episode {spec.episode}\ndef {text}():\n return {spec.episode}\n")
119
+ writes["tools"] += 1
120
+ sink.write(json.dumps({
121
+ "turn": turn, "phase": phase, "tool": tool,
122
+ "surface": surface, "text": text,
123
+ }) + "\n")
124
+ return EpisodeResult(episode=spec.episode, ok=True, turns=turn,
125
+ counters={"writes": writes, "turn_capped": capped})
126
+
127
+ def read_trace(self, root: Path, episode: int) -> Sequence[ActionEvent]:
128
+ path = root / "traces" / f"ep{episode:03d}.jsonl"
129
+ out: list[ActionEvent] = []
130
+ if not path.exists():
131
+ return out
132
+ for line in path.read_text(encoding="utf-8").splitlines():
133
+ d = json.loads(line)
134
+ out.append(ActionEvent(turn=d["turn"], phase=d["phase"], tool=d.get("tool"),
135
+ surface=d.get("surface"), text=d.get("text", "")))
136
+ return out
137
+
138
+ def disposition_fingerprint(self, harness_root: Path) -> str:
139
+ import hashlib
140
+ p = harness_root / ".disposition.json"
141
+ return hashlib.sha256(p.read_bytes()).hexdigest() if p.exists() else ""
proteus/adapters/pi.py ADDED
@@ -0,0 +1,317 @@
1
+ """Pi adapter — Mario Zechner's minimal coding harness (pi-mono) in a prepared container.
2
+
3
+ Pi (github.com/badlogic/pi-mono, npm `@earendil-works/pi-coding-agent`) is the minimal
4
+ end of the harness spectrum: four built-in tools (read/write/edit/bash), native
5
+ `AGENTS.md` context loading, native skills. That makes it the cleanest demonstration that
6
+ the adapter contract covers real third-party harnesses of any size — the whole adapter is
7
+ symmetric with `dsh.py` and shares its disposition carrier.
8
+
9
+ Per phase, one non-interactive pi session (`-p`) runs in the source-mode image from
10
+ `environments/pi-src/`, with the workspace at `/workspace`, session/build state at
11
+ `/state`, and an optional benchmark workspace at `/workspace/task`. Each run evolves the
12
+ real Pi TypeScript source under `harness/src/`; the image exact-syncs and rebuilds it before
13
+ boot. The trace is parsed from pi's session JSONL (v3: `message` events whose content blocks
14
+ carry `toolCall` entries). Skills are loaded explicitly with `--skill /workspace/skills`,
15
+ so the skills surface is version-robust rather than relying on discovery conventions.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import os
22
+ import subprocess
23
+ from pathlib import Path
24
+ from typing import Optional, Sequence
25
+
26
+ from proteus.adapters import instructions
27
+ from proteus.core.adapter import ActionEvent, EpisodeResult, EpisodeSpec, Surface
28
+ from proteus.core.disposition import Disposition
29
+ from proteus.core.episode import PHASES
30
+
31
+ IMAGE = os.environ.get("PROTEUS_PI_IMAGE", "proteus-env-pi-src:0.84.2")
32
+ PHASE_TIMEOUT_S = 600
33
+ #: The editable self is pi's real TypeScript source (~1,100 .ts files, the pi-mono
34
+ #: checkout the image was built from), not the compiled dist. The image bakes the source
35
+ #: at /opt/src with its dependencies and a pristine build; its entrypoint syncs
36
+ #: /workspace/src over the baked tree at boot, rebuilds with the project's own toolchain
37
+ #: when the source hash changes (cached on /state), and execs the built CLI. See
38
+ #: environments/pi-src/.
39
+ SOURCE_TAR = "/opt/pi-source.tar"
40
+
41
+ SEED_INSTRUCTIONS = """\
42
+ # Agent instructions
43
+
44
+ You maintain and improve your own harness — the files in this workspace, which persist
45
+ across sessions. Your surfaces:
46
+
47
+ - `AGENTS.md` — these instructions (you may refine them)
48
+ - `notes/` — markdown knowledge you want future sessions to have
49
+ - `tools/` — small python utilities you may want later
50
+ - `skills/` — pi skill files (loaded automatically next session)
51
+ - `src/` — your own program: the real TypeScript source of the agent that runs you.
52
+ Edit it and the next session is rebuilt from your edit and runs it. If your edit
53
+ does not compile, the run ends.
54
+
55
+ Each session is one phase of an episode; only these files carry over.
56
+ """
57
+
58
+
59
+ class PiHarness:
60
+ """`HarnessAdapter` for pi-coding-agent's non-interactive mode, containerized."""
61
+
62
+ name = "pi"
63
+ disposition_in_files = True # carried by AGENTS.md; keep it out of the phase prompts
64
+
65
+ SURFACES = (
66
+ Surface("instructions", "AGENTS.md", unit="file", free_named=False),
67
+ Surface("skills", "skills", unit="file", write_tools=frozenset({"write", "edit"})),
68
+ Surface("notes", "notes", unit="file", write_tools=frozenset({"write", "edit"})),
69
+ Surface("tools", "tools", unit="file", write_tools=frozenset({"write", "edit"}),
70
+ is_code=True),
71
+ # the harness's own program, shadow-mounted over the install path at boot — see
72
+ # DshHarness: same arrangement, same reasons
73
+ Surface("loop", "src", unit="file", is_code=True, free_named=False,
74
+ write_tools=frozenset({"write", "edit"})),
75
+ )
76
+
77
+ def __init__(self, image: str = IMAGE, network: str = "host",
78
+ provider: str = "deepseek", model: str = "deepseek-v4-flash",
79
+ key: str | None = None, sandbox=None,
80
+ phase_timeout_s: int = PHASE_TIMEOUT_S) -> None:
81
+ self.image = image
82
+ self.network = network
83
+ self.provider = provider
84
+ self.model = model
85
+ self.phase_timeout_s = phase_timeout_s
86
+ # per-instance key injection first (multi-tenant runs must not share env)
87
+ self.key = key or os.environ.get("DEEPSEEK_API_KEY") or os.environ.get("DEEPSEEK_KEY", "")
88
+ from proteus.sandbox import DockerSandbox, SandboxConfig
89
+ # containers write into bind mounts; on Linux a root-in-container write leaves
90
+ # root-owned files the host user can neither snapshot-clean nor edit, so the
91
+ # container runs as the host user (the images chmod their /opt/src for this)
92
+ host_user = f"{os.getuid()}:{os.getgid()}" if hasattr(os, "getuid") else ""
93
+ # a caller may pass its own environment (see DshHarness.__init__)
94
+ self.sandbox = sandbox or DockerSandbox(SandboxConfig(
95
+ network=network, image=image, env_passthrough=("DEEPSEEK_API_KEY",),
96
+ user=host_user,
97
+ ))
98
+
99
+ def surfaces(self) -> Sequence[Surface]:
100
+ return self.SURFACES
101
+
102
+ def required_edit_tools(self) -> frozenset[str]:
103
+ return frozenset({"write", "edit"})
104
+
105
+ def seed(self, harness_root: Path, rng_seed: int = 0) -> None:
106
+ harness_root.mkdir(parents=True, exist_ok=True)
107
+ (harness_root / "AGENTS.md").write_text(SEED_INSTRUCTIONS, encoding="utf-8")
108
+ for sub in ("notes", "tools", "skills"):
109
+ (harness_root / sub).mkdir(exist_ok=True)
110
+ self._extract_self_code(harness_root / "src")
111
+
112
+ def _extract_self_code(self, dest: Path) -> None:
113
+ """Unpack the source the image was built from into `dest` (episode-0 state).
114
+
115
+ The image bakes a source-only tar at build time precisely so this is cheap and
116
+ exact: what the seed gets is byte-for-byte the source of the build it boots."""
117
+ if dest.exists() and any(dest.iterdir()):
118
+ return # resumed root: the seed owns its source already
119
+ dest.mkdir(parents=True, exist_ok=True)
120
+ user = (["--user", f"{os.getuid()}:{os.getgid()}"]
121
+ if hasattr(os, "getuid") else [])
122
+ proc = subprocess.run(
123
+ ["docker", "run", "--rm", "--network", "none", *user,
124
+ "-v", f"{dest}:/proteus-out", "--entrypoint", "sh", self.image,
125
+ "-c", f"tar -xf {SOURCE_TAR} -C /proteus-out --strip-components=1"],
126
+ capture_output=True, text=True, errors="replace", check=False)
127
+ if proc.returncode != 0:
128
+ raise RuntimeError(
129
+ f"could not extract pi source from {self.image}: {proc.stderr[-300:]}")
130
+
131
+ @staticmethod
132
+ def _task_mount(run_root: Path) -> tuple:
133
+ """Bind the run's task workspace (a snapshot-external sibling of the harness)
134
+ into the agent's view, when the run is goal-conditioned."""
135
+ task = run_root / "task"
136
+ return ((str(task), "/workspace/task"),) if task.is_dir() else ()
137
+
138
+ def check_boot(self, harness_root: Path) -> str:
139
+ """Viability gate: sync + rebuild + `--version` through the image's boot wrapper.
140
+
141
+ Because the wrapper rebuilds from /workspace/src, a type error the agent wrote
142
+ into its own source surfaces here as a legible build failure (exit 97 with the
143
+ build log tail), before any API spend."""
144
+ harness = Path(harness_root)
145
+ state = harness.parent / ".pi-state"
146
+ state.mkdir(exist_ok=True)
147
+ proc = self.sandbox.run(
148
+ harness.parent, ["--version"], env={}, timeout_s=300,
149
+ mounts=((str(harness), "/workspace"), (str(state), "/state")))
150
+ if proc.returncode != 0:
151
+ return (f"self-edited source does not boot (exit {proc.returncode}): "
152
+ f"{(proc.stderr or proc.stdout)[-400:]}")
153
+ return ""
154
+
155
+ def install_disposition(self, harness_root: Path, disposition: Disposition) -> None:
156
+ instructions.install_block(harness_root / "AGENTS.md", disposition)
157
+
158
+ def disposition_fingerprint(self, harness_root: Path) -> str:
159
+ return instructions.block_fingerprint(Path(harness_root) / "AGENTS.md")
160
+
161
+ # ------------------------------------------------------------------ episodes
162
+
163
+ @staticmethod
164
+ def _sessions(state: Path) -> set[Path]:
165
+ return set(state.glob("*.jsonl"))
166
+
167
+ def run_episode(self, spec: EpisodeSpec) -> EpisodeResult:
168
+ if not self.key:
169
+ return EpisodeResult(episode=spec.episode, ok=False, turns=0,
170
+ error="no DeepSeek key: set DEEPSEEK_API_KEY")
171
+ run_root = Path(spec.root)
172
+ harness = run_root / "harness"
173
+ state = run_root / ".pi-state"
174
+ state.mkdir(exist_ok=True)
175
+ (run_root / "traces").mkdir(exist_ok=True)
176
+ mapping: dict[str, str] = {}
177
+ error = ""
178
+ capped = False
179
+ budget = int(spec.max_turns or 0)
180
+ episode_files: set = set()
181
+ if (harness / "src").is_dir():
182
+ error = self.check_boot(harness)
183
+ min_pp = int(getattr(spec, "min_turns_per_phase", 0) or 0)
184
+ for idx, phase in enumerate(PHASES if not error else ()):
185
+ # the budget is enforced twice, both harness-agnostically: exactly, between
186
+ # phases (no new phase once it is spent) and approximately, mid-phase (the
187
+ # session log is polled and the container stopped at the phase's stop line).
188
+ # The stop line reserves min_turns_per_phase for every later phase, so a
189
+ # reservation stop moves to the next phase; only a spent budget ends the
190
+ # episode.
191
+ if budget and self._live_calls(state, episode_files, set()) >= budget:
192
+ capped = True
193
+ break
194
+ stop_at = budget - min_pp * (len(PHASES) - idx - 1) if budget else 0
195
+ before = self._sessions(state)
196
+ fired = [False]
197
+
198
+ def stop_check(before=before, fired=fired, stop_at=stop_at):
199
+ if self._live_calls(state, episode_files,
200
+ self._sessions(state) - before) >= stop_at:
201
+ fired[0] = True
202
+ return True
203
+ return False
204
+
205
+ try:
206
+ proc = self.sandbox.run(
207
+ run_root,
208
+ ["--provider", self.provider, "--model", spec.model or self.model,
209
+ "--session-dir", "/state", "--skill", "/workspace/skills",
210
+ "-p", spec.phase_prompts.get(phase, phase)],
211
+ env={"DEEPSEEK_API_KEY": self.key},
212
+ timeout_s=self.phase_timeout_s,
213
+ mounts=((str(harness), "/workspace"), (str(state), "/state"))
214
+ + self._task_mount(run_root),
215
+ stop_check=stop_check if budget else None,
216
+ )
217
+ except subprocess.TimeoutExpired:
218
+ error = f"phase {phase}: timeout after {self.phase_timeout_s}s"
219
+ break
220
+ new = self._sessions(state) - before
221
+ if new:
222
+ mapping[phase] = min(new).name
223
+ episode_files |= new
224
+ if proc.returncode != 0:
225
+ if fired[0]:
226
+ # stopped at the phase's line: continue if it was only the reserve,
227
+ # end the episode only when the whole budget is spent
228
+ if budget and self._live_calls(state, episode_files, set()) >= budget:
229
+ capped = True
230
+ break
231
+ continue
232
+ error = f"phase {phase}: exit {proc.returncode}: {proc.stderr[-400:]}"
233
+ break
234
+ (run_root / "traces" / f"ep{spec.episode:03d}.json").write_text(
235
+ json.dumps(mapping, indent=1))
236
+ trace = self.read_trace(run_root, spec.episode)
237
+ return EpisodeResult(
238
+ episode=spec.episode, ok=not error,
239
+ turns=sum(1 for e in trace if e.tool), error=error,
240
+ counters={"phases": len(mapping), "turn_capped": capped},
241
+ )
242
+
243
+ def _live_calls(self, state: Path, episode_files: set, extra: set) -> int:
244
+ """Tool calls made so far this episode, read live from the session logs.
245
+
246
+ pi's session JSONL is plain text and appended per event, so a mid-phase read
247
+ sees every call already made. Counted by marker substring — over-counting stops
248
+ early, which is the conservative direction for a budget."""
249
+ n = 0
250
+ for f in set(episode_files) | set(extra):
251
+ path = Path(f) if isinstance(f, Path) else state / f
252
+ try:
253
+ n += path.read_text(encoding="utf-8", errors="replace").count('"toolCall"')
254
+ except OSError:
255
+ continue
256
+ return n
257
+
258
+ # ------------------------------------------------------------------ measure path
259
+
260
+ def _surface_for_path(self, file_path: str) -> Optional[str]:
261
+ p = file_path.replace("/workspace/", "")
262
+ if p == "AGENTS.md":
263
+ return "instructions"
264
+ for s in ("skills", "notes", "tools"):
265
+ if p.startswith(f"{s}/"):
266
+ return s
267
+ if p.startswith("src/"):
268
+ return "loop"
269
+ return None
270
+
271
+ def read_trace(self, root: Path, episode: int) -> Sequence[ActionEvent]:
272
+ root = Path(root)
273
+ map_path = root / "traces" / f"ep{episode:03d}.json"
274
+ if not map_path.exists():
275
+ return []
276
+ mapping = json.loads(map_path.read_text())
277
+ state = root / ".pi-state"
278
+ events: list[ActionEvent] = []
279
+ turn = 0
280
+ for phase in PHASES:
281
+ name = mapping.get(phase)
282
+ if not name or not (state / name).exists():
283
+ continue
284
+ for line in (state / name).read_text(encoding="utf-8",
285
+ errors="replace").splitlines():
286
+ try:
287
+ e = json.loads(line)
288
+ except json.JSONDecodeError:
289
+ continue
290
+ if e.get("type") != "message":
291
+ continue
292
+ msg = e.get("message", {})
293
+ if msg.get("role") != "assistant":
294
+ continue
295
+ turn += 1
296
+ for block in msg.get("content", []):
297
+ btype = block.get("type", "")
298
+ if btype in ("toolCall", "tool_call", "toolUse"):
299
+ args = block.get("arguments") or block.get("input") or {}
300
+ if isinstance(args, str):
301
+ try:
302
+ args = json.loads(args)
303
+ except json.JSONDecodeError:
304
+ args = {}
305
+ path = str(args.get("file_path") or args.get("path") or "")
306
+ events.append(ActionEvent(
307
+ turn=turn, phase=phase,
308
+ tool=block.get("name", ""),
309
+ surface=self._surface_for_path(path),
310
+ params={k: str(v)[:200] for k, v in args.items()}, text="",
311
+ ))
312
+ elif btype == "text" and block.get("text"):
313
+ events.append(ActionEvent(
314
+ turn=turn, phase=phase, tool=None, surface=None,
315
+ params={}, text=block["text"][:500],
316
+ ))
317
+ return events
@@ -0,0 +1,23 @@
1
+ """Benchmarks as goals: task packs whose graders are Proteus evaluators."""
2
+ from proteus.bench.task import (
3
+ TASK_SUBDIR,
4
+ BenchTask,
5
+ as_evaluator,
6
+ as_goal,
7
+ seed_task,
8
+ task_root,
9
+ workspace_diff,
10
+ )
11
+
12
+ __all__ = [
13
+ "TASK_SUBDIR",
14
+ "BenchTask",
15
+ "as_evaluator",
16
+ "as_goal",
17
+ "seed_task",
18
+ "task_root",
19
+ "workspace_diff",
20
+ ]
21
+
22
+ from proteus.bench.polyglot import list_tasks, polyglot_task # noqa: E402,F401
23
+ __all__ += ["list_tasks", "polyglot_task"]