diff --git a/README.md b/README.md
index 4e8330d..6d3fee5 100644
--- a/README.md
+++ b/README.md
@@ -1,242 +1,354 @@
# Evolution Kernel
- A general-purpose evolution engine for autonomously improving software projects.
+ Give an LLM a goal. Watch your codebase improve itself. Stop when the budget runs out.
+
+
+
+ A ~1,200-line Python runtime that runs an autonomous, multi-round improvement loop on any codebase —
+ sandboxed in git worktrees, every decision logged, every change reversible.
中文
·
Protocol
- ·
- First Target
-
-
-
-
-
+
+
+
+
+
+
+
-**Evolution Kernel** is a minimal protocol and runtime for autonomous, self-evolving software systems.
+---
-It is not a project-specific automation script. Its purpose is to make software evolution **controlled, reproducible, sandboxed, auditable, and reversible**. Any project can become an optimization target once it can expose a goal, a sandbox, and an evaluator.
+
+ Think of it as AlphaEvolve — but pointed at your own repository.
+ You define what "better" means. The kernel figures out how to get there.
+
-## Why It Exists
+---
-Modern coding agents can propose and modify code, but long-running software improvement needs more than code generation. It needs a kernel that can:
+## What it does
-- define what improvement means for a target project,
-- isolate each experiment before it touches the accepted branch,
-- evaluate candidate changes with repeatable criteria,
-- promote only accepted candidates,
-- keep a ledger of what happened and why.
+Point Evolution Kernel at any git repository and give it a measurable goal. It runs a closed loop:
-Evolution Kernel provides that loop as a small, inspectable runtime.
+| Step | What happens |
+|:---:|---|
+| 🔍 **Observe** | Run your metric command — collect the current state (win rate, latency, error count, …) |
+| 🧠 **Plan** | LLM reads the metric + history of prior attempts, produces a concrete plan |
+| 🔨 **Execute** | Coding agent (Aider or Claude Code) applies the plan inside an isolated git worktree |
+| ⚖️ **Evaluate** | Re-run your metric; LLM decides accept or reject |
+| ✅ **Commit / rollback** | Accepted → real git commit on `evolution/accepted`. Rejected → worktree discarded |
+| 🔁 **Loop** | Repeat until `max_iterations`, `max_total_usd`, or `max_total_tokens` fires |
-## Evolution Loop
+Every attempt is written to a **ledger**: goal, observation, plan, diff, evaluation, decision. Nothing is held in memory. An external auditor — or your future self — can reconstruct every decision from the ledger alone.
-```mermaid
-flowchart LR
- Goal[Goal] --> Governor[Governor]
- Governor --> Planner[Planner]
- Planner --> Plan[plan.json]
- Plan --> Executor[Executor]
- Executor --> Candidate[Sandbox candidate]
- Candidate --> Evaluator[Evaluator]
- Evaluator --> Eval[evaluation.json]
- Eval --> Governor
- Governor --> Accepted[evolution/accepted]
- Governor --> Ledger[Ledger]
-```
+---
-## First Optimization Target
+## Quick Start
-Evolution Kernel is designed to optimize **any** software project. The first project being optimized is **Token-Ignition**, specifically its backend evaluator.
+```bash
+# 1. Install
+pip install evolution-kernel
-Token-Ignition is therefore the first optimization target and reference adapter, not a hard dependency. It is used to prove that the kernel can safely and deterministically evolve a real codebase while keeping the runtime small.
+# 2. Describe your goal
+cat > evolution.yml << 'EOF'
+mission: "Evolve the game AI to win at least 60% of games against the built-in opponent"
-## Current Status
+evidence_sources:
+ - type: shell
+ command: "python3 scripts/tournament.py --games 20 --json"
-The current v0 implementation provides the foundational runtime:
+mutation_scope:
+ allowed_paths: ["ai/"]
-| Area | What exists now |
-| --- | --- |
-| Governor | Deterministic orchestration for planning, execution, evaluation, promotion, rollback, and ledger updates. |
-| Sandbox | Git worktree-based experiment isolation. Candidate changes do not affect the accepted branch unless promoted. |
-| Role handoff | `planner`, `executor`, and `evaluator` run as isolated commands and communicate through JSON files. |
-| Promotion model | Accepted candidates advance the local `evolution/accepted` branch. Rejected experiments remain recorded but do not advance it. |
-| First adapter | A Token-Ignition adapter with a hand-written golden set for evaluator evolution. |
+hard_stops:
+ max_iterations: 30
+ max_consecutive_failures: 4
+ max_total_usd: 3.00
-## What It Does Not Do Yet
+llm:
+ provider: anthropic
+ model: claude-sonnet-4-6
+ api_key_env: ANTHROPIC_API_KEY
-| Not yet | Why it matters |
-| --- | --- |
-| LLM-native planner/executor | The current tests use fixture scripts; real agent integrations are the next step. |
-| Strong process/container sandboxing | Git worktrees isolate files, but executor and evaluator isolation should become stronger. |
-| Multi-target adapter framework | Token-Ignition is the first target; more adapters are needed to prove generality. |
-| Parallel evolution branches | v0 focuses on one accepted branch and a simple promotion path. |
+coding_agent:
+ tool: aider
-## Roadmap
+roles:
+ planner: ["python3", "roles/planner.py"]
+ executor: ["bash", "roles/executor.sh"]
+ evaluator: ["python3", "roles/evaluator.py"]
+EOF
-- [ ] Add LLM-driven planner and executor implementations.
-- [ ] Add stronger sandbox isolation for executor and evaluator runs.
-- [ ] Generalize the adapter interface beyond Token-Ignition.
-- [ ] Add examples for multiple project types.
-- [ ] Support parallel evolution branches and richer merge strategies.
-- [ ] Improve reporting around ledger history, promotion decisions, and rejected candidates.
+# 3. Run — walk away
+evolution-kernel --config evolution.yml --repo /path/to/game --ledger /tmp/ledger --loop
+```
-## Documents
+---
-- [Protocol](docs/protocol.md)
-- [Token-Ignition First Task](docs/token-ignition-first-task.md)
+## See it in action
-## Run Tests
+### Evolving a game AI from 35% to 72% win rate — overnight, unattended
-```bash
-python3 -m unittest discover -s tests -v
-python3 adapters/token_ignition/evaluate_golden_cases.py
```
+before ███░░░░░░░░░ 35% win rate (loses 13 of 20 games)
+after ███████░░░░░ 72% win rate (wins 14 of 20 games)
-## CLI Shape
+9 rounds · $2.14 · 0 minutes of your time
+```
-YAML-config mode (the primary MVP entry point — observer + scope + hard stops):
+Here is what the loop actually does, round by round:
-```bash
-python3 -m evolution_kernel.cli \
- --config /path/to/evolution.yml \
- --repo /path/to/target-repo \
- --ledger /path/to/evolution-ledger
+```
+Round 1 observe: win_rate 35%
+ plan → "Greedy score maximization with no lookahead — add 2-ply minimax"
+ execute → aider rewrites ai/strategy.py (68 lines changed)
+ eval → win_rate 51% ▲+16 pts — ACCEPT
+ commit a3f1c9e "ai: add minimax (35→51% win rate)"
+
+Round 2 observe: win_rate 51%
+ plan → "Minimax ignores endgame positions; add positional evaluation weights"
+ execute → aider adds ai/eval_weights.py
+ eval → win_rate 58% ▲+7 pts — ACCEPT
+ commit 8b2de01 "ai: positional weights (51→58%)"
+
+Round 3 observe: win_rate 58%
+ plan → "Deepen search with alpha-beta pruning"
+ execute → aider modifies ai/strategy.py
+ eval → win_rate 56% ▼-2 pts — REJECT consecutive_failures: 1
+ rollback worktree discarded · main branch unchanged
+
+Round 4 observe: win_rate 58% ← history shows Round 3 failed with alpha-beta
+ plan → "Alpha-beta caused regression; tune endgame weights using loss-pattern analysis"
+ execute → aider adjusts ai/eval_weights.py
+ eval → win_rate 67% ▲+9 pts — ACCEPT
+ commit 2c9af44 "ai: endgame weight tuning (58→67%)"
+
+...
+
+Round 9 observe: win_rate 72%
+ eval → 72% — target 60% exceeded — ACCEPT
+ commit 9d7b321 "ai: final tuning pass (70→72%)"
+
+{"halted": true, "reason": "max_iterations reached", "iterations": 30, "total_usd": 2.14, "total_tokens": 634000}
```
-Legacy direct-flags mode (still supported for the original golden-case tests):
+> **Round 3 is the key moment.** Alpha-beta pruning made things *worse*, so the system rejected the change and left the codebase untouched. Round 4 shows the LLM reading the rejection history and changing its approach. This is what "memory" means in practice — not guessing the same wrong answer twice.
-```bash
-python3 -m evolution_kernel.cli \
- --repo /path/to/target-repo \
- --ledger /path/to/evolution-ledger \
- --goal /path/to/goal.json \
- --planner python3 /path/to/planner.py \
- --executor python3 /path/to/executor.py \
- --evaluator python3 /path/to/evaluator.py
+---
+
+## Ledger: the complete audit trail
+
+```
+ledger/
+ .evolution_state.json ← budget counters; survives restarts
+ runs/
+ 0001/
+ config.json ← full snapshot of your evolution.yml
+ observation.json ← raw output of your evidence_sources commands
+ plan.json ← LLM plan: summary · steps · expected_improvement
+ patch.diff ← exact diff the executor applied
+ candidate_commit.txt ← git SHA of the sandbox commit
+ evaluation.json ← verdict + metrics + cost_usd + tokens_used
+ decision.json ← accept / reject + reason
+ reflection.json ← one-line summary injected into the next round
+ 0002/ ...
+ halted/
+ 20260501T120000Z.json ← written when any hard stop fires
```
-Reset the persistent hard-stop state (after a halt) without running a loop:
+To undo every change from a session:
```bash
-python3 -m evolution_kernel.cli --reset --ledger /path/to/evolution-ledger
+git checkout evolution/accepted
+git reset --hard # every accepted change is a named commit
```
-Each role command receives:
+---
-```text
---input
---output
---worktree
+## Architecture
+
+```mermaid
+flowchart LR
+ Config[evolution.yml] --> Governor
+
+ subgraph loop ["↻ Loop until hard stop fires"]
+ direction LR
+ Governor -->|"planner_input.json\ngoal · observation · history"| Planner["🧠 Planner\nLLM"]
+ Planner -->|plan.json| Executor["🔨 Executor\nAider / Claude Code"]
+ Executor -->|patch in git worktree| Evaluator["⚖️ Evaluator\nLLM + shell"]
+ Evaluator -->|evaluation.json| Governor
+ end
+
+ Governor -->|"accept → git commit"| Branch["evolution/accepted"]
+ Governor -->|"reject → discard"| Ledger[📁 Ledger]
+ Governor --> Ledger
```
-## MVP Usage (closed loop with observer, scope, hard stops)
+**The Governor is intentionally dumb.** It is pure orchestration — zero LLM calls. All intelligence lives in the three role scripts. Swap any role for your own implementation; the Governor only cares about the JSON each role reads and writes.
+
+**Roles communicate through files, not shared memory.** The planner never talks to the executor. The evaluator never sees the executor's self-assessment. The only shared state is the ledger.
+
+---
-This MVP wires the full closed loop described in the protocol:
-`config -> observe -> plan/execute -> evaluate -> accept/reject -> ledger`.
+## What works today
-### 1. Author an `evolution.yml`
+| Feature | Status |
+|---|:---:|
+| Multi-round LLM loop with memory (history injection) | ✅ |
+| Budget guards: `max_total_usd`, `max_total_tokens` | ✅ |
+| Iteration / consecutive-failure hard stops | ✅ |
+| Full ledger audit trail (survives process restarts) | ✅ |
+| Git worktree sandbox — every attempt isolated | ✅ |
+| Scope enforcement — rejects changes outside `allowed_paths` | ✅ |
+| Config-driven: swap LLM provider, model, coding agent | ✅ |
+| Aider and Claude Code executor support | ✅ |
+| Anthropic and OpenAI planner/evaluator support | ✅ |
+| Goal evaluator — stops when mission is "won" | 🔧 PR #5 |
+| k-branch parallel exploration (FunSearch / AlphaEvolve style) | 🔧 PR #6 |
+| Process sandbox (firejail / bwrap) for production safety | 🔧 PR #7 |
+
+---
+
+## Configuration reference
```yaml
-mission: "Add a minimal in-scope mutation so the evaluator accepts."
+# Required — what "better" means for your project
+mission: "Evolve the game AI to win at least 60% of games"
+# How to measure the current state
evidence_sources:
- - type: file
- path: metrics.json
- - type: shell
- command: "bash scripts/status.sh"
+ - type: shell # stdout goes into observation.json
+ command: "python3 scripts/tournament.py --games 20 --json"
+ - type: file # file contents go into observation.json
+ path: "metrics.json"
+# Only files under these paths may be changed
mutation_scope:
allowed_paths:
- - "src/"
+ - "ai/" # changes outside this list are auto-rejected
+# When to stop
hard_stops:
- max_iterations: 3
- max_consecutive_failures: 2
+ max_iterations: 30 # total rounds
+ max_consecutive_failures: 4 # consecutive rejections before halt
+ max_total_usd: 3.00 # 0 = unlimited
+ max_total_tokens: 0 # 0 = unlimited
+
+# LLM for planner and evaluator
+llm:
+ provider: anthropic # anthropic | openai
+ model: claude-sonnet-4-6
+ api_key_env: ANTHROPIC_API_KEY
+
+# Coding agent for executor
+coding_agent:
+ tool: aider # aider | claude-code
+
+# How many past rounds the planner sees
+history:
+ max_entries: 10
roles:
- planner: ["python3", "bots/planner.py"]
- executor: ["python3", "bots/executor.py"]
- evaluator: ["python3", "bots/evaluator.py"]
+ planner: ["python3", "roles/planner.py"]
+ executor: ["bash", "roles/executor.sh"]
+ evaluator: ["python3", "roles/evaluator.py"]
+```
+
+**Switch to OpenAI:**
+```yaml
+llm:
+ provider: openai
+ model: gpt-4o
+ api_key_env: OPENAI_API_KEY
+```
+
+**Switch to Claude Code:**
+```yaml
+coding_agent:
+ tool: claude-code
+```
+
+---
+
+## CLI
+
+```bash
+# Loop until a hard stop fires (recommended)
+evolution-kernel --config evolution.yml --repo /path/to/repo --ledger /tmp/ledger --loop
+
+# Single round
+evolution-kernel --config evolution.yml --repo /path/to/repo --ledger /tmp/ledger
+
+# Reset budget counters after a halt
+evolution-kernel --ledger /tmp/ledger --reset
```
-`evidence_sources` are read into `observation.json` before the planner runs.
-`mutation_scope.allowed_paths` are enforced after the executor commits — anything
-outside the scope is auto-rejected with `decision.reason = "scope_violation: ..."`.
-`hard_stops` persist across runs in `/.evolution_state.json` so a stuck
-loop halts even across CLI invocations.
+Exit codes: `0` clean finish · `3` halted by a hard stop.
+
+---
-### 2. Run a single iteration
+## Install
```bash
-# one-time: install the package (pulls PyYAML, the only runtime dep)
-python3 -m pip install -e .
+pip install evolution-kernel
+```
-# one-time: prepare a target repo
-bash examples/demo_target/setup.sh
+From source (only runtime dependency: PyYAML):
-python3 -m evolution_kernel.cli \
- --config examples/evolution.yml \
- --repo examples/demo_target \
- --ledger /tmp/ek-ledger
+```bash
+git clone https://github.com/Protocol-zero-0/evolution-kernel.git
+cd evolution-kernel
+pip install -e .
```
-> The `pip install -e .` step is only needed once per environment — it pulls
-> `PyYAML>=6.0` (declared in `pyproject.toml`). After that the three-line
-> command above is reproducible from a clean checkout.
+Python 3.10 or later.
+
+---
-Reset the persistent hard-stop counters when you want to start fresh:
+## Tests
```bash
-python3 -m evolution_kernel.cli --reset --ledger /tmp/ek-ledger
+python3 -m pytest tests/ -v
```
-### 3. Inspect the ledger
+39 tests · no network calls · roles replaced by lightweight fixture scripts.
+
+---
+
+## Writing your own roles
-Every run produces a directory under `/runs//` containing the
-full evidence trail:
+Each role is an executable that receives:
-```text
-goal.json # legacy mode only
-config.json # full snapshot of the YAML config (full mode)
-observation.json # what the observer collected before planning
-plan.json # planner output
-patch.diff # diff between baseline and candidate commit
-candidate_commit.txt # the candidate commit hash inside the sandbox
-evaluation.json # evaluator output (synthesised on scope_violation)
-decision.json # accept / reject + reason
-reflection.json # post-decision summary
+```
+--input JSON the governor wrote for this role
+--output JSON the role must write before exiting
+--worktree path to the isolated git sandbox checkout
```
-### 4. Acceptance criteria -> tests
+`roles/planner.py`, `roles/executor.sh`, and `roles/evaluator.py` are the reference implementation. Copy, modify, or replace them entirely — with a shell script, a Docker call, or anything that reads `--input` and writes `--output`.
-The six acceptance bullets from issue #1 each map to a test in
-`tests/test_acceptance.py`:
+---
-| # | Acceptance bullet | Test |
-| - | --- | --- |
-| 1 | Accept advances `evolution/accepted` | `test_accept_advances_accepted_branch` |
-| 2 | Reject does not advance it | `test_reject_does_not_advance_accepted_branch` |
-| 3 | Mutation scope enforced + violation logged | `test_scope_violation_is_rejected_and_logged` |
-| 4 | Observer writes `observation.json` (file + shell) | `test_observer_writes_observation_with_file_and_shell` |
-| 5 | Hard stops halt then `reset` re-enables | `test_hard_stop_blocks_then_reset_allows_via_cli` |
-| 6 | Ledger contains all required artifacts | `test_ledger_contains_all_required_artifacts` |
+## Project layout
-### What this MVP intentionally does **not** do
+```
+evolution_kernel/ ~1,200-line runtime (Governor · Observer · HardStops · Config · CLI)
+roles/ reference planner, executor, evaluator
+examples/ demo target + working evolution.yml
+docs/ protocol spec
+tests/ 39 unit + acceptance tests
+```
-In line with the issue's "out of scope" list:
+---
-- No LLM / agent-swarm / dashboard.
-- No PR router and no auto-merge to upstream `main`.
-- No multi-target adapter framework — the only example target is
- `examples/demo_target/`.
-- No container/process sandbox beyond git worktrees.
+## License
-These are the natural next steps once the kernel itself is trusted.
+MIT — see [LICENSE](LICENSE).
diff --git a/README.zh.md b/README.zh.md
index 113b5bf..72cd391 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -1,241 +1,354 @@
# Evolution Kernel
- 一个用于自主优化软件项目的通用进化引擎。
+ 给 LLM 一个目标,让代码库自己进化,预算用完自动停。
+
+
+
+ 约 1,200 行 Python 运行时,对任意代码库跑全自动多轮改进循环——
+ 隔离在 git worktree 沙箱里,每一个决策留档,每一次变更可回滚。
English
·
- 协议
- ·
- 首个优化对象
+ 协议文档
-
-
-
-
-
+
+
+
+
+
+
+
-**Evolution Kernel** 是一个面向“自主自我进化软件系统”的最小协议与运行时。
+---
-它不是某个具体项目的自动化脚本,而是一个通用的进化内核。它的目标是让软件项目的持续改进过程变得**可控、可复现、可沙箱化、可审计、可回滚**。只要一个项目能够提供目标、沙箱和评估器,就可以成为它的优化对象。
+
+ 把它理解成 AlphaEvolve——但目标是你自己的代码仓库。
+ 你定义"更好"是什么意思,内核负责找到如何到达那里。
+
-## 为什么需要它
+---
-现代 coding agent 可以提出并修改代码,但长期的软件自我改进不只需要代码生成,还需要一个稳定的内核来管理整个进化闭环:
+## 它做什么
-- 定义目标项目里的“改进”到底意味着什么;
-- 在影响已接受分支之前隔离每一次实验;
-- 用可复现的标准评估候选变更;
-- 只晋升通过评估的候选结果;
-- 记录每次实验发生了什么、为什么接受或拒绝。
+把 Evolution Kernel 指向任意 git 仓库,给它一个可衡量的目标,它就跑起一个闭环:
-Evolution Kernel 将这个闭环做成一个小而可检查的运行时。
+| 步骤 | 发生了什么 |
+|:---:|---|
+| 🔍 **观察** | 运行你的指标命令——采集当前状态(胜率、延迟、报错数……) |
+| 🧠 **规划** | LLM 读取指标 + 历史轮次记录,生成一个具体的改进方案 |
+| 🔨 **执行** | Coding agent(Aider 或 Claude Code)在隔离的 git worktree 里实施方案 |
+| ⚖️ **评估** | 重新运行指标;LLM 判断接受还是拒绝 |
+| ✅ **提交 / 回滚** | 接受 → 在 `evolution/accepted` 上留下真实的 git commit。拒绝 → worktree 直接丢弃 |
+| 🔁 **循环** | 重复,直到 `max_iterations`、`max_total_usd` 或 `max_total_tokens` 触发 |
-## 进化闭环
+每一次尝试都写入 **ledger**:目标、观察、方案、diff、评估、决策。不依赖内存。任何外部审计者——或未来的你——都能从 ledger 单独复盘每一个决定。
-```mermaid
-flowchart LR
- Goal[Goal] --> Governor[Governor]
- Governor --> Planner[Planner]
- Planner --> Plan[plan.json]
- Plan --> Executor[Executor]
- Executor --> Candidate[Sandbox candidate]
- Candidate --> Evaluator[Evaluator]
- Evaluator --> Eval[evaluation.json]
- Eval --> Governor
- Governor --> Accepted[evolution/accepted]
- Governor --> Ledger[Ledger]
-```
+---
-## 首个优化对象
+## 快速上手
-Evolution Kernel 的定位是优化**任何**软件项目。它第一个正在优化的项目是 **Token-Ignition**,具体对象是 Token-Ignition 的后端评估器。
+```bash
+# 1. 安装
+pip install evolution-kernel
-因此,Token-Ignition 是第一个优化对象和参考适配器,不是 Evolution Kernel 的硬依赖。它用来验证这个内核能否安全、确定性地进化一个真实代码库,同时保持运行时足够小。
+# 2. 描述你的目标
+cat > evolution.yml << 'EOF'
+mission: "让游戏 AI 对内置对手的胜率达到 60% 以上"
-## 当前状态
+evidence_sources:
+ - type: shell
+ command: "python3 scripts/tournament.py --games 20 --json"
-当前 v0 版本已经实现了基础运行时:
+mutation_scope:
+ allowed_paths: ["ai/"]
-| 模块 | 当前已实现 |
-| --- | --- |
-| Governor | 确定性编排 planning、execution、evaluation、promotion、rollback 和 ledger 更新。 |
-| Sandbox | 基于 Git worktree 的实验隔离。候选变更只有被晋升后才会影响已接受分支。 |
-| 角色交接 | `planner`、`executor`、`evaluator` 作为隔离命令运行,并通过 JSON 文件通信。 |
-| 晋升模型 | 被接受的候选结果推进本地 `evolution/accepted` 分支;被拒绝的实验只保留记录,不推进该分支。 |
-| 首个适配器 | Token-Ignition 适配器,包含用于评估器进化的手写 golden set。 |
+hard_stops:
+ max_iterations: 30
+ max_consecutive_failures: 4
+ max_total_usd: 3.00
-## 目前还没有做什么
+llm:
+ provider: anthropic
+ model: claude-sonnet-4-6
+ api_key_env: ANTHROPIC_API_KEY
-| 尚未完成 | 为什么重要 |
-| --- | --- |
-| LLM-native planner/executor | 当前测试使用 fixture 脚本;真实 agent 接入是下一步。 |
-| 更强的进程/容器级沙箱 | Git worktree 能隔离文件,但 executor 和 evaluator 的运行隔离还应进一步增强。 |
-| 多目标适配器框架 | Token-Ignition 是第一个目标;还需要更多适配器来证明通用性。 |
-| 并行进化分支 | v0 目前聚焦单一 accepted 分支和简单晋升路径。 |
+coding_agent:
+ tool: aider
-## Roadmap
+roles:
+ planner: ["python3", "roles/planner.py"]
+ executor: ["bash", "roles/executor.sh"]
+ evaluator: ["python3", "roles/evaluator.py"]
+EOF
-- [ ] 增加 LLM 驱动的 planner 和 executor 实现。
-- [ ] 为 executor 和 evaluator 增加更强的沙箱隔离。
-- [ ] 将适配器接口从 Token-Ignition 推广为通用接口。
-- [ ] 增加多个不同类型项目的 examples。
-- [ ] 支持并行进化分支和更丰富的合并策略。
-- [ ] 改进 ledger 历史、晋升决策、拒绝候选的报告能力。
+# 3. 跑起来,放着不管
+evolution-kernel --config evolution.yml --repo /path/to/game --ledger /tmp/ledger --loop
+```
-## 文档
+---
-- [协议](docs/protocol.md)
-- [Token-Ignition 首个任务](docs/token-ignition-first-task.md)
+## 看它实际运行
-## 运行测试
+### 游戏 AI 胜率从 35% 进化到 72%——隔夜完成,无人值守
-```bash
-python3 -m unittest discover -s tests -v
-python3 adapters/token_ignition/evaluate_golden_cases.py
```
+进化前 ███░░░░░░░░░ 35% 胜率 (20 局输 13 局)
+进化后 ███████░░░░░ 72% 胜率 (20 局赢 14 局)
-## CLI 形状
+共 9 轮 · 花费 $2.14 · 你的时间投入:0 分钟
+```
-YAML 配置模式(MVP 主入口 — 包含 observer + scope + hard stops):
+循环逐轮发生的事情:
-```bash
-python3 -m evolution_kernel.cli \
- --config /path/to/evolution.yml \
- --repo /path/to/target-repo \
- --ledger /path/to/evolution-ledger
+```
+第 1 轮 观察: 胜率 35%
+ 规划 → "当前 AI 只会贪心取分,没有前瞻——加入 2 层 minimax 搜索"
+ 执行 → aider 重写 ai/strategy.py(改了 68 行)
+ 评估 → 胜率 51% ▲+16 — 接受
+ 提交 a3f1c9e "ai: 加入 minimax(35→51% 胜率)"
+
+第 2 轮 观察: 胜率 51%
+ 规划 → "minimax 没处理残局——加入位置评估权重"
+ 执行 → aider 新增 ai/eval_weights.py
+ 评估 → 胜率 58% ▲+7 — 接受
+ 提交 8b2de01 "ai: 位置权重(51→58%)"
+
+第 3 轮 观察: 胜率 58%
+ 规划 → "加入 alpha-beta 剪枝以搜索更深"
+ 执行 → aider 修改 ai/strategy.py
+ 评估 → 胜率 56% ▼-2 — 拒绝 连续失败次数: 1
+ 回滚 worktree 已丢弃 · 主分支没有任何变化
+
+第 4 轮 观察: 胜率 58% ← 历史记录显示第 3 轮 alpha-beta 失败
+ 规划 → "alpha-beta 导致了回退;改为根据失败模式分析调整残局权重"
+ 执行 → aider 调整 ai/eval_weights.py
+ 评估 → 胜率 67% ▲+9 — 接受
+ 提交 2c9af44 "ai: 残局权重调优(58→67%)"
+
+...
+
+第 9 轮 观察: 胜率 72%
+ 评估 → 72%——目标 60% 已超越——接受
+ 提交 9d7b321 "ai: 最终调优(70→72%)"
+
+{"halted": true, "reason": "max_iterations reached", "iterations": 30, "total_usd": 2.14, "total_tokens": 634000}
```
-旧版直接传参模式(保留以兼容原始的 golden-case 测试):
+> **第 3 轮是关键。** alpha-beta 剪枝让结果变*更差*,系统拒绝了这次变更,代码库保持不动。第 4 轮展示了 LLM 读取了拒绝历史并换了思路。这就是"有记忆"在实际中的含义——不会把同样的错误答案猜两遍。
+
+---
+
+## Ledger:完整的审计链
-```bash
-python3 -m evolution_kernel.cli \
- --repo /path/to/target-repo \
- --ledger /path/to/evolution-ledger \
- --goal /path/to/goal.json \
- --planner python3 /path/to/planner.py \
- --executor python3 /path/to/executor.py \
- --evaluator python3 /path/to/evaluator.py
+```
+ledger/
+ .evolution_state.json ← 预算计数器,进程重启后依然有效
+ runs/
+ 0001/
+ config.json ← 你的 evolution.yml 完整快照
+ observation.json ← evidence_sources 命令的原始输出
+ plan.json ← LLM 方案:摘要 · 步骤 · 预期改进
+ patch.diff ← 执行器实际应用的 diff
+ candidate_commit.txt ← 沙箱 commit 的 git SHA
+ evaluation.json ← 评估结果 + 指标 + cost_usd + tokens_used
+ decision.json ← 接受 / 拒绝 + 原因
+ reflection.json ← 注入下一轮历史的一行摘要
+ 0002/ ...
+ halted/
+ 20260501T120000Z.json ← 任何 hard stop 触发时写入
```
-熔断后清空持久化的 hard-stop 状态(不会触发一次 run):
+回滚一个 session 的所有变更:
```bash
-python3 -m evolution_kernel.cli --reset --ledger /path/to/evolution-ledger
+git checkout evolution/accepted
+git reset --hard # 每次接受的变更都是一个具名 commit
```
-每个角色命令都会收到:
+---
+
+## 架构
-```text
---input
---output
---worktree
+```mermaid
+flowchart LR
+ Config[evolution.yml] --> Governor
+
+ subgraph loop ["↻ 循环,直到 hard stop 触发"]
+ direction LR
+ Governor -->|"planner_input.json\n目标 · 观察 · 历史"| Planner["🧠 规划器\nLLM"]
+ Planner -->|plan.json| Executor["🔨 执行器\nAider / Claude Code"]
+ Executor -->|patch in git worktree| Evaluator["⚖️ 评估器\nLLM + shell"]
+ Evaluator -->|evaluation.json| Governor
+ end
+
+ Governor -->|"接受 → git commit"| Branch["evolution/accepted"]
+ Governor -->|"拒绝 → 丢弃"| Ledger[📁 Ledger]
+ Governor --> Ledger
```
-## MVP 使用方式(observer + scope + hard stops 闭环)
+**Governor 故意设计得"笨"。** 它是纯编排逻辑——零 LLM 调用。所有智能都在三个角色脚本里。换掉任何一个角色,Governor 只关心它读写的 JSON 文件。
+
+**角色之间通过文件通信,不共享内存。** 规划器不直接和执行器说话,评估器看不到执行器的自我评价。唯一的共享状态是 ledger。
-本 MVP 串起协议描述的完整闭环:
-`config -> observe -> plan/execute -> evaluate -> accept/reject -> ledger`。
+---
-### 1. 编写 `evolution.yml`
+## 当前能力
+
+| 功能 | 状态 |
+|---|:---:|
+| 多轮 LLM 循环,带记忆(历史注入) | ✅ |
+| 预算保护:`max_total_usd`、`max_total_tokens` | ✅ |
+| 迭代次数 / 连续失败次数 hard stop | ✅ |
+| 完整 ledger 审计链(进程重启后不丢失) | ✅ |
+| git worktree 沙箱——每次尝试完全隔离 | ✅ |
+| Scope 强制校验——`allowed_paths` 外的改动自动拒绝 | ✅ |
+| 配置驱动:随时切换 LLM 提供商、模型、coding agent | ✅ |
+| Aider 和 Claude Code executor 支持 | ✅ |
+| Anthropic 和 OpenAI 规划器 / 评估器支持 | ✅ |
+| 目标评估器——当 mission 完成时自动停止 | 🔧 PR #5 |
+| k 路并行探索(FunSearch / AlphaEvolve 模式) | 🔧 PR #6 |
+| 进程级沙箱(firejail / bwrap),面向生产环境 | 🔧 PR #7 |
+
+---
+
+## 配置参考
```yaml
-mission: "Add a minimal in-scope mutation so the evaluator accepts."
+# 必填——"更好"对你的项目意味着什么
+mission: "让游戏 AI 对内置对手的胜率达到 60% 以上"
+# 如何衡量当前状态
evidence_sources:
- - type: file
- path: metrics.json
- - type: shell
- command: "bash scripts/status.sh"
+ - type: shell # stdout 写入 observation.json
+ command: "python3 scripts/tournament.py --games 20 --json"
+ - type: file # 文件内容写入 observation.json
+ path: "metrics.json"
+# 只有这些路径下的文件允许被修改
mutation_scope:
allowed_paths:
- - "src/"
+ - "ai/" # 不在列表里的改动自动拒绝
+# 何时停止
hard_stops:
- max_iterations: 3
- max_consecutive_failures: 2
+ max_iterations: 30 # 总轮数
+ max_consecutive_failures: 4 # 连续拒绝多少次触发停止
+ max_total_usd: 3.00 # 0 = 不限制
+ max_total_tokens: 0 # 0 = 不限制
+
+# 规划器和评估器使用的 LLM
+llm:
+ provider: anthropic # anthropic | openai
+ model: claude-sonnet-4-6
+ api_key_env: ANTHROPIC_API_KEY
+
+# 执行器使用的 coding agent
+coding_agent:
+ tool: aider # aider | claude-code
+
+# 规划器每轮能看到多少轮历史
+history:
+ max_entries: 10
roles:
- planner: ["python3", "bots/planner.py"]
- executor: ["python3", "bots/executor.py"]
- evaluator: ["python3", "bots/evaluator.py"]
+ planner: ["python3", "roles/planner.py"]
+ executor: ["bash", "roles/executor.sh"]
+ evaluator: ["python3", "roles/evaluator.py"]
```
-`evidence_sources` 在 planner 运行前被读入 `observation.json`。
-`mutation_scope.allowed_paths` 在 executor 提交后被强制校验 —— 范围之外
-的任何改动都会被自动 reject,`decision.reason` 写为 `scope_violation: ...`。
-`hard_stops` 通过 `/.evolution_state.json` 跨 run 持久化,循环卡死
-时即使重启 CLI 也会被拦截。
+**切换到 OpenAI:**
+```yaml
+llm:
+ provider: openai
+ model: gpt-4o
+ api_key_env: OPENAI_API_KEY
+```
+
+**切换到 Claude Code:**
+```yaml
+coding_agent:
+ tool: claude-code
+```
-### 2. 跑一次实验
+---
+
+## CLI
```bash
-# 一次性:安装包(PyYAML 是唯一运行时依赖,已在 pyproject.toml 声明)
-python3 -m pip install -e .
+# 循环运行直到 hard stop 触发(推荐)
+evolution-kernel --config evolution.yml --repo /path/to/repo --ledger /tmp/ledger --loop
-# 一次性:准备目标仓库
-bash examples/demo_target/setup.sh
+# 只跑一轮
+evolution-kernel --config evolution.yml --repo /path/to/repo --ledger /tmp/ledger
-python3 -m evolution_kernel.cli \
- --config examples/evolution.yml \
- --repo examples/demo_target \
- --ledger /tmp/ek-ledger
+# 触发 halt 后重置预算计数器
+evolution-kernel --ledger /tmp/ledger --reset
```
-> 上面 `pip install -e .` 每个环境只需要做一次。之后那三行 CLI 命令是
-> 干净 checkout 下可复现的。
+退出码:`0` 正常结束 · `3` 被 hard stop 触发
+
+---
-需要重置熔断器从头来过:
+## 安装
```bash
-python3 -m evolution_kernel.cli --reset --ledger /tmp/ek-ledger
+pip install evolution-kernel
```
-### 3. 检查 ledger
+从源码安装(唯一运行时依赖:PyYAML):
-每一次 run 都会在 `/runs//` 下产出完整的证据链:
+```bash
+git clone https://github.com/Protocol-zero-0/evolution-kernel.git
+cd evolution-kernel
+pip install -e .
+```
+
+需要 Python 3.10 或更高版本。
-```text
-goal.json # 仅 legacy 模式
-config.json # 完整 YAML 配置快照(full 模式)
-observation.json # planning 之前 observer 收集到的证据
-plan.json # planner 输出
-patch.diff # baseline 与 candidate commit 之间的 diff
-candidate_commit.txt # sandbox 中 candidate commit 的 hash
-evaluation.json # evaluator 输出(scope_violation 时由 Governor 合成)
-decision.json # accept / reject + 原因
-reflection.json # 决策后的总结
+---
+
+## 运行测试
+
+```bash
+python3 -m pytest tests/ -v
```
-### 4. 验收标准 → 测试映射
+39 个测试 · 不需要网络连接 · 角色脚本由轻量 fixture 替代。
+
+---
-issue #1 中六条验收标准在 `tests/test_acceptance.py` 中各对应一个测试:
+## 自己写角色脚本
-| # | 验收要求 | 测试 |
-| - | --- | --- |
-| 1 | accept 推进 `evolution/accepted` | `test_accept_advances_accepted_branch` |
-| 2 | reject 不推进 | `test_reject_does_not_advance_accepted_branch` |
-| 3 | 强制 mutation scope + 记录违规 | `test_scope_violation_is_rejected_and_logged` |
-| 4 | observer 写出 `observation.json`(file + shell) | `test_observer_writes_observation_with_file_and_shell` |
-| 5 | hard stops 触发熔断后 `--reset` 恢复 | `test_hard_stop_blocks_then_reset_allows_via_cli` |
-| 6 | ledger 包含全部必需 artifact | `test_ledger_contains_all_required_artifacts` |
+每个角色是一个普通的可执行程序,接收三个参数:
-此外 `tests/test_scope.py` 单独钉死了 `allowed_paths` matcher 的边界语义
-(递归 / 精确匹配 / 兄弟名碰撞 / `..` 逃逸 / 空作用域 等)。
+```
+--input <路径> Governor 为这个角色准备的 JSON
+--output <路径> 角色退出前必须写入的 JSON
+--worktree <路径> 隔离 git 沙箱的 checkout 路径
+```
+
+`roles/planner.py`、`roles/executor.sh`、`roles/evaluator.py` 是参考实现。复制并修改它们,或者完全替换成 shell 脚本、Docker 调用——任何能读 `--input`、写 `--output` 的东西都行。
+
+---
-### 本 MVP 有意**不做**的内容
+## 项目结构
+
+```
+evolution_kernel/ 约 1,200 行运行时(Governor · Observer · HardStops · Config · CLI)
+roles/ 参考版规划器、执行器、评估器
+examples/ demo 目标仓库 + 可直接运行的 evolution.yml
+docs/ 协议文档
+tests/ 39 个单元 + 验收测试
+```
-按照 issue 的“不要做”清单:
+---
-- 不做 LLM / agent-swarm / dashboard。
-- 不做 PR router,不做自动 merge 到上游 `main`。
-- 不做多目标适配器框架 —— 唯一示例目标是 `examples/demo_target/`。
-- 不做超出 git worktree 的容器/进程级沙箱。
+## 许可证
-这些都是在内核本身被信任之后才适合做的下一步。
+MIT — 见 [LICENSE](LICENSE)。
diff --git a/evolution_kernel/cli.py b/evolution_kernel/cli.py
index 72f6895..51b01b2 100644
--- a/evolution_kernel/cli.py
+++ b/evolution_kernel/cli.py
@@ -1,18 +1,15 @@
"""Evolution Kernel command-line entry point.
-The CLI shape mirrors the suggested form in the project's MVP brief:
+Usage:
- python -m evolution_kernel.cli \
- --config examples/evolution.yml \
- --repo /path/to/target-repo \
- --ledger /tmp/evolution-ledger
+ # Run once:
+ evolution-kernel --config examples/evolution.yml --repo /path/to/repo --ledger /tmp/ledger
-Two extra modes are supported alongside this primary form:
+ # Run until hard stops trigger (multi-round loop):
+ evolution-kernel --config examples/evolution.yml --repo /path/to/repo --ledger /tmp/ledger --loop
-* ``--goal goal.json`` runs the legacy direct-flags loop (no observer / scope /
- hard-stops) so the original golden-case tests keep working unchanged.
-* ``--reset`` clears the persisted hard-stop state for the given ledger and
- exits — used to re-enable a halted loop after a human review.
+ # Reset hard-stop state:
+ evolution-kernel --ledger /tmp/ledger --reset
"""
from __future__ import annotations
@@ -32,7 +29,7 @@
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(
prog="evolution-kernel",
- description="Run one Evolution Kernel experiment under MVP constraints.",
+ description="Run Evolution Kernel experiments.",
)
parser.add_argument("--repo", help="Target git repository (required unless --reset)")
parser.add_argument("--ledger", required=True, help="Ledger directory")
@@ -43,6 +40,7 @@ def main(argv: Sequence[str] | None = None) -> int:
parser.add_argument("--executor", nargs="+", help="Executor argv (overrides config.roles.executor)")
parser.add_argument("--evaluator", nargs="+", help="Evaluator argv (overrides config.roles.evaluator)")
parser.add_argument("--run-id", default=None)
+ parser.add_argument("--loop", action="store_true", help="Run until hard stops trigger (multi-round).")
parser.add_argument(
"--reset",
action="store_true",
@@ -75,7 +73,7 @@ def _cmd_reset(args: argparse.Namespace) -> int:
return 0
-def _run_with_config(args: argparse.Namespace, cfg: EvolutionConfig) -> int:
+def _make_governor(args: argparse.Namespace, cfg: EvolutionConfig) -> Governor:
planner = tuple(args.planner) if args.planner else cfg.roles.planner
executor = tuple(args.executor) if args.executor else cfg.roles.executor
evaluator = tuple(args.evaluator) if args.evaluator else cfg.roles.evaluator
@@ -84,44 +82,102 @@ def _run_with_config(args: argparse.Namespace, cfg: EvolutionConfig) -> int:
"error: planner/executor/evaluator must be defined in config.roles or via flags",
file=sys.stderr,
)
- return 2
+ raise SystemExit(2)
+ return Governor(
+ target_repo=args.repo,
+ ledger_dir=args.ledger,
+ planner=RoleCommand(list(planner)),
+ executor=RoleCommand(list(executor)),
+ evaluator=RoleCommand(list(evaluator)),
+ evidence_sources=cfg.evidence_sources,
+ allowed_paths=cfg.mutation_scope.allowed_paths,
+ config_snapshot=cfg.raw,
+ history_max_entries=cfg.history.max_entries,
+ )
+
+
+def _run_with_config(args: argparse.Namespace, cfg: EvolutionConfig) -> int:
+ try:
+ governor = _make_governor(args, cfg)
+ except SystemExit as e:
+ return int(e.code)
+
+ goal = {"name": cfg.mission, "objective": cfg.mission}
+
+ if args.loop:
+ return _run_loop(args, cfg, governor, goal)
+ # Single run
state = hard_stops.load_state(args.ledger)
allowed, why = hard_stops.precheck(
state,
cfg.hard_stops.max_iterations,
cfg.hard_stops.max_consecutive_failures,
+ max_total_usd=cfg.hard_stops.max_total_usd,
+ max_total_tokens=cfg.hard_stops.max_total_tokens,
)
if not allowed:
- # Even when blocked, leave an audit record so the ledger covers every
- # invocation, not just the ones that actually ran the loop.
_record_halted(args.ledger, state, why)
print(json.dumps({"halted": True, "reason": why}, indent=2, sort_keys=True))
return 3
- goal = {"name": cfg.mission, "objective": cfg.mission}
- governor = Governor(
- target_repo=args.repo,
- ledger_dir=args.ledger,
- planner=RoleCommand(list(planner)),
- executor=RoleCommand(list(executor)),
- evaluator=RoleCommand(list(evaluator)),
- evidence_sources=cfg.evidence_sources,
- allowed_paths=cfg.mutation_scope.allowed_paths,
- config_snapshot=cfg.raw,
- )
result = governor.run_once(goal, run_id=args.run_id)
+ cost_usd, tokens_used = _safe_cost(result.evaluation)
new_state = hard_stops.record_outcome(
state,
accepted=result.decision.accepted,
max_iterations=cfg.hard_stops.max_iterations,
max_consecutive_failures=cfg.hard_stops.max_consecutive_failures,
+ cost_usd=cost_usd,
+ tokens_used=tokens_used,
+ max_total_usd=cfg.hard_stops.max_total_usd,
+ max_total_tokens=cfg.hard_stops.max_total_tokens,
)
hard_stops.save_state(args.ledger, new_state)
_print_result(result, halted=new_state.halted, halt_reason=new_state.halt_reason)
return 0
+def _run_loop(
+ args: argparse.Namespace,
+ cfg: EvolutionConfig,
+ governor: Governor,
+ goal: dict,
+) -> int:
+ """Run until hard stops trigger. Each iteration saves state immediately."""
+ while True:
+ state = hard_stops.load_state(args.ledger)
+ allowed, why = hard_stops.precheck(
+ state,
+ cfg.hard_stops.max_iterations,
+ cfg.hard_stops.max_consecutive_failures,
+ max_total_usd=cfg.hard_stops.max_total_usd,
+ max_total_tokens=cfg.hard_stops.max_total_tokens,
+ )
+ if not allowed:
+ _record_halted(args.ledger, state, why)
+ print(json.dumps({"halted": True, "reason": why}, indent=2, sort_keys=True))
+ return 3
+
+ result = governor.run_once(goal)
+ cost_usd, tokens_used = _safe_cost(result.evaluation)
+ new_state = hard_stops.record_outcome(
+ state,
+ accepted=result.decision.accepted,
+ max_iterations=cfg.hard_stops.max_iterations,
+ max_consecutive_failures=cfg.hard_stops.max_consecutive_failures,
+ cost_usd=cost_usd,
+ tokens_used=tokens_used,
+ max_total_usd=cfg.hard_stops.max_total_usd,
+ max_total_tokens=cfg.hard_stops.max_total_tokens,
+ )
+ hard_stops.save_state(args.ledger, new_state)
+ _print_result(result, halted=new_state.halted, halt_reason=new_state.halt_reason)
+ if new_state.halted:
+ _record_halted(args.ledger, new_state, new_state.halt_reason)
+ return 3
+
+
def _run_legacy(args: argparse.Namespace) -> int:
if not (args.planner and args.executor and args.evaluator):
print(
@@ -142,6 +198,19 @@ def _run_legacy(args: argparse.Namespace) -> int:
return 0
+def _safe_cost(evaluation: dict) -> tuple[float, int]:
+ """Extract cost fields defensively; return (0.0, 0) on any parse error."""
+ try:
+ cost_usd = float(evaluation.get("cost_usd") or 0.0)
+ except (TypeError, ValueError):
+ cost_usd = 0.0
+ try:
+ tokens_used = int(evaluation.get("tokens_used") or 0)
+ except (TypeError, ValueError):
+ tokens_used = 0
+ return cost_usd, tokens_used
+
+
def _record_halted(
ledger_dir: str,
state: hard_stops.HardStopState,
@@ -155,8 +224,9 @@ def _record_halted(
"reason": reason,
"iterations": state.iterations,
"consecutive_failures": state.consecutive_failures,
+ "total_usd": state.total_usd,
+ "total_tokens": state.total_tokens,
}
- # Suffix with sequence number to avoid collisions within the same second.
base = halted_dir / f"{ts}.json"
target = base
n = 1
diff --git a/evolution_kernel/config.py b/evolution_kernel/config.py
index 97ab5a3..03651df 100644
--- a/evolution_kernel/config.py
+++ b/evolution_kernel/config.py
@@ -4,6 +4,17 @@
mission: "free-text statement of intent"
+ llm:
+ provider: anthropic # anthropic | openai
+ model: claude-sonnet-4-6
+ api_key_env: ANTHROPIC_API_KEY
+
+ coding_agent:
+ tool: aider # aider | claude-code
+
+ history:
+ max_entries: 10
+
evidence_sources:
- type: file
path: "./metrics.json"
@@ -16,8 +27,10 @@
- "tests/"
hard_stops:
- max_iterations: 3
- max_consecutive_failures: 2
+ max_iterations: 10
+ max_consecutive_failures: 3
+ max_total_usd: 1.00 # 0.0 = unlimited
+ max_total_tokens: 500000 # 0 = unlimited
Validation prefers human-readable errors over raw tracebacks so that bad configs
can be fixed without reading source.
@@ -52,6 +65,8 @@ class MutationScope:
class HardStops:
max_iterations: int = 1
max_consecutive_failures: int = 1
+ max_total_usd: float = 0.0 # 0.0 = unlimited
+ max_total_tokens: int = 0 # 0 = unlimited
@dataclass(frozen=True)
@@ -61,6 +76,23 @@ class Roles:
evaluator: tuple[str, ...] = ()
+@dataclass(frozen=True)
+class LLMConfig:
+ provider: str = "anthropic" # anthropic | openai
+ model: str = "claude-sonnet-4-6"
+ api_key_env: str = "ANTHROPIC_API_KEY"
+
+
+@dataclass(frozen=True)
+class CodingAgentConfig:
+ tool: str = "aider" # aider | claude-code
+
+
+@dataclass(frozen=True)
+class HistoryConfig:
+ max_entries: int = 10
+
+
@dataclass(frozen=True)
class EvolutionConfig:
mission: str
@@ -68,6 +100,9 @@ class EvolutionConfig:
mutation_scope: MutationScope = field(default_factory=MutationScope)
hard_stops: HardStops = field(default_factory=HardStops)
roles: Roles = field(default_factory=Roles)
+ llm: LLMConfig = field(default_factory=LLMConfig)
+ coding_agent: CodingAgentConfig = field(default_factory=CodingAgentConfig)
+ history: HistoryConfig = field(default_factory=HistoryConfig)
raw: Mapping[str, Any] = field(default_factory=dict)
@@ -97,6 +132,9 @@ def parse_config(raw: Mapping[str, Any]) -> EvolutionConfig:
mutation_scope = _parse_mutation_scope(raw.get("mutation_scope", {}))
hard_stops = _parse_hard_stops(raw.get("hard_stops", {}))
roles = _parse_roles(raw.get("roles", {}))
+ llm = _parse_llm(raw.get("llm", {}))
+ coding_agent = _parse_coding_agent(raw.get("coding_agent", {}))
+ history = _parse_history(raw.get("history", {}))
return EvolutionConfig(
mission=mission.strip(),
@@ -104,6 +142,9 @@ def parse_config(raw: Mapping[str, Any]) -> EvolutionConfig:
mutation_scope=mutation_scope,
hard_stops=hard_stops,
roles=roles,
+ llm=llm,
+ coding_agent=coding_agent,
+ history=history,
raw=dict(raw),
)
@@ -182,4 +223,53 @@ def _parse_hard_stops(value: Any) -> HardStops:
for label, n in (("max_iterations", max_iterations), ("max_consecutive_failures", max_failures)):
if not isinstance(n, int) or isinstance(n, bool) or n < 1:
raise ConfigError(f"`hard_stops.{label}` must be a positive integer, got {n!r}")
- return HardStops(max_iterations=max_iterations, max_consecutive_failures=max_failures)
+ usd_raw = value.get("max_total_usd", 0.0)
+ tok_raw = value.get("max_total_tokens", 0)
+ try:
+ max_total_usd = float(usd_raw)
+ except (TypeError, ValueError):
+ raise ConfigError(f"`hard_stops.max_total_usd` must be a number, got {usd_raw!r}")
+ try:
+ max_total_tokens = int(tok_raw)
+ except (TypeError, ValueError):
+ raise ConfigError(f"`hard_stops.max_total_tokens` must be an integer, got {tok_raw!r}")
+ if max_total_usd < 0:
+ raise ConfigError("`hard_stops.max_total_usd` must be >= 0")
+ if max_total_tokens < 0:
+ raise ConfigError("`hard_stops.max_total_tokens` must be >= 0")
+ return HardStops(
+ max_iterations=max_iterations,
+ max_consecutive_failures=max_failures,
+ max_total_usd=max_total_usd,
+ max_total_tokens=max_total_tokens,
+ )
+
+
+def _parse_llm(value: Any) -> LLMConfig:
+ if not isinstance(value, Mapping):
+ raise ConfigError("`llm` must be a mapping")
+ provider = value.get("provider", "anthropic")
+ model = value.get("model", "claude-sonnet-4-6")
+ api_key_env = value.get("api_key_env", "ANTHROPIC_API_KEY")
+ for label, v in (("provider", provider), ("model", model), ("api_key_env", api_key_env)):
+ if not isinstance(v, str) or not v.strip():
+ raise ConfigError(f"`llm.{label}` must be a non-empty string")
+ return LLMConfig(provider=provider.strip(), model=model.strip(), api_key_env=api_key_env.strip())
+
+
+def _parse_coding_agent(value: Any) -> CodingAgentConfig:
+ if not isinstance(value, Mapping):
+ raise ConfigError("`coding_agent` must be a mapping")
+ tool = value.get("tool", "aider")
+ if not isinstance(tool, str) or not tool.strip():
+ raise ConfigError("`coding_agent.tool` must be a non-empty string")
+ return CodingAgentConfig(tool=tool.strip())
+
+
+def _parse_history(value: Any) -> HistoryConfig:
+ if not isinstance(value, Mapping):
+ raise ConfigError("`history` must be a mapping")
+ max_entries = value.get("max_entries", 10)
+ if not isinstance(max_entries, int) or isinstance(max_entries, bool) or max_entries < 1:
+ raise ConfigError("`history.max_entries` must be a positive integer")
+ return HistoryConfig(max_entries=max_entries)
diff --git a/evolution_kernel/governor.py b/evolution_kernel/governor.py
index b0ce49d..1583261 100644
--- a/evolution_kernel/governor.py
+++ b/evolution_kernel/governor.py
@@ -52,6 +52,7 @@ def __init__(
evidence_sources: Sequence[EvidenceSource] = (),
allowed_paths: Sequence[str] = (),
config_snapshot: Mapping[str, Any] | None = None,
+ history_max_entries: int = 10,
) -> None:
self.target_repo = Path(target_repo).resolve()
self.ledger_dir = Path(ledger_dir).resolve()
@@ -61,6 +62,7 @@ def __init__(
self.evidence_sources = tuple(evidence_sources)
self.allowed_paths = tuple(allowed_paths)
self.config_snapshot = dict(config_snapshot) if config_snapshot else None
+ self.history_max_entries = history_max_entries
def run_once(self, goal: Mapping[str, Any], run_id: str | None = None) -> RunResult:
self._ensure_git_repo()
@@ -96,6 +98,7 @@ def run_once(self, goal: Mapping[str, Any], run_id: str | None = None) -> RunRes
"ledger_dir": str(self.ledger_dir),
"observation_path": str(observation_path),
"allowed_paths": list(self.allowed_paths),
+ "history": self._build_history(),
},
)
self._run_role(self.planner, run_dir / "planner_input.json", run_dir / "plan.json", worktree)
@@ -182,12 +185,20 @@ def run_once(self, goal: Mapping[str, Any], run_id: str | None = None) -> RunRes
self._git("branch", "-f", ACCEPTED_BRANCH, candidate_commit)
self._record_accepted_commit()
+ # Pull plan summary for history — more informative than decision.reason.
+ plan_summary = ""
+ try:
+ plan_data = self._read_json(run_dir / "plan.json")
+ plan_summary = str(plan_data.get("summary", ""))
+ except Exception:
+ pass
self._write_json(
run_dir / "reflection.json",
{
"run_id": run_id,
"accepted": decision.accepted,
"reason": decision.reason,
+ "plan_summary": plan_summary,
"metrics": evaluation.get("metrics", {}),
"created_at": self._now(),
},
@@ -203,6 +214,28 @@ def run_once(self, goal: Mapping[str, Any], run_id: str | None = None) -> RunRes
if worktree.exists():
self._git("worktree", "remove", "--force", str(worktree))
+ def _build_history(self) -> list[dict]:
+ """Scan ledger for past run reflections; return most recent N entries."""
+ runs_dir = self.ledger_dir / "runs"
+ if not runs_dir.exists():
+ return []
+ entries = []
+ for run_dir in sorted(runs_dir.iterdir()):
+ reflection = run_dir / "reflection.json"
+ if not reflection.exists():
+ continue
+ try:
+ data = self._read_json(reflection)
+ entries.append({
+ "run_id": data.get("run_id", run_dir.name),
+ "accepted": data.get("accepted", False),
+ "summary": data.get("plan_summary") or data.get("reason", ""),
+ "metrics": data.get("metrics", {}),
+ })
+ except Exception:
+ pass
+ return entries[-self.history_max_entries:]
+
def _decide(
self,
evaluation: Mapping[str, Any],
diff --git a/evolution_kernel/hard_stops.py b/evolution_kernel/hard_stops.py
index 493df79..e26e57c 100644
--- a/evolution_kernel/hard_stops.py
+++ b/evolution_kernel/hard_stops.py
@@ -20,6 +20,8 @@
class HardStopState:
iterations: int = 0
consecutive_failures: int = 0
+ total_usd: float = 0.0
+ total_tokens: int = 0
halted: bool = False
halt_reason: str | None = None
@@ -31,6 +33,8 @@ def from_json(cls, data: Mapping[str, Any]) -> "HardStopState":
return cls(
iterations=int(data.get("iterations", 0)),
consecutive_failures=int(data.get("consecutive_failures", 0)),
+ total_usd=float(data.get("total_usd", 0.0)),
+ total_tokens=int(data.get("total_tokens", 0)),
halted=bool(data.get("halted", False)),
halt_reason=data.get("halt_reason"),
)
@@ -63,7 +67,14 @@ def save_state(ledger_dir: Path | str, state: HardStopState) -> None:
os.replace(tmp, p)
-def precheck(state: HardStopState, max_iterations: int, max_consecutive_failures: int) -> tuple[bool, str | None]:
+def precheck(
+ state: HardStopState,
+ max_iterations: int,
+ max_consecutive_failures: int,
+ *,
+ max_total_usd: float = 0.0,
+ max_total_tokens: int = 0,
+) -> tuple[bool, str | None]:
"""Return (allowed, reason). reason is None when allowed."""
if state.halted:
return False, state.halt_reason or "halted"
@@ -71,6 +82,10 @@ def precheck(state: HardStopState, max_iterations: int, max_consecutive_failures
return False, f"max_iterations reached ({max_iterations})"
if state.consecutive_failures >= max_consecutive_failures:
return False, f"max_consecutive_failures reached ({max_consecutive_failures})"
+ if max_total_usd > 0 and state.total_usd >= max_total_usd:
+ return False, f"max_total_usd reached ({max_total_usd})"
+ if max_total_tokens > 0 and state.total_tokens >= max_total_tokens:
+ return False, f"max_total_tokens reached ({max_total_tokens})"
return True, None
@@ -80,9 +95,15 @@ def record_outcome(
accepted: bool,
max_iterations: int,
max_consecutive_failures: int,
+ cost_usd: float = 0.0,
+ tokens_used: int = 0,
+ max_total_usd: float = 0.0,
+ max_total_tokens: int = 0,
) -> HardStopState:
"""Update counters after a run; mark halted if any limit just tripped."""
state.iterations += 1
+ state.total_usd += cost_usd
+ state.total_tokens += tokens_used
if accepted:
state.consecutive_failures = 0
else:
@@ -93,6 +114,12 @@ def record_outcome(
elif state.consecutive_failures >= max_consecutive_failures:
state.halted = True
state.halt_reason = f"max_consecutive_failures reached ({max_consecutive_failures})"
+ elif max_total_usd > 0 and state.total_usd >= max_total_usd:
+ state.halted = True
+ state.halt_reason = f"max_total_usd reached ({max_total_usd:.4f})"
+ elif max_total_tokens > 0 and state.total_tokens >= max_total_tokens:
+ state.halted = True
+ state.halt_reason = f"max_total_tokens reached ({max_total_tokens})"
return state
diff --git a/examples/evolution.yml b/examples/evolution.yml
index eceedbc..8abf941 100644
--- a/examples/evolution.yml
+++ b/examples/evolution.yml
@@ -1,5 +1,19 @@
mission: "Improve the demo target so its evaluator passes a simple metric check, under strict reproducibility constraints."
+# LLM configuration — all role scripts read this via config.json in the run dir.
+llm:
+ provider: anthropic # anthropic | openai
+ model: claude-sonnet-4-6
+ api_key_env: ANTHROPIC_API_KEY # name of the env var holding the key
+
+# Coding agent used by roles/executor.sh
+coding_agent:
+ tool: aider # aider | claude-code
+
+# How many past run reflections to inject into each planner call
+history:
+ max_entries: 10
+
evidence_sources:
- type: file
path: "metrics.json"
@@ -11,10 +25,12 @@ mutation_scope:
- "src/"
hard_stops:
- max_iterations: 3
- max_consecutive_failures: 2
+ max_iterations: 10
+ max_consecutive_failures: 3
+ max_total_usd: 1.00 # stop if total LLM spend reaches $1
+ max_total_tokens: 500000 # stop if total tokens reaches 500k
roles:
- planner: ["python3", "bots/planner.py"]
- executor: ["python3", "bots/executor.py"]
- evaluator: ["python3", "bots/evaluator.py"]
+ planner: ["python3", "roles/planner.py"]
+ executor: ["bash", "roles/executor.sh"]
+ evaluator: ["python3", "roles/evaluator.py"]
diff --git a/roles/evaluator.py b/roles/evaluator.py
new file mode 100755
index 0000000..1219394
--- /dev/null
+++ b/roles/evaluator.py
@@ -0,0 +1,124 @@
+#!/usr/bin/env python3
+"""LLM evaluator role.
+
+Reads evaluator_input.json, calls an LLM to judge accept/reject, writes evaluation.json.
+LLM provider/model are read from config.json in the same run directory.
+Reports cost_usd and tokens_used so the kernel can enforce cost guards.
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import re
+import sys
+from pathlib import Path
+
+
+def _call_llm(prompt: str, cfg: dict) -> tuple[str, int, float]:
+ provider = cfg.get("provider", "anthropic")
+ model = cfg.get("model", "claude-sonnet-4-6")
+ api_key_env = cfg.get("api_key_env", "ANTHROPIC_API_KEY")
+
+ if provider == "anthropic":
+ import anthropic # type: ignore
+ client = anthropic.Anthropic(api_key=os.environ[api_key_env])
+ msg = client.messages.create(
+ model=model,
+ max_tokens=512,
+ messages=[{"role": "user", "content": prompt}],
+ )
+ tokens = msg.usage.input_tokens + msg.usage.output_tokens
+ return msg.content[0].text, tokens, tokens * 3e-6
+
+ if provider == "openai":
+ import openai # type: ignore
+ client = openai.OpenAI(api_key=os.environ[api_key_env])
+ resp = client.chat.completions.create(
+ model=model,
+ messages=[{"role": "user", "content": prompt}],
+ )
+ tokens = resp.usage.total_tokens
+ return resp.choices[0].message.content, tokens, tokens * 3e-6
+
+ raise ValueError(f"unknown llm.provider: {provider!r}")
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--input", required=True)
+ parser.add_argument("--output", required=True)
+ parser.add_argument("--worktree", required=True)
+ args = parser.parse_args()
+
+ inp = json.loads(Path(args.input).read_text(encoding="utf-8"))
+
+ run_dir = Path(args.input).parent
+ cfg = {}
+ config_path = run_dir / "config.json"
+ if config_path.exists():
+ cfg = json.loads(config_path.read_text(encoding="utf-8"))
+ llm_cfg = cfg.get("llm", {})
+
+ goal = inp.get("goal", {})
+ patch_path = inp.get("patch_path", "")
+ obs_path = inp.get("observation_path", "")
+
+ patch_text = ""
+ if patch_path and Path(patch_path).exists():
+ patch_text = Path(patch_path).read_text(encoding="utf-8")[:3000]
+
+ obs_text = ""
+ if obs_path and Path(obs_path).exists():
+ obs_text = Path(obs_path).read_text(encoding="utf-8")[:1000]
+
+ prompt = f"""You are a code evolution evaluator. Decide whether to ACCEPT or REJECT a candidate change.
+
+Goal: {goal.get("objective", goal.get("name", ""))}
+
+Observation (current state):
+{obs_text or "(none)"}
+
+Patch applied:
+{patch_text or "(no changes)"}
+
+Respond with ONLY a JSON object:
+- "hard_gates_passed": true if the change is safe and relevant, false otherwise
+- "recommendation": "accept" or "reject"
+- "reason": one sentence explaining your decision
+- "metrics": {{}} (optional key/value metrics you can infer)
+"""
+
+ try:
+ text, tokens, cost = _call_llm(prompt, llm_cfg)
+ m = re.search(r"\{.*\}", text, re.DOTALL)
+ if m:
+ result = json.loads(m.group())
+ else:
+ result = {
+ "hard_gates_passed": False,
+ "recommendation": "reject",
+ "reason": f"evaluator could not parse LLM response: {text[:100]}",
+ "metrics": {},
+ }
+ except Exception as exc:
+ result = {
+ "hard_gates_passed": False,
+ "recommendation": "reject",
+ "reason": f"evaluator error: {exc}",
+ "metrics": {},
+ }
+ tokens, cost = 0, 0.0
+
+ result.setdefault("hard_gates_passed", False)
+ result.setdefault("recommendation", "reject")
+ result.setdefault("reason", "")
+ result.setdefault("metrics", {})
+ result["cost_usd"] = cost
+ result["tokens_used"] = tokens
+
+ Path(args.output).write_text(json.dumps(result, indent=2, sort_keys=True) + "\n", encoding="utf-8")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/roles/executor.sh b/roles/executor.sh
new file mode 100755
index 0000000..f6f7e49
--- /dev/null
+++ b/roles/executor.sh
@@ -0,0 +1,69 @@
+#!/usr/bin/env bash
+# Executor role: calls a configurable coding agent to apply the plan.
+#
+# Reads executor_input.json (--input), writes executor_output.json (--output).
+# Coding agent is selected via `coding_agent.tool` in config.json (same run dir).
+# Supported tools: aider | claude-code
+#
+# Usage: executor.sh --input --output --worktree
+set -euo pipefail
+
+INPUT="" OUTPUT="" WORKTREE=""
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --input) INPUT="$2"; shift 2 ;;
+ --output) OUTPUT="$2"; shift 2 ;;
+ --worktree) WORKTREE="$2"; shift 2 ;;
+ *) echo "unknown arg: $1" >&2; exit 1 ;;
+ esac
+done
+
+[[ -n "$INPUT" && -n "$OUTPUT" && -n "$WORKTREE" ]] || { echo "missing required args" >&2; exit 1; }
+
+RUN_DIR="$(dirname "$INPUT")"
+
+# Load plan
+PLAN_PATH="$(python3 -c "import json,sys; d=json.load(open('$INPUT')); print(d.get('plan_path',''))" 2>/dev/null || echo "")"
+if [[ -z "$PLAN_PATH" || ! -f "$PLAN_PATH" ]]; then
+ PLAN_PATH="$RUN_DIR/plan.json"
+fi
+SUMMARY="$(python3 -c "import json; d=json.load(open('$PLAN_PATH')); print(d.get('summary','improve the codebase'))" 2>/dev/null || echo "improve the codebase")"
+STEPS="$(python3 -c "import json; d=json.load(open('$PLAN_PATH')); print('\n'.join(d.get('steps',[])) or 'Apply the plan.')" 2>/dev/null || echo "Apply the plan.")"
+
+# Load coding agent tool from config.json
+TOOL="aider"
+CONFIG_PATH="$RUN_DIR/config.json"
+if [[ -f "$CONFIG_PATH" ]]; then
+ TOOL="$(python3 -c "import json; d=json.load(open('$CONFIG_PATH')); print(d.get('coding_agent',{}).get('tool','aider'))" 2>/dev/null || echo "aider")"
+fi
+
+PROMPT="$SUMMARY
+
+Steps:
+$STEPS
+
+Important: only modify files within the allowed paths specified in the plan."
+
+cd "$WORKTREE"
+
+case "$TOOL" in
+ aider)
+ aider --message "$PROMPT" --yes --no-pretty --auto-commits=false 2>&1 || true
+ ;;
+ claude-code)
+ claude -p "$PROMPT" 2>&1 || true
+ ;;
+ *)
+ echo "error: unknown coding_agent.tool: $TOOL" >&2
+ exit 1
+ ;;
+esac
+
+CHANGED=$(git status --porcelain 2>/dev/null | wc -l | tr -d ' ')
+# Use jq to safely encode strings — avoids shell-quoting bugs when LLM output
+# contains quotes, backslashes, or other special characters.
+jq -n \
+ --argjson changed "$CHANGED" \
+ --arg tool "$TOOL" \
+ --arg summary "$SUMMARY" \
+ '{"changed_files": $changed, "tool": $tool, "summary": $summary}' > "$OUTPUT"
diff --git a/roles/planner.py b/roles/planner.py
new file mode 100755
index 0000000..ec2f14f
--- /dev/null
+++ b/roles/planner.py
@@ -0,0 +1,137 @@
+#!/usr/bin/env python3
+"""LLM planner role.
+
+Reads planner_input.json, calls an LLM to produce a plan, writes plan.json.
+LLM provider/model are read from config.json in the same run directory.
+
+Config keys used (under `llm:`):
+ provider: anthropic (default) | openai
+ model: e.g. claude-sonnet-4-6 or gpt-4o
+ api_key_env: name of the env var holding the API key
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import re
+import sys
+from pathlib import Path
+
+
+def _call_anthropic(prompt: str, model: str, api_key_env: str) -> tuple[str, int, float]:
+ import anthropic # type: ignore
+ client = anthropic.Anthropic(api_key=os.environ[api_key_env])
+ msg = client.messages.create(
+ model=model,
+ max_tokens=1024,
+ messages=[{"role": "user", "content": prompt}],
+ )
+ tokens = msg.usage.input_tokens + msg.usage.output_tokens
+ # Approximate cost — exact pricing varies by model; callers may override.
+ cost = tokens * 3e-6
+ return msg.content[0].text, tokens, cost
+
+
+def _call_openai(prompt: str, model: str, api_key_env: str) -> tuple[str, int, float]:
+ import openai # type: ignore
+ client = openai.OpenAI(api_key=os.environ[api_key_env])
+ resp = client.chat.completions.create(
+ model=model,
+ messages=[{"role": "user", "content": prompt}],
+ )
+ tokens = resp.usage.total_tokens
+ cost = tokens * 3e-6
+ return resp.choices[0].message.content, tokens, cost
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--input", required=True)
+ parser.add_argument("--output", required=True)
+ parser.add_argument("--worktree", required=True)
+ args = parser.parse_args()
+
+ inp = json.loads(Path(args.input).read_text(encoding="utf-8"))
+
+ # Load LLM config from run-dir config.json (written by governor).
+ run_dir = Path(args.input).parent
+ cfg = {}
+ config_path = run_dir / "config.json"
+ if config_path.exists():
+ cfg = json.loads(config_path.read_text(encoding="utf-8"))
+ llm_cfg = cfg.get("llm", {})
+ provider = llm_cfg.get("provider", "anthropic")
+ model = llm_cfg.get("model", "claude-sonnet-4-6")
+ api_key_env = llm_cfg.get("api_key_env", "ANTHROPIC_API_KEY")
+
+ goal = inp.get("goal", {})
+ allowed_paths = inp.get("allowed_paths", [])
+ history = inp.get("history", [])
+
+ obs_text = ""
+ obs_path = inp.get("observation_path", "")
+ if obs_path and Path(obs_path).exists():
+ obs_text = Path(obs_path).read_text(encoding="utf-8")
+
+ history_text = "\n".join(
+ f"- Run {h['run_id']}: {'ACCEPTED' if h.get('accepted') else 'REJECTED'} — {h.get('summary', '')}"
+ for h in history
+ ) or "(no history yet — this is the first run)"
+
+ prompt = f"""You are a code evolution planner. Produce a concrete plan to make progress toward the goal.
+
+Goal: {goal.get("objective", goal.get("name", ""))}
+
+Current observation:
+{obs_text or "(none)"}
+
+Allowed paths (ONLY modify files under these paths):
+{json.dumps(allowed_paths)}
+
+Previous attempts:
+{history_text}
+
+Respond with ONLY a JSON object containing:
+- "summary": one-line description of the change
+- "steps": list of concrete implementation steps
+- "expected_improvement": what should improve after this change
+- "allowed_paths": paths to be modified (must be a subset of the allowed list above)
+- "abort": false (set true only if you have absolutely no viable approach)
+"""
+
+ if provider == "anthropic":
+ text, tokens, cost = _call_anthropic(prompt, model, api_key_env)
+ elif provider == "openai":
+ text, tokens, cost = _call_openai(prompt, model, api_key_env)
+ else:
+ print(f"error: unknown llm.provider: {provider!r}", file=sys.stderr)
+ sys.exit(1)
+
+ m = re.search(r"\{.*\}", text, re.DOTALL)
+ plan = None
+ if m:
+ try:
+ plan = json.loads(m.group())
+ except json.JSONDecodeError:
+ pass
+ if plan is None:
+ plan = {
+ "summary": text[:200],
+ "steps": [text],
+ "expected_improvement": "",
+ "allowed_paths": allowed_paths,
+ "abort": False,
+ }
+
+ plan.setdefault("run_id", inp.get("run_id", ""))
+ plan.setdefault("abort", False)
+ plan.setdefault("allowed_paths", allowed_paths)
+ plan["_tokens_used"] = tokens
+ plan["_cost_usd"] = cost
+
+ Path(args.output).write_text(json.dumps(plan, indent=2, sort_keys=True) + "\n", encoding="utf-8")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/test_pr4.py b/tests/test_pr4.py
new file mode 100644
index 0000000..0fa9682
--- /dev/null
+++ b/tests/test_pr4.py
@@ -0,0 +1,262 @@
+"""Tests for PR4 features: cost guard, history injection, --loop flag, new config fields."""
+from __future__ import annotations
+
+import json
+import subprocess
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+from evolution_kernel import hard_stops
+from evolution_kernel.config import parse_config
+from evolution_kernel.governor import ACCEPTED_BRANCH, Governor, RoleCommand
+
+
+ROOT = Path(__file__).resolve().parents[1]
+FIXTURES = ROOT / "tests" / "fixtures"
+
+
+def _git(args, cwd):
+ result = subprocess.run(["git", *args], cwd=cwd, text=True, capture_output=True, check=False)
+ if result.returncode != 0:
+ raise AssertionError(f"git {' '.join(args)} failed: {result.stderr}")
+ return result.stdout.strip()
+
+
+def _bootstrap_repo(repo: Path) -> None:
+ repo.mkdir(parents=True, exist_ok=True)
+ _git(["init"], repo)
+ _git(["config", "user.email", "test@example.com"], repo)
+ _git(["config", "user.name", "Test"], repo)
+ (repo / "README.md").write_text("# target\n")
+ src = repo / "src"
+ src.mkdir(exist_ok=True)
+ (src / ".gitkeep").write_text("")
+ _git(["add", "-A"], repo)
+ _git(["commit", "-m", "initial"], repo)
+
+
+def _role(name: str) -> RoleCommand:
+ return RoleCommand([sys.executable, str(FIXTURES / name)])
+
+
+# ---------------------------------------------------------------------------
+# Config parsing — new fields
+# ---------------------------------------------------------------------------
+
+class TestNewConfigFields(unittest.TestCase):
+
+ def test_cost_guard_defaults(self):
+ cfg = parse_config({"mission": "x"})
+ self.assertEqual(cfg.hard_stops.max_total_usd, 0.0)
+ self.assertEqual(cfg.hard_stops.max_total_tokens, 0)
+
+ def test_cost_guard_values(self):
+ cfg = parse_config({"mission": "x", "hard_stops": {
+ "max_iterations": 5, "max_consecutive_failures": 2,
+ "max_total_usd": 1.5, "max_total_tokens": 100000,
+ }})
+ self.assertAlmostEqual(cfg.hard_stops.max_total_usd, 1.5)
+ self.assertEqual(cfg.hard_stops.max_total_tokens, 100000)
+
+ def test_llm_defaults(self):
+ cfg = parse_config({"mission": "x"})
+ self.assertEqual(cfg.llm.provider, "anthropic")
+ self.assertEqual(cfg.llm.model, "claude-sonnet-4-6")
+ self.assertEqual(cfg.llm.api_key_env, "ANTHROPIC_API_KEY")
+
+ def test_llm_custom(self):
+ cfg = parse_config({"mission": "x", "llm": {
+ "provider": "openai", "model": "gpt-4o", "api_key_env": "OPENAI_API_KEY",
+ }})
+ self.assertEqual(cfg.llm.provider, "openai")
+ self.assertEqual(cfg.llm.model, "gpt-4o")
+
+ def test_coding_agent_default(self):
+ cfg = parse_config({"mission": "x"})
+ self.assertEqual(cfg.coding_agent.tool, "aider")
+
+ def test_coding_agent_claude_code(self):
+ cfg = parse_config({"mission": "x", "coding_agent": {"tool": "claude-code"}})
+ self.assertEqual(cfg.coding_agent.tool, "claude-code")
+
+ def test_history_defaults(self):
+ cfg = parse_config({"mission": "x"})
+ self.assertEqual(cfg.history.max_entries, 10)
+
+ def test_history_custom(self):
+ cfg = parse_config({"mission": "x", "history": {"max_entries": 5}})
+ self.assertEqual(cfg.history.max_entries, 5)
+
+
+# ---------------------------------------------------------------------------
+# Hard stops — cost guard
+# ---------------------------------------------------------------------------
+
+class TestCostGuard(unittest.TestCase):
+
+ def test_precheck_blocks_on_usd(self):
+ state = hard_stops.HardStopState(total_usd=1.0)
+ allowed, reason = hard_stops.precheck(state, 10, 3, max_total_usd=1.0)
+ self.assertFalse(allowed)
+ self.assertIn("max_total_usd", reason)
+
+ def test_precheck_blocks_on_tokens(self):
+ state = hard_stops.HardStopState(total_tokens=500000)
+ allowed, reason = hard_stops.precheck(state, 10, 3, max_total_tokens=500000)
+ self.assertFalse(allowed)
+ self.assertIn("max_total_tokens", reason)
+
+ def test_precheck_allows_below_limit(self):
+ state = hard_stops.HardStopState(total_usd=0.5, total_tokens=100)
+ allowed, _ = hard_stops.precheck(state, 10, 3, max_total_usd=1.0, max_total_tokens=500000)
+ self.assertTrue(allowed)
+
+ def test_record_outcome_accumulates_cost(self):
+ state = hard_stops.HardStopState()
+ state = hard_stops.record_outcome(
+ state, accepted=True, max_iterations=10, max_consecutive_failures=3,
+ cost_usd=0.05, tokens_used=1000,
+ )
+ self.assertAlmostEqual(state.total_usd, 0.05)
+ self.assertEqual(state.total_tokens, 1000)
+
+ def test_record_outcome_halts_on_usd(self):
+ state = hard_stops.HardStopState(total_usd=0.95)
+ state = hard_stops.record_outcome(
+ state, accepted=True, max_iterations=10, max_consecutive_failures=3,
+ cost_usd=0.10, tokens_used=0, max_total_usd=1.0,
+ )
+ self.assertTrue(state.halted)
+ self.assertIn("max_total_usd", state.halt_reason)
+
+ def test_record_outcome_halts_on_tokens(self):
+ state = hard_stops.HardStopState(total_tokens=490000)
+ state = hard_stops.record_outcome(
+ state, accepted=True, max_iterations=10, max_consecutive_failures=3,
+ tokens_used=20000, max_total_tokens=500000,
+ )
+ self.assertTrue(state.halted)
+ self.assertIn("max_total_tokens", state.halt_reason)
+
+ def test_state_persists_cost_fields(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ ledger = Path(tmp)
+ state = hard_stops.HardStopState(total_usd=0.12, total_tokens=4500)
+ hard_stops.save_state(ledger, state)
+ loaded = hard_stops.load_state(ledger)
+ self.assertAlmostEqual(loaded.total_usd, 0.12)
+ self.assertEqual(loaded.total_tokens, 4500)
+
+
+# ---------------------------------------------------------------------------
+# Governor — history injection
+# ---------------------------------------------------------------------------
+
+class TestHistoryInjection(unittest.TestCase):
+
+ def setUp(self):
+ self._tmp = tempfile.TemporaryDirectory()
+ self.base = Path(self._tmp.name)
+ self.repo = self.base / "repo"
+ self.ledger = self.base / "ledger"
+ _bootstrap_repo(self.repo)
+
+ def tearDown(self):
+ self._tmp.cleanup()
+
+ def _make_governor(self, max_entries: int = 10) -> Governor:
+ return Governor(
+ target_repo=self.repo,
+ ledger_dir=self.ledger,
+ planner=_role("planner.py"),
+ executor=_role("executor.py"),
+ evaluator=_role("evaluator_accept.py"),
+ allowed_paths=["src/"],
+ history_max_entries=max_entries,
+ )
+
+ def test_first_run_has_empty_history(self):
+ gov = self._make_governor()
+ gov.run_once({"name": "test", "objective": "test"})
+ planner_input = json.loads(
+ (self.ledger / "runs" / "0001" / "planner_input.json").read_text()
+ )
+ self.assertEqual(planner_input["history"], [])
+
+ def test_second_run_sees_first_run_in_history(self):
+ gov = self._make_governor()
+ gov.run_once({"name": "test", "objective": "test"})
+ gov.run_once({"name": "test", "objective": "test"})
+ planner_input = json.loads(
+ (self.ledger / "runs" / "0002" / "planner_input.json").read_text()
+ )
+ self.assertEqual(len(planner_input["history"]), 1)
+ self.assertEqual(planner_input["history"][0]["run_id"], "0001")
+
+ def test_history_capped_by_max_entries(self):
+ gov = self._make_governor(max_entries=2)
+ for _ in range(4):
+ gov.run_once({"name": "test", "objective": "test"})
+ planner_input = json.loads(
+ (self.ledger / "runs" / "0004" / "planner_input.json").read_text()
+ )
+ self.assertLessEqual(len(planner_input["history"]), 2)
+
+
+# ---------------------------------------------------------------------------
+# CLI — --loop flag
+# ---------------------------------------------------------------------------
+
+class TestLoopFlag(unittest.TestCase):
+
+ def setUp(self):
+ self._tmp = tempfile.TemporaryDirectory()
+ self.base = Path(self._tmp.name)
+ self.repo = self.base / "repo"
+ self.ledger = str(self.base / "ledger")
+ _bootstrap_repo(self.repo)
+
+ # Write a minimal config that uses fixture roles
+ self.config_path = self.base / "evolution.yml"
+ # No allowed_paths restriction so fixture executor (writes EVOLUTION_MARKER.txt) is in scope.
+ self.config_path.write_text(f"""
+mission: "test loop"
+hard_stops:
+ max_iterations: 3
+ max_consecutive_failures: 5
+roles:
+ planner: ["python3", "{FIXTURES}/planner.py"]
+ executor: ["python3", "{FIXTURES}/executor.py"]
+ evaluator: ["python3", "{FIXTURES}/evaluator_accept.py"]
+""")
+
+ def tearDown(self):
+ self._tmp.cleanup()
+
+ def _run_cli(self, *extra_args):
+ from evolution_kernel.cli import main
+ return main([
+ "--config", str(self.config_path),
+ "--repo", str(self.repo),
+ "--ledger", self.ledger,
+ *extra_args,
+ ])
+
+ def test_loop_runs_until_max_iterations(self):
+ rc = self._run_cli("--loop")
+ self.assertEqual(rc, 3) # halted → exit 3
+ # max_iterations=3, so 3 run dirs should exist
+ runs = list((Path(self.ledger) / "runs").iterdir())
+ self.assertEqual(len(runs), 3)
+
+ def test_loop_state_halted_after_completion(self):
+ self._run_cli("--loop")
+ state = hard_stops.load_state(self.ledger)
+ self.assertTrue(state.halted)
+ self.assertIn("max_iterations", state.halt_reason or "")
+
+
+if __name__ == "__main__":
+ unittest.main()