Skip to content

Commit 53d6ff3

Browse files
authored
Merge pull request #18 from proteus-evolve/codex/default-episode-epistemic-protocol
Add a universal epistemic protocol to default episodes
2 parents b26271c + 303baa7 commit 53d6ff3

20 files changed

Lines changed: 474 additions & 82 deletions

docs/ADAPTERS.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -107,8 +107,9 @@ salience — and the prompt copy sits outside `F`, so it is neither removable no
107107
`disposition_fingerprint`, which is what the attribution argument rests on.
108108

109109
### 4. Run one episode, emit the trace
110-
`run_episode(spec)` executes the four phases (`spec.phase_prompts` carries goal text and
111-
visible evaluator feedback already merged). `read_trace` returns normalized `ActionEvent`s
110+
`run_episode(spec)` executes the four phases (`spec.phase_prompts` carries the versioned
111+
default epistemic protocol, goal text, and visible evaluator feedback already merged).
112+
`read_trace` returns normalized `ActionEvent`s
112113
— the only behaviour channel Proteus reads; never self-report. An external harness's own
113114
logs are the source of truth: parse them rather than adding measurement instrumentation to
114115
the harness. Evolving its run-local source is the subject's action, not instrumentation.
@@ -283,8 +284,10 @@ natively —
283284
fresh container. This catches stale lockfiles, missing package links, newly added package
284285
outputs omitted from a cache, and plugin-load failures before activation. A failed
285286
candidate cannot activate: the active line rolls back and remains healthy, while the
286-
exact failed tree is restored as episode N+1's writable repair candidate. A passing
287-
candidate first runs as the controlling harness one episode later.
287+
exact failed tree is restored as episode N+1's writable repair candidate. Its redacted
288+
gate failure persists as a framework controller notice across every repair phase and
289+
fallback until the repair passes. A passing candidate first runs as the controlling
290+
harness one episode later.
288291

289292
Verified live for both harnesses: a marker written into the real TypeScript entry point
290293
(`packages/coding-agent/src/cli.ts` for pi, `apps/cli/src/bin.ts` for dsh) appears on the

docs/EPISODE.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,15 @@ fail to build or boot.
7171
`_phase_prompts` starts from the four base texts (observe / propose / act / reflect) and
7272
applies fixed rules:
7373

74+
- the versioned **default epistemic protocol** joins all four phases. External evaluator
75+
feedback is evidence, not automatically a complete definition of success: a narrow
76+
benchmark can fully operationalize its matching goal, while a broad natural-language
77+
goal may require the harness to develop additional tests or evaluators. The harness
78+
judges that sufficiency and is explicitly told not to create evaluators ritualistically;
79+
- runs with no stated goal use an **open-ended base prompt**: observe maps evidence and
80+
uncertainty, propose chooses a self-directed experiment/question/change, and reflect
81+
records effects and surprises without calling them improvements. The harness may later
82+
formulate its own provisional goals and evaluators as evolved state;
7483
- the **goal text** (freeform, decoupled from evaluators) joins **all four phases** because
7584
a fresh observe or propose otherwise investigates and plans against the wrong objective;
7685
- **last episode's OBSERVE-visible evaluator feedback** joins the **observe** phase
@@ -86,6 +95,11 @@ applies fixed rules:
8695

8796
The agent sees these texts and nothing else. It is never told why.
8897

98+
This framework default reaches adapters that execute `spec.phase_prompts`. Aki currently
99+
keeps its registered no-goal prompts inside its own frozen supervisor and is the documented
100+
legacy exception; goal injection and the default epistemic protocol are not yet wired for
101+
that adapter.
102+
89103
### 2. Freeze the active harness — framework
90104

91105
For an adapter declaring `staged_activation=True`, Proteus materializes the last accepted
@@ -205,7 +219,10 @@ candidate. The candidate never controls a model session during either probe.
205219
- **fail**: commit `candidate N [viability failed]`, restore the last valid state, commit
206220
the gapless `episode N [viability failed; rolled back]` checkpoint, record the build
207221
error, and continue. Episode N+1 runs healthy code, receives the failure detail, and
208-
restores the exact failed tree into its separate writable candidate for repair.
222+
restores the exact failed tree into its separate writable candidate for repair. For
223+
framework continuity, that redacted failure is a durable controller notice: it survives
224+
agent-written and fallback handoffs, stays visible to every repair phase, and clears only
225+
after a candidate passes the boundary gate.
209226

