Skip to content

Commit 683ec6e

Browse files
feat: goal evaluator + strategist (closes #10) (#13)
Two new optional roles that let an evolution session stop on success instead of running to max_iterations: - goal_evaluator: after each accepted run, asks an LLM whether the mission is complete; on goal_reached=true the CLI exits 0 with halted=false. Wired in config under `goal_evaluator.enabled` and `roles.goal_evaluator`. - strategist: every N rounds, produces stage / next_milestone / taboo_directions and injects them into the next planner_input.json. Wired in config under `strategist.enabled`, `strategist.every_n_rounds`, and `roles.strategist`. Hard stop machinery is untouched; both roles are off by default so existing configs keep behaving the same. 15 new tests; full suite 54/54 passing.
1 parent 3cee524 commit 683ec6e

9 files changed

Lines changed: 637 additions & 18 deletions

File tree

‎evolution_kernel/cli.py‎

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
import argparse
1818
import json
19+
import subprocess
1920
import sys
2021
from datetime import datetime, timezone
2122
from pathlib import Path
@@ -144,7 +145,10 @@ def _run_loop(
144145
governor: Governor,
145146
goal: dict,
146147
) -> int:
147-
"""Run until hard stops trigger. Each iteration saves state immediately."""
148+
"""Run until hard stops trigger or goal is reached."""
149+
iteration = 0
150+
pending_strategy: dict | None = None
151+
148152
while True:
149153
state = hard_stops.load_state(args.ledger)
150154
allowed, why = hard_stops.precheck(
@@ -159,7 +163,10 @@ def _run_loop(
159163
print(json.dumps({"halted": True, "reason": why}, indent=2, sort_keys=True))
160164
return 3
161165

162-
result = governor.run_once(goal)
166+
result = governor.run_once(goal, strategy=pending_strategy)
167+
pending_strategy = None
168+
iteration += 1
169+
163170
cost_usd, tokens_used = _safe_cost(result.evaluation)
164171
new_state = hard_stops.record_outcome(
165172
state,
@@ -173,6 +180,15 @@ def _run_loop(
173180
)
174181
hard_stops.save_state(args.ledger, new_state)
175182
_print_result(result, halted=new_state.halted, halt_reason=new_state.halt_reason)
183+
184+
if result.decision.accepted and cfg.goal_evaluator.enabled and cfg.roles.goal_evaluator:
185+
if _check_goal_reached(cfg, result):
186+
print(json.dumps({"goal_reached": True, "halted": False}, indent=2, sort_keys=True))
187+
return 0
188+
189+
if cfg.strategist.enabled and cfg.roles.strategist and iteration % cfg.strategist.every_n_rounds == 0:
190+
pending_strategy = _invoke_strategist(cfg, result, iteration)
191+
176192
if new_state.halted:
177193
_record_halted(args.ledger, new_state, new_state.halt_reason)
178194
return 3
@@ -250,5 +266,53 @@ def _print_result(result, *, halted: bool = False, halt_reason: str | None = Non
250266
print(json.dumps(payload, indent=2, sort_keys=True))
251267

252268

269+
def _check_goal_reached(cfg: EvolutionConfig, result) -> bool:
270+
input_path = result.run_dir / "goal_eval_input.json"
271+
output_path = result.run_dir / "goal_evaluation.json"
272+
input_data = {
273+
"mission": cfg.mission,
274+
"latest_evaluation": dict(result.evaluation),
275+
}
276+
input_path.write_text(json.dumps(input_data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
277+
argv = [
278+
*cfg.roles.goal_evaluator,
279+
"--input", str(input_path),
280+
"--output", str(output_path),
281+
"--worktree", str(result.run_dir),
282+
]
283+
completed = subprocess.run(argv, text=True, capture_output=True, check=False)
284+
if completed.returncode != 0 or not output_path.exists():
285+
return False
286+
try:
287+
data = json.loads(output_path.read_text(encoding="utf-8"))
288+
return bool(data.get("goal_reached", False))
289+
except (json.JSONDecodeError, OSError):
290+
return False
291+
292+
293+
def _invoke_strategist(cfg: EvolutionConfig, result, iteration: int) -> dict | None:
294+
input_path = result.run_dir / "strategist_input.json"
295+
output_path = result.run_dir / "strategy.json"
296+
input_data = {
297+
"mission": cfg.mission,
298+
"current_round": iteration,
299+
"latest_evaluation": dict(result.evaluation),
300+
}
301+
input_path.write_text(json.dumps(input_data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
302+
argv = [
303+
*cfg.roles.strategist,
304+
"--input", str(input_path),
305+
"--output", str(output_path),
306+
"--worktree", str(result.run_dir),
307+
]
308+
completed = subprocess.run(argv, text=True, capture_output=True, check=False)
309+
if completed.returncode != 0 or not output_path.exists():
310+
return None
311+
try:
312+
return json.loads(output_path.read_text(encoding="utf-8"))
313+
except (json.JSONDecodeError, OSError):
314+
return None
315+
316+
253317
if __name__ == "__main__":
254318
raise SystemExit(main())

‎evolution_kernel/config.py‎

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ class Roles:
7474
planner: tuple[str, ...] = ()
7575
executor: tuple[str, ...] = ()
7676
evaluator: tuple[str, ...] = ()
77+
goal_evaluator: tuple[str, ...] = ()
78+
strategist: tuple[str, ...] = ()
7779

7880

7981
@dataclass(frozen=True)
@@ -93,6 +95,17 @@ class HistoryConfig:
9395
max_entries: int = 10
9496

9597

98+
@dataclass(frozen=True)
99+
class GoalEvaluatorConfig:
100+
enabled: bool = False
101+
102+
103+
@dataclass(frozen=True)
104+
class StrategistConfig:
105+
enabled: bool = False
106+
every_n_rounds: int = 3
107+
108+
96109
@dataclass(frozen=True)
97110
class EvolutionConfig:
98111
mission: str
@@ -103,6 +116,8 @@ class EvolutionConfig:
103116
llm: LLMConfig = field(default_factory=LLMConfig)
104117
coding_agent: CodingAgentConfig = field(default_factory=CodingAgentConfig)
105118
history: HistoryConfig = field(default_factory=HistoryConfig)
119+
goal_evaluator: GoalEvaluatorConfig = field(default_factory=GoalEvaluatorConfig)
120+
strategist: StrategistConfig = field(default_factory=StrategistConfig)
106121
raw: Mapping[str, Any] = field(default_factory=dict)
107122

108123

@@ -135,6 +150,8 @@ def parse_config(raw: Mapping[str, Any]) -> EvolutionConfig:
135150
llm = _parse_llm(raw.get("llm", {}))
136151
coding_agent = _parse_coding_agent(raw.get("coding_agent", {}))
137152
history = _parse_history(raw.get("history", {}))
153+
goal_evaluator = _parse_goal_evaluator(raw.get("goal_evaluator", {}))
154+
strategist = _parse_strategist(raw.get("strategist", {}))
138155

139156
return EvolutionConfig(
140157
mission=mission.strip(),
@@ -145,6 +162,8 @@ def parse_config(raw: Mapping[str, Any]) -> EvolutionConfig:
145162
llm=llm,
146163
coding_agent=coding_agent,
147164
history=history,
165+
goal_evaluator=goal_evaluator,
166+
strategist=strategist,
148167
raw=dict(raw),
149168
)
150169

@@ -212,7 +231,13 @@ def _argv(label: str) -> tuple[str, ...]:
212231
f"`roles.{label}` must be a string or a list of non-empty strings"
213232
)
214233

215-
return Roles(planner=_argv("planner"), executor=_argv("executor"), evaluator=_argv("evaluator"))
234+
return Roles(
235+
planner=_argv("planner"),
236+
executor=_argv("executor"),
237+
evaluator=_argv("evaluator"),
238+
goal_evaluator=_argv("goal_evaluator"),
239+
strategist=_argv("strategist"),
240+
)
216241

217242

218243
def _parse_hard_stops(value: Any) -> HardStops:
@@ -273,3 +298,18 @@ def _parse_history(value: Any) -> HistoryConfig:
273298
if not isinstance(max_entries, int) or isinstance(max_entries, bool) or max_entries < 1:
274299
raise ConfigError("`history.max_entries` must be a positive integer")
275300
return HistoryConfig(max_entries=max_entries)
301+
302+
303+
def _parse_goal_evaluator(value: Any) -> GoalEvaluatorConfig:
304+
if not isinstance(value, Mapping):
305+
raise ConfigError("`goal_evaluator` must be a mapping")
306+
return GoalEvaluatorConfig(enabled=bool(value.get("enabled", False)))
307+
308+
309+
def _parse_strategist(value: Any) -> StrategistConfig:
310+
if not isinstance(value, Mapping):
311+
raise ConfigError("`strategist` must be a mapping")
312+
every_n = value.get("every_n_rounds", 3)
313+
if not isinstance(every_n, int) or isinstance(every_n, bool) or every_n < 1:
314+
raise ConfigError("`strategist.every_n_rounds` must be a positive integer")
315+
return StrategistConfig(enabled=bool(value.get("enabled", False)), every_n_rounds=every_n)

‎evolution_kernel/governor.py‎

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ def __init__(
6464
self.config_snapshot = dict(config_snapshot) if config_snapshot else None
6565
self.history_max_entries = history_max_entries
6666

67-
def run_once(self, goal: Mapping[str, Any], run_id: str | None = None) -> RunResult:
67+
def run_once(self, goal: Mapping[str, Any], run_id: str | None = None, strategy: dict | None = None) -> RunResult:
6868
self._ensure_git_repo()
6969
self._ensure_accepted_branch()
7070

@@ -87,20 +87,20 @@ def run_once(self, goal: Mapping[str, Any], run_id: str | None = None) -> RunRes
8787
observation = collect_observation(self.evidence_sources, self.target_repo)
8888
write_observation(observation_path, observation)
8989

90-
self._write_json(
91-
run_dir / "planner_input.json",
92-
{
93-
"run_id": run_id,
94-
"goal": goal,
95-
"accepted_branch": ACCEPTED_BRANCH,
96-
"baseline_commit": baseline_commit,
97-
"worktree": str(worktree),
98-
"ledger_dir": str(self.ledger_dir),
99-
"observation_path": str(observation_path),
100-
"allowed_paths": list(self.allowed_paths),
101-
"history": self._build_history(),
102-
},
103-
)
90+
planner_input: dict = {
91+
"run_id": run_id,
92+
"goal": goal,
93+
"accepted_branch": ACCEPTED_BRANCH,
94+
"baseline_commit": baseline_commit,
95+
"worktree": str(worktree),
96+
"ledger_dir": str(self.ledger_dir),
97+
"observation_path": str(observation_path),
98+
"allowed_paths": list(self.allowed_paths),
99+
"history": self._build_history(),
100+
}
101+
if strategy is not None:
102+
planner_input["strategy"] = strategy
103+
self._write_json(run_dir / "planner_input.json", planner_input)
104104
self._run_role(self.planner, run_dir / "planner_input.json", run_dir / "plan.json", worktree)
105105

106106
self._write_json(

‎roles/goal_evaluator.py‎

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
#!/usr/bin/env python3
2+
"""Goal evaluator role.
3+
4+
Reads goal_eval_input.json, calls an LLM to decide whether the overall mission
5+
is complete, and writes goal_evaluation.json.
6+
7+
LLM provider/model are read from config.json in the same run directory (same
8+
pattern as roles/planner.py).
9+
"""
10+
from __future__ import annotations
11+
12+
import argparse
13+
import json
14+
import os
15+
import re
16+
import sys
17+
from pathlib import Path
18+
19+
20+
def _call_anthropic(prompt: str, model: str, api_key_env: str) -> str:
21+
import anthropic # type: ignore
22+
client = anthropic.Anthropic(api_key=os.environ[api_key_env])
23+
msg = client.messages.create(
24+
model=model,
25+
max_tokens=512,
26+
messages=[{"role": "user", "content": prompt}],
27+
)
28+
return msg.content[0].text
29+
30+
31+
def _call_openai(prompt: str, model: str, api_key_env: str) -> str:
32+
import openai # type: ignore
33+
client = openai.OpenAI(api_key=os.environ[api_key_env])
34+
resp = client.chat.completions.create(
35+
model=model,
36+
messages=[{"role": "user", "content": prompt}],
37+
)
38+
return resp.choices[0].message.content
39+
40+
41+
def main() -> None:
42+
parser = argparse.ArgumentParser()
43+
parser.add_argument("--input", required=True)
44+
parser.add_argument("--output", required=True)
45+
parser.add_argument("--worktree", required=True)
46+
args = parser.parse_args()
47+
48+
inp = json.loads(Path(args.input).read_text(encoding="utf-8"))
49+
50+
run_dir = Path(args.input).parent
51+
cfg = {}
52+
config_path = run_dir / "config.json"
53+
if config_path.exists():
54+
cfg = json.loads(config_path.read_text(encoding="utf-8"))
55+
llm_cfg = cfg.get("llm", {})
56+
provider = llm_cfg.get("provider", "anthropic")
57+
model = llm_cfg.get("model", "claude-sonnet-4-6")
58+
api_key_env = llm_cfg.get("api_key_env", "ANTHROPIC_API_KEY")
59+
60+
mission = inp.get("mission", "")
61+
latest_eval = inp.get("latest_evaluation", {})
62+
metrics = latest_eval.get("metrics", {})
63+
64+
prompt = f"""You are a goal evaluator for an automated code evolution system.
65+
66+
Mission: {mission}
67+
68+
Latest evaluation metrics:
69+
{json.dumps(metrics, indent=2)}
70+
71+
Based on the mission statement and the latest evaluation metrics, has the mission
72+
been fully accomplished?
73+
74+
Respond with ONLY a JSON object:
75+
- "goal_reached": true if the mission is fully accomplished, false otherwise
76+
- "confidence": a float between 0.0 and 1.0
77+
- "reason": one sentence explaining your decision
78+
"""
79+
80+
if provider == "anthropic":
81+
text = _call_anthropic(prompt, model, api_key_env)
82+
elif provider == "openai":
83+
text = _call_openai(prompt, model, api_key_env)
84+
else:
85+
print(f"error: unknown llm.provider: {provider!r}", file=sys.stderr)
86+
sys.exit(1)
87+
88+
m = re.search(r"\{.*\}", text, re.DOTALL)
89+
result = None
90+
if m:
91+
try:
92+
result = json.loads(m.group())
93+
except json.JSONDecodeError:
94+
pass
95+
if result is None:
96+
result = {"goal_reached": False, "confidence": 0.0, "reason": text[:200]}
97+
98+
result.setdefault("goal_reached", False)
99+
result.setdefault("confidence", 0.0)
100+
result.setdefault("reason", "")
101+
102+
Path(args.output).write_text(json.dumps(result, indent=2, sort_keys=True) + "\n", encoding="utf-8")
103+
104+
105+
if __name__ == "__main__":
106+
main()

0 commit comments

Comments
 (0)