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.
- proteus/__init__.py +2 -0
- proteus/adapters/__init__.py +3 -0
- proteus/adapters/aki.py +231 -0
- proteus/adapters/dsh.py +381 -0
- proteus/adapters/instructions.py +40 -0
- proteus/adapters/llm.py +172 -0
- proteus/adapters/minimal.py +141 -0
- proteus/adapters/pi.py +317 -0
- proteus/bench/__init__.py +23 -0
- proteus/bench/local.py +183 -0
- proteus/bench/polyglot.py +173 -0
- proteus/bench/swe.py +128 -0
- proteus/bench/task.py +111 -0
- proteus/cli.py +515 -0
- proteus/core/__init__.py +40 -0
- proteus/core/adapter.py +150 -0
- proteus/core/disposition.py +90 -0
- proteus/core/episode.py +274 -0
- proteus/core/evaluators.py +113 -0
- proteus/core/goal.py +209 -0
- proteus/core/snapshot.py +110 -0
- proteus/envs.py +141 -0
- proteus/measure/__init__.py +3 -0
- proteus/measure/audit.py +221 -0
- proteus/measure/crystallize.py +76 -0
- proteus/measure/distance.py +172 -0
- proteus/measure/stream.py +153 -0
- proteus/report.py +155 -0
- proteus/sandbox/__init__.py +3 -0
- proteus/sandbox/docker.py +192 -0
- proteus/sweep.py +144 -0
- proteus/testing.py +96 -0
- proteus_evolve-0.1.0.dist-info/METADATA +317 -0
- proteus_evolve-0.1.0.dist-info/RECORD +38 -0
- proteus_evolve-0.1.0.dist-info/WHEEL +5 -0
- proteus_evolve-0.1.0.dist-info/entry_points.txt +2 -0
- proteus_evolve-0.1.0.dist-info/licenses/LICENSE +21 -0
- proteus_evolve-0.1.0.dist-info/top_level.txt +1 -0
proteus/adapters/llm.py
ADDED
|
@@ -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"]
|