210227
The gate runs before arbitrary evaluators, so invalid candidate code is not accidentally
211228
executed by benchmark or custom evaluation either. If an adapter does not implement the
@@ -294,6 +311,7 @@ evolved memory. Raw conversation and process state never survive.
294311

295312
| part | where |
296313
|---|---|
314+
| versioned goal/no-goal defaults and epistemic protocol | `episode_protocol.py` |
297315
| phase-prompt assembly rules (where goal / feedback / disposition inject) | `episode._phase_prompts` |
298316
| phase allocation, hard ceiling, live budget prompt | `budget.py` |
299317
| framework continuity protocol, redaction, phase history, fallback | `continuity.py` |

docs/RECIPES.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,14 @@ A general goal such as “become more robust” needs no benchmark. A specific o
239239
claim does: if the goal says “optimize benchmark X”, attach X and make it `@observe`, or the
240240
agent has no measured feedback for the stated objective.
241241

242+
The default episode protocol does not assume that an attached evaluator is either complete
243+
or incomplete. A benchmark can fully define a goal whose whole meaning is “raise this
244+
benchmark score”; the same benchmark can be only partial evidence for a broader goal. The
245+
harness is asked to judge that relationship and may add its own tests or evaluators when
246+
they reduce uncertainty, but not merely because an external evaluator exists. With neither
247+
a goal nor evaluator, the default remains open-ended: the harness may keep exploring or
248+
formulate its own provisional goal and evaluation machinery as part of its evolved state.
249+
242250
## Your harness
243251

244252
```bash

proteus/adapters/aki.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919
Known limits, stated rather than papered over:
2020
- Aki's phase prompts live inside the harness (`loop.py`), so `spec.phase_prompts` is not
2121
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`.
2324
- Aki couples seeding and disposition install in one `init_run`, so this adapter performs
2425
both inside `install_disposition` (the framework calls `seed` first; it only records state).
2526
"""

proteus/adapters/dsh.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,8 @@
5656
SEED_INSTRUCTIONS = """\
5757
# Agent instructions
5858
59-
You maintain and improve your own harness. During a Proteus episode, the harness currently
60-
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
6161
persists across phases is `/workspace/candidate`. Make every edit in that candidate. Your
6262
candidate surfaces are:
6363

proteus/adapters/llm.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
"""A real-LLM harness on the minimal surface set — any OpenAI-compatible endpoint.
22
33
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.
89
910
Works against any OpenAI-compatible chat endpoint via stdlib HTTP (no SDK dependency).
1011
Defaults target DeepSeek:
@@ -29,8 +30,8 @@
2930
from proteus.core.budget import PHASES, budget_plan, phase_prompt
3031

3132
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:
3435
- notes/ markdown files (observations, plans, knowledge you want to keep)
3536
- tools/ small python files (utilities you may want later)
3637

proteus/adapters/pi.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,8 @@
4444
SEED_INSTRUCTIONS = """\
4545
# Agent instructions
4646
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
4949
persists across phases is `/workspace/candidate`. Make every edit in that candidate. Your
5050
candidate surfaces are:
5151

