Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
420 changes: 266 additions & 154 deletions README.md

Large diffs are not rendered by default.

419 changes: 266 additions & 153 deletions README.zh.md

Large diffs are not rendered by default.

124 changes: 97 additions & 27 deletions evolution_kernel/cli.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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")
Expand All @@ -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",
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -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,
Expand All @@ -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
Expand Down
96 changes: 93 additions & 3 deletions evolution_kernel/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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.
Expand Down Expand Up @@ -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)
Expand All @@ -61,13 +76,33 @@ 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
evidence_sources: tuple[EvidenceSource, ...] = ()
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)


Expand Down Expand Up @@ -97,13 +132,19 @@ 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(),
evidence_sources=evidence_sources,
mutation_scope=mutation_scope,
hard_stops=hard_stops,
roles=roles,
llm=llm,
coding_agent=coding_agent,
history=history,
raw=dict(raw),
)

Expand Down Expand Up @@ -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())


Comment on lines +257 to +259
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())
Comment on lines +263 to +266


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)
Loading
Loading