Skip to content

feat(v1): GEPA prompt optimization for native v1 environments#1952

Open
Ziems wants to merge 10 commits into
mainfrom
claude/gepa-verifiers-v1-cf6518
Open

feat(v1): GEPA prompt optimization for native v1 environments#1952
Ziems wants to merge 10 commits into
mainfrom
claude/gepa-verifiers-v1-cf6518

Conversation

@Ziems

@Ziems Ziems commented Jul 8, 2026

Copy link
Copy Markdown

What

Adds GEPA (Genetic-Pareto) system-prompt optimization for native v1 environments as a new gepa CLI command. The existing v0 path (vf-gepa / verifiers/gepa/) is untouched.

uv run gepa reverse-text-v1 --model google/gemini-3-flash-preview --num-train 20 --num-val 10

Why

GEPA only worked against the legacy v0 Environment.generate() API. v1 composes Taskset + Harness + Environment with no generate() and no dataset split, so there was no way to optimize a native v1 taskset. This wires GEPA into the v1 execution path (Environment.episode() / Episode.run() / Trace).

How

  • verifiers/v1/gepa/ — a self-contained package (adapter, config, dataset, reflection, runner); the CLI is a thin cli/gepa.py entrypoint. Imports nothing from v0.
  • run_gepa is synchronous, mirroring how v0 vf-gepa bridged sync GEPA to async rollouts: the third-party gepa.optimize() is blocking, so it runs on the main thread and the runner manages one event loop by hand — env.serving() is entered on it once, each rollout batch runs via loop.run_until_complete (GEPAv1Adapter.evaluate), and everything is torn down in finally. No worker thread, so a Ctrl-C raises straight through optimize() into that teardown. Candidates are injected via Task.model_copy(update={"system_prompt": ...}).
  • GEPAConfig inherits EnvConfig (like EvalConfig). CLI resolution reuses the shared cli/resolve.py path (with_positional_taskset / narrow_config / extract_id / references_config_file) exactly like eval and serve, so a leading bare taskset id works, -h renders narrowed taskset/harness help, and a missing taskset fails fast.
  • Reflection (teacher LM) is built through a vf Judge (Judge.complete), so it reuses the v1 client config — resolved API key, extra headers (e.g. Prime team billing) — instead of a hand-constructed client.
  • Output matches eval: every rollout's Trace streams to traces.jsonl via the same on_completeappend_trace hook run_eval uses (each trace records its candidate prompt); per-iteration progress logs through v1 logging; the best prompt prints to stdout. GEPA's own artifacts (candidates.json, run_log.json, …) land in the run dir too.
  • Task selection uses a shared verifiers/v1/utils/sampling.py::sample (the fixed-seed shuffle/slice, also adopted by run_eval / run_eval_server / validate / debug / the v0 bridge); split_tasks layers two disjoint train/val slices on top.
  • Graceful shutdown is the shared verifiers/v1/utils/interrupt.py::install_interrupt (renamed from install), now used by eval, gepa, validate, debug, and replay in place of one-off SIGTERM handlers.