proteus/core/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from proteus.core.continuity import HandoffStore, PROTOCOL_VERSION
1010
from proteus.core.budget import BUDGET_PROTOCOL_VERSION, BudgetPlan, budget_plan
1111
from proteus.core.episode import RunConfig, RunResult, run
12+
from proteus.core.episode_protocol import DEFAULT_EPISODE_PROTOCOL_VERSION
1213
from proteus.core.goal import (
1314
EvalResult,
1415
Evaluator,
@@ -25,6 +26,7 @@
2526
"BUDGET_PROTOCOL_VERSION",
2627
"BudgetPlan",
2728
"Disposition",
29+
"DEFAULT_EPISODE_PROTOCOL_VERSION",
2830
"EpisodeResult",
2931
"EpisodeSpec",
3032
"EvalResult",

proteus/core/adapter.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,9 @@ class EpisodeSpec:
6969
root: Path # the run root; harness working tree lives at root/harness
7070
episode: int # 1-based
7171
model: str # model id the harness should use
72-
phase_prompts: Mapping[str, str] # observe/propose/act/reflect texts (goal + evaluator
73-
# feedback already merged in by the framework)
72+
phase_prompts: Mapping[str, str] # observe/propose/act/reflect texts (default episode
73+
# protocol, goal, and visible evaluator feedback
74+
# already merged in by the framework)
7475
max_turns: int = 100
7576
min_turns_per_phase: int = 0
7677
"""Reserve at least this many turns for each phase. While phase i runs, its stop

proteus/core/continuity.py

Lines changed: 96 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222

2323
from proteus.core.adapter import ActionEvent
2424

25-
PROTOCOL_VERSION = 1
25+
PROTOCOL_VERSION = 2
2626
MODES = frozenset({"native", "framework", "none"})
2727
# Containerized coding harnesses commonly restrict writes to /workspace. The host source
2828
# is still `<run>/harness`, while adapters bind this external directory over the nested
@@ -31,6 +31,9 @@
3131
CONTAINER_HANDOFF = f"{CONTAINER_ROOT}/handoff.md"
3232
MAX_CONTENT_CHARS = 12_000
3333
MAX_PRIOR_CHARS = 6_000
34+
MAX_CONTROLLER_NOTICE_CHARS = 3_000
35+
CONTROLLER_NOTICE_START = "<!-- proteus-controller-notice:start -->"
36+
CONTROLLER_NOTICE_END = "<!-- proteus-controller-notice:end -->"
3437

3538
_SECRET_PATTERNS = (
3639
re.compile(r"\bsk-[A-Za-z0-9_-]{12,}\b"),
@@ -50,10 +53,15 @@ def validate_mode(mode: str) -> str:
5053
return mode
5154

5255

53-
def framework_prompt(phase: str) -> str:
56+
def framework_prompt(phase: str, *, goal_present: bool = True) -> str:
5457
"""The portable protocol text appended to a framework-continuity phase prompt."""
58+
observe_action = (
59+
"Record objective-relevant findings and evidence for propose."
60+
if goal_present else
61+
"Record findings, evidence, and uncertainties for propose."
62+
)
5563
action = {
56-
"observe": "Record objective-relevant findings and evidence for propose.",
64+
"observe": observe_action,
5765
"propose": "Replace it with one scoped file-and-test plan for act.",
5866
"act": "Replace it with edits attempted, files changed, and verification still needed.",
5967
"reflect": "Replace it with validation results, unresolved risks, and the next step.",
@@ -83,6 +91,39 @@ def _clip(text: str, limit: int = MAX_CONTENT_CHARS) -> str:
8391
return "[earlier handoff content omitted]\n\n" + text[-limit:]
8492

8593

94+
def _strip_controller_notice(text: str) -> str:
95+
"""Remove framework-owned notices before replacing or clearing one."""
96+
pattern = re.compile(
97+
rf"{re.escape(CONTROLLER_NOTICE_START)}.*?"
98+
rf"{re.escape(CONTROLLER_NOTICE_END)}",
99+
re.DOTALL,
100+
)
101+
return pattern.sub("", text).strip()
102+
103+
104+
def _with_controller_notice(text: str, notice: str) -> str:
105+
"""Compose one durable controller notice with an agent/fallback handoff.
106+
107+
The notice is kept in a separately owned file, so an agent that replaces
108+
``handoff.md`` cannot accidentally discard a still-active boundary failure. Markers
109+
make repeated phase transitions idempotent and let a successful repair remove stale
110+
notices from the live handoff without rewriting archived history.
111+
"""
112+
base = _strip_controller_notice(text)
113+
clean_notice = _clip(notice, MAX_CONTROLLER_NOTICE_CHARS) if notice.strip() else ""
114+
if not clean_notice:
115+
return _clip(base)
116+
block = (
117+
f"{CONTROLLER_NOTICE_START}\n"
118+
"# Proteus controller notice\n\n"
119+
f"{clean_notice}\n"
120+
f"{CONTROLLER_NOTICE_END}"
121+
)
122+
remaining = max(1_000, MAX_CONTENT_CHARS - len(block) - 2)
123+
clipped_base = _clip(base, remaining) if base else ""
124+
return f"{block}\n\n{clipped_base}" if clipped_base else block
125+
126+
86127
def _event_detail(event: ActionEvent) -> str:
87128
preferred = ("file_path", "path", "pattern", "query", "command")
88129
detail = next((str(event.params[k]) for k in preferred if event.params.get(k)), "")
@@ -124,18 +165,58 @@ def __init__(self, run_root: Path):
124165
self.history = self.root / "handoffs"
125166
self.current = self.root / "handoff.md"
126167
self.latest = self.root / "latest.md"
168+
self.controller_notice = self.root / "controller-notice.md"
127169

128170
def initialise(self) -> None:
129171
self.history.mkdir(parents=True, exist_ok=True)
130172
meta = self.root / "continuity.json"
131-
if not meta.exists():
132-
self._atomic_text(meta, json.dumps({
133-
"protocol": "proteus-phase-continuity",
134-
"version": PROTOCOL_VERSION,
135-
"mode": "framework",
136-
"container_handoff": CONTAINER_HANDOFF,
137-
"persists_raw_reasoning": False,
138-
}, indent=2) + "\n")
173+
desired = {
174+
"protocol": "proteus-phase-continuity",
175+
"version": PROTOCOL_VERSION,
176+
"mode": "framework",
177+
"container_handoff": CONTAINER_HANDOFF,
178+
"persists_raw_reasoning": False,
179+
"persistent_controller_notices": True,
180+
}
181+
try:
182+
current = json.loads(meta.read_text(encoding="utf-8"))
183+
except (OSError, json.JSONDecodeError):
184+
current = None
185+
if current != desired:
186+
self._atomic_text(meta, json.dumps(desired, indent=2) + "\n")
187+
188+
def set_controller_notice(self, notice: str) -> None:
189+
"""Persist a redacted framework fact across phases until explicitly cleared."""
190+
self.initialise()
191+
clean = _clip(notice, MAX_CONTROLLER_NOTICE_CHARS)
192+
if not clean:
193+
self.clear_controller_notice()
194+
return
195+
self._atomic_text(self.controller_notice, clean + "\n")
196+
self._sync_live_notice(clean)
197+
198+
def clear_controller_notice(self) -> None:
199+
"""Clear a resolved notice from live continuity while preserving history."""
200+
self.initialise()
201+
self.controller_notice.unlink(missing_ok=True)
202+
self._sync_live_notice("")
203+
204+
def _read_controller_notice(self) -> str:
205+
try:
206+
return _clip(
207+
self.controller_notice.read_text(encoding="utf-8"),
208+
MAX_CONTROLLER_NOTICE_CHARS,
209+
)
210+
except OSError:
211+
return ""
212+
213+
def _sync_live_notice(self, notice: str) -> None:
214+
for path in (self.latest, self.current):
215+
try:
216+
content = path.read_text(encoding="utf-8")
217+
except OSError:
218+
continue
219+
self._atomic_text(path, _with_controller_notice(content, notice) + "\n")
139220

140221
def begin(self, episode: int, phase: str) -> HandoffStart:
141222
"""Expose the latest archived handoff and return a modification baseline."""
@@ -147,7 +228,7 @@ def begin(self, episode: int, phase: str) -> HandoffStart:
147228
"No prior phase has run. Inspect the current harness and write the first "
148229
"handoff before this phase ends.\n"
149230
)
150-
previous = _clip(previous)
231+
previous = _with_controller_notice(previous, self._read_controller_notice())
151232
self._atomic_text(self.current, previous + ("\n" if previous else ""))
152233
digest = hashlib.sha256(self.current.read_bytes()).hexdigest()
153234
return HandoffStart(episode, phase, previous, digest)
@@ -189,6 +270,8 @@ def finish(self, start: HandoffStart, events: Sequence[ActionEvent] = (),
189270
content = _clip(current) if explicit else fallback_handoff(
190271
start.previous, events, interrupted
191272
)
273+
notice = self._read_controller_notice()
274+
content = _with_controller_notice(content, notice)
192275
calls = [_event_detail(event) for event in events if event.tool][-30:]
193276
phase_dir = self.history / f"ep{start.episode:03d}"
194277
phase_dir.mkdir(parents=True, exist_ok=True)
@@ -203,6 +286,7 @@ def finish(self, start: HandoffStart, events: Sequence[ActionEvent] = (),
203286
"attempt": attempt,
204287
"source": "agent" if explicit else "framework-fallback",
205288
"interrupted": bool(interrupted),
289+
"controller_notice": bool(notice),
206290
"content": content,
207291
"tool_calls": calls,
208292
}

0 commit comments

Comments
 (0)