Scope / non-goals

  • v1-native only — a legacy v0 --id env is rejected (use vf-gepa).
  • No multi-taskset optimization (v0's EnvGroup has no v1 equivalent); single taskset per run.
  • No env-server / --server worker pool — GEPA runs in-process.
  • Group-reward tasksets are rejected up front — GEPA scores one rollout per task (n=1) for the per-task scalar its pareto frontier needs, but @group_reward compares ≥2 rollouts of a task, so those tasksets can't be optimized this way.
  • Harnesses with no system-message input (APPENDS_SYSTEM_PROMPT=False, e.g. coding agents): the harness folds the candidate into the user prompt and warns (Harness.resolve_prompt owns that policy) rather than blocking. Tasksets that bake instructions into prompt instead of Task.system_prompt (e.g. gsm8k-v1) need --initial-prompt.

Testing

  • Verified live end-to-end against echo-v1 and reverse-text-v1 across multiple optimize iterations: rollouts stream to traces.jsonl, all traces scored with no errors, and GEPA proposes/optimizes the system prompt.
  • ruff + pre-commit clean.
  • Unit tests were removed for now (see review thread) — to be re-added against the reworked v1-native shape.

Files

  • verifiers/v1/gepa/adapter, config, dataset, reflection, runner
  • verifiers/v1/cli/gepa.py — the gepa entrypoint
  • verifiers/v1/utils/sampling.py — shared sample helper (also adopted by eval / server eval / validate / debug / legacy bridge)
  • verifiers/v1/utils/interrupt.pyinstall_interrupt shared graceful-shutdown handler (renamed; adopted by eval / validate / debug / replay / gepa)
  • verifiers/v1/cli/{eval/main,eval/runner,validate,debug,replay}.py, verifiers/v1/legacy.py — migrated onto the shared sample / install_interrupt helpers
  • pyproject.toml — register the gepa console script

Open question: docs & skill (feedback wanted)

Bugbot flagged that this doesn't update the user-facing docs (docs/) or the GEPA skill (skills/optimize-with-environments/SKILL.md), both of which currently describe the v0 prime gepa run / vf-gepa flow. I deliberately left those out of this PR and want a maintainer's call on where they should go, because I'm not sure of the intended structure:

  • docs/training.md has a "Prompt Optimization with prime gepa run" section. Should the v1 gepa command be (a) a new subsection there, (b) folded into the existing section as a v0/v1 split, or (c) documented wherever the other v1 CLIs (eval, serve, replay) are described? I couldn't find an existing v1-CLI doc home to match.
  • skills/optimize-with-environments/SKILL.md is v0-centric (prime gepa run, v0 env config). Should it gain a parallel v1 path, be rewritten around gepa + GEPAConfig, or stay v0-only for now?

Happy to do the doc/skill update in this PR or a follow-up once we agree on placement — just let me know the preferred structure.

🤖 Generated with Claude Code


Note

Medium Risk
Runs many paid model rollouts and bridges blocking GEPA to async v1 execution on one event loop; mitigations include upfront rejection of incompatible tasksets and shared eval-style teardown/interrupt handling.

Overview
Adds a native v1 GEPA path: new gepa console script and verifiers/v1/gepa/ (GEPAConfig, GEPAv1Adapter, train/val split, reflection via Judge, synchronous run_gepa driving gepa.api.optimize on the main thread with a hand-managed event loop and env.serving()). Candidates are applied by rebuilding tasks with an updated system_prompt; rollouts stream to traces.jsonl like eval. Legacy v0 envs and @group_reward tasksets are rejected at the CLI/dataset layer.

Refactors: verifiers/v1/utils/sampling.sample centralizes fixed-seed shuffle + limit (used by eval, server eval, debug, validate, legacy eval, and GEPA). install_interrupt replaces per-CLI SIGTERM hacks in eval, debug, validate, and replay (eval already used the shared helper; import renamed from install).

Reviewed by Cursor Bugbot for commit f64fc94. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

What

Adds GEPA (Genetic-Pareto) system-prompt optimization for native v1 environments as a new gepa CLI command. The existing v0 path (vf-gepa / verifiers/gepa/) is untouched.

uv run gepa reverse-text-v1 --model google/gemini-3-flash-preview --num-train 20 --num-val 10

Why

GEPA only worked against the legacy v0 Environment.generate() API. v1 composes Taskset + Harness + Environment with no generate() and no dataset split, so there was no way to optimize a native v1 taskset. This wires GEPA into the v1 execution path (Environment.episode() / Episode.run() / Trace).

How

  • verifiers/v1/gepa/ — a self-contained package (adapter, config, dataset, reflection, runner); the CLI is a thin cli/gepa.py entrypoint. Imports nothing from v0.
  • run_gepa is synchronous, mirroring how v0 vf-gepa bridged sync GEPA to async rollouts: the third-party gepa.optimize() is blocking, so it runs on the main thread and the runner manages one event loop by hand — env.serving() is entered on it once, each rollout batch runs via loop.run_until_complete (GEPAv1Adapter.evaluate), and everything is torn down in finally. No worker thread, so a Ctrl-C raises straight through optimize() into that teardown. Candidates are injected via Task.model_copy(update={"system_prompt": ...}).
  • GEPAConfig inherits EnvConfig (like EvalConfig). CLI resolution reuses the shared cli/resolve.py path (with_positional_taskset / narrow_config / extract_id / references_config_file) exactly like eval and serve, so a leading bare taskset id works, -h renders narrowed taskset/harness help, and a missing taskset fails fast.
  • Reflection (teacher LM) is built through a vf Judge (Judge.complete), so it reuses the v1 client config — resolved API key, extra headers (e.g. Prime team billing) — instead of a hand-constructed client.
  • Output matches eval: every rollout's Trace streams to traces.jsonl via the same on_completeappend_trace hook run_eval uses (each trace records its candidate prompt); per-iteration progress logs through v1 logging; the best prompt prints to stdout. GEPA's own artifacts (candidates.json, run_log.json, …) land in the run dir too.
  • Task selection uses a shared verifiers/v1/utils/sampling.py::sample (the fixed-seed shuffle/slice, also adopted by run_eval / run_eval_server / validate / debug / the v0 bridge); split_tasks layers two disjoint train/val slices on top.
  • Graceful shutdown is the shared verifiers/v1/utils/interrupt.py::install_interrupt (renamed from install), now used by eval, gepa, validate, debug, and replay in place of one-off SIGTERM handlers.

Scope / non-goals

  • v1-native only — a legacy v0 --id env is rejected (use vf-gepa).
  • No multi-taskset optimization (v0's EnvGroup has no v1 equivalent); single taskset per run.
  • No env-server / --server worker pool — GEPA runs in-process.
  • Group-reward tasksets are rejected up front — GEPA scores one rollout per task (n=1) for the per-task scalar its pareto frontier needs, but @group_reward compares ≥2 rollouts of a task, so those tasksets can't be optimized this way.
  • Harnesses with no system-message input (APPENDS_SYSTEM_PROMPT=False, e.g. coding agents): the harness folds the candidate into the user prompt and warns (Harness.resolve_prompt owns that policy) rather than blocking. Tasksets that bake instructions into prompt instead of Task.system_prompt (e.g. gsm8k-v1) need --initial-prompt.

Testing

  • Verified live end-to-end against echo-v1 and reverse-text-v1 across multiple optimize iterations: rollouts stream to traces.jsonl, all traces scored with no errors, and GEPA proposes/optimizes the system prompt.
  • ruff + pre-commit clean.
  • Unit tests were removed for now (see review thread) — to be re-added against the reworked v1-native shape.

Files

  • verifiers/v1/gepa/adapter, config, dataset, reflection, runner
  • verifiers/v1/cli/gepa.py — the gepa entrypoint
  • verifiers/v1/utils/sampling.py — shared sample helper (also adopted by eval / server eval / validate / debug / legacy bridge)
  • verifiers/v1/utils/interrupt.pyinstall_interrupt shared graceful-shutdown handler (renamed; adopted by eval / validate / debug / replay / gepa)
  • verifiers/v1/cli/{eval/main,eval/runner,validate,debug,replay}.py, verifiers/v1/legacy.py — migrated onto the shared sample / install_interrupt helpers
  • pyproject.toml — register the gepa console script

Open question: docs & skill (feedback wanted)

Bugbot flagged that this doesn't update the user-facing docs (docs/) or the GEPA skill (skills/optimize-with-environments/SKILL.md), both of which currently describe the v0 prime gepa run / vf-gepa flow. I deliberately left those out of this PR and want a maintainer's call on where they should go, because I'm not sure of the intended structure:

  • docs/training.md has a "Prompt Optimization with prime gepa run" section. Should the v1 gepa command be (a) a new subsection there, (b) folded into the existing section as a v0/v1 split, or (c) documented wherever the other v1 CLIs (eval, serve, replay) are described? I couldn't find an existing v1-CLI doc home to match.
  • skills/optimize-with-environments/SKILL.md is v0-centric (prime gepa run, v0 env config). Should it gain a parallel v1 path, be rewritten around gepa + GEPAConfig, or stay v0-only for now?

Happy to do the doc/skill update in this PR or a follow-up once we agree on placement — just let me know the preferred structure.

🤖 Generated with Claude Code


[!NOTE]
Medium Risk
Runs many paid model rollouts and bridges blocking GEPA to async v1 execution on one event loop; mitigations include upfront rejection of incompatible tasksets and shared eval-style teardown/interrupt handling.

Overview
Adds a native v1 GEPA path: new gepa console script and verifiers/v1/gepa/ (GEPAConfig, GEPAv1Adapter, train/val split, reflection via Judge, synchronous run_gepa driving gepa.api.optimize on the main thread with a hand-managed event loop and env.serving()). Candidates are applied by rebuilding tasks with an updated system_prompt; rollouts stream to traces.jsonl like eval. Legacy v0 envs and @group_reward tasksets are rejected at the CLI/dataset layer.

Refactors: verifiers/v1/utils/sampling.sample centralizes fixed-seed shuffle + limit (used by eval, server eval, debug, validate, legacy eval, and GEPA). install_interrupt replaces per-CLI SIGTERM hacks in eval, debug, validate, and replay (eval already used the shared helper; import renamed from install).

Reviewed by Cursor Bugbot for commit f64fc94. Bugbot is set up for automated code reviews on this repo. Configure here.

Changes since #1952 opened

  • Restructured context manager lifecycle handling in verifiers.v1.gepa.runner.run_gepa function [f64fc94]
  • Modified run_gepa function to initialize HTTP client, model context, and reflection LM inside a try block with the client variable initialized to None beforehand, added conditional guard to close the client only if successfully created, and ensured cleanup of both client and event loop occurs in the outer finally block regardless of initialization failures [18ee54e]

Comment thread verifiers/v1/cli/gepa/runner.py Outdated
Comment thread verifiers/v1/cli/gepa/main.py
Comment thread verifiers/v1/cli/gepa/main.py
@Ziems Ziems marked this pull request as draft July 8, 2026 17:44
@Ziems Ziems requested a review from xeophon July 8, 2026 17:45
Comment thread verifiers/v1/cli/gepa/runner.py Outdated
Comment thread verifiers/v1/gepa/dataset.py
@macroscopeapp

macroscopeapp Bot commented Jul 8, 2026

Copy link
Copy Markdown

Approvability

Verdict: Needs human review

This PR introduces a substantial new feature (GEPA system-prompt optimization for v1) with ~400+ lines of new code across 8 new files, including a new console script, adapter, config, and runner. New features of this scope warrant human review.

You can customize Macroscope's approvability policy. Learn more.

Comment thread verifiers/v1/configs/gepa.py Outdated
Comment thread tests/v1/test_gepa.py Outdated
Comment thread verifiers/v1/cli/gepa/runner.py Outdated
Comment thread verifiers/v1/cli/gepa/runner.py Outdated
Comment thread verifiers/v1/cli/gepa/__init__.py Outdated
Comment thread verifiers/v1/cli/gepa/adapter.py Outdated
Comment thread verifiers/v1/cli/gepa/adapter.py Outdated
Comment thread verifiers/v1/cli/gepa/dataset.py Outdated
Comment thread verifiers/v1/cli/gepa/main.py
Comment thread verifiers/v1/cli/gepa/output.py Outdated
Comment thread verifiers/v1/cli/gepa/output.py Outdated
Comment thread verifiers/v1/cli/gepa/adapter.py Outdated
Comment thread verifiers/v1/cli/gepa.py
Comment thread verifiers/v1/gepa/runner.py Outdated
Comment thread verifiers/v1/gepa/reflection.py Outdated
Comment thread verifiers/v1/gepa/adapter.py
@Ziems Ziems requested a review from xeophon July 10, 2026 15:34
Add a `gepa` CLI command that runs GEPA (Genetic-Pareto) system-prompt
optimization against native v1 taskset/harness environments. The async runner
holds env.serving() open and runs the blocking gepa.optimize() via
asyncio.to_thread; GEPAv1Adapter marshals each rollout batch back onto the loop
and injects candidates by rebuilding each Task around a data row with the new
system_prompt. Rollouts stream to traces.jsonl like eval. Self-contained in
verifiers/v1/gepa/ with a thin cli/gepa.py entrypoint; the v0 path (vf-gepa) is
untouched.
@Ziems Ziems force-pushed the claude/gepa-verifiers-v1-cf6518 branch from 81bd1e0 to e88dd64 Compare July 11, 2026 15:21
@Ziems Ziems marked this pull request as ready for review July 11, 2026 15:44
Comment thread verifiers/v1/gepa/reflection.py Outdated
Comment thread verifiers/v1/gepa/reflection.py Outdated
Call the reflection (teacher) LM via `Judge.complete` instead of a
hand-constructed OpenAI client, so vf owns the client lifecycle and
key/header/Prime-billing resolution. GEPA calls `reflection_lm`
synchronously from the `optimize()` worker thread, so each call drives
the async `complete` on a private loop.
Comment thread verifiers/v1/gepa/runner.py Outdated
Comment thread verifiers/v1/gepa/adapter.py

@xeophon xeophon left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few remaining places should reuse the existing v1 plumbing instead of carrying parallel behavior.

Comment thread verifiers/v1/cli/gepa.py
Comment thread verifiers/v1/cli/gepa.py Outdated
Comment thread verifiers/v1/gepa/dataset.py Outdated
Comment thread verifiers/v1/gepa/dataset.py
Ziems added 3 commits July 11, 2026 13:01
Address review feedback that gepa carried behavior parallel to the rest of v1:

- Extract the fixed-seed shuffle-and-slice into verifiers/v1/utils/sampling.py
  (`sample`) and migrate eval, server eval, debug, validate, and legacy eval onto
  it; gepa's split_tasks now layers only its disjoint train/val slicing on top of
  the shared shuffle.
- Resolve the gepa CLI through the shared path (with_positional_taskset,
  narrow_config, extract_id, references_config_file) like eval/serve, so a leading
  bare taskset id works, -h renders narrowed taskset/harness help, and a missing
  taskset fails fast with usage.
- Stop re-policing harness prompt delivery in resolve_gepa_seed_prompt;
  Harness.resolve_prompt already owns fold-vs-emit and warns, so the function only
  chooses the seed prompt now.
…e v1 CLIs

Rename interrupt.install -> install_interrupt (it isn't eval-specific) and generalize its
docstring, then route every entrypoint that hand-rolled the SIGTERM->KeyboardInterrupt snippet
through it: gepa, validate, debug, and replay now get the same graceful shutdown eval already
has -- the first Ctrl-C/SIGTERM raises to unwind teardown `finally`, and a second is swallowed
so it can't orphan containers/sandboxes/tool-server runtimes mid-cleanup.
Comment thread verifiers/v1/gepa/runner.py Outdated
Comment thread verifiers/v1/gepa/runner.py Outdated
Comment thread verifiers/v1/cli/gepa.py
The output_dir docstring promised system_prompt.txt + metadata.json, left over from the
removed save_gepa_results path; nothing writes those. Describe what the run dir actually
holds — config.toml + the streamed traces.jsonl, alongside GEPA's own candidates.json /
run_log.json — and fix three comments that still named the trace file results.jsonl (it's
traces.jsonl). Docs only; no behavior change.
Comment thread verifiers/v1/gepa/runner.py Outdated
Comment thread verifiers/v1/cli/gepa.py
Ziems added 2 commits July 11, 2026 14:34
Replace the worker-thread bridge (asyncio.to_thread(optimize) + run_coroutine_threadsafe) with
the v0 vf-gepa shape: optimize() runs on the main thread and the runner owns one event loop --
env.serving() is entered on it once, each rollout batch runs via loop.run_until_complete
(GEPAv1Adapter.evaluate), and it's torn down in finally. This removes the cancellation hazards
that bridge created: no orphaned non-daemon thread to hang shutdown, and serving() is no longer
torn down while a worker thread still marshals rollouts into it. gepa.py runs run_gepa
synchronously and maps KeyboardInterrupt to exit 130, matching eval.

Verified end-to-end across many optimize iterations -- the interception pool serves each batch
fine with the loop idle between them.
GEPA scores one rollout per task (n=1) for the per-task scalar its pareto frontier needs, but
@group_reward compares >=2 rollouts of a task -- so Environment.episode raises on the first
batch of a group-reward taskset, crashing the run mid-optimization with a cryptic per-task
error. Detect @group_reward at startup (as run_eval does) and raise a clear, actionable error
before optimizing instead.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 3 total unresolved issues (including 2 from previous reviews).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 2f98cd4. Configure here.

Comment thread verifiers/v1/gepa/runner.py
Ziems added 2 commits July 11, 2026 15:48
run_gepa enters env.serving() by hand and its finally called __aexit__ unconditionally, so a
failure in __aenter__ (a tool server or the interception pool not coming up) ran teardown
against a context that never entered -- violating the async CM contract and risking masking the
original startup error with a follow-on one. Nest the optimize block so __aexit__ pairs only
with a successful __aenter__; keep the always-needed client/loop teardown in an outer finally.
resolve_client opens an httpx pool at EvalClient construction, but the client was built before
the try/finally that closes it -- so an exception while constructing ModelContext or the
reflection Judge would skip client.close() and leak the pool. Build client/ctx/reflection_lm
inside that try and guard the close on `client is not None` (so a resolve_client failure doesn't
turn into a NameError in finally).
@Ziems Ziems requested review from mikasenghaas and xeophon July 11, 2026 20:29

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#1969 puts this logic into Taskset.select but fine to keep for now

uuid: str = Field(default_factory=lambda: str(uuid4()), exclude=True)
"""Auto-generated run id — the leaf of the output dir, so runs never overwrite.
Excluded from the saved config so re-running `@ config.toml` lands in a fresh dir."""
model: str = Field(..., validation_alias=AliasChoices("model", "m"))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no default model?

Excluded from the saved config so re-running `@ config.toml` lands in a fresh dir."""
model: str = Field(..., validation_alias=AliasChoices("model", "m"))
"""Model id for rollouts under optimization."""
client: ClientConfig = EvalClientConfig()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should prob be typed as eval client config? assume we dont need tokens/logprobs here?

sampling: SamplingConfig = SamplingConfig()
reflection_model: str | None = None
"""Teacher model that proposes new system prompts. None = reuse `model`."""
reflection_client: ClientConfig | None = None

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here

"""Train tasks sampled per reflection step."""
perfect_score: float | None = None
"""Skip reflecting on a minibatch that already scores this well."""
state_columns: list[str] = Field(default_factory=list)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hm not sure i like this name

"""Seed system prompt. None = the first loaded task's `Task.system_prompt`, if any task
sets one (see `resolve_gepa_seed_prompt`)."""

max_concurrent: int | None = Field(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

default eval concurrency is 128, should we match this or is 32 deliberate?



@dataclass
class GEPAv1Adapter:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just GEPAAdapter

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants