Skip to content

feat: add engine-pluggable optimize_anything#346

Draft
Shangyint wants to merge 59 commits into
mainfrom
feat/optimize-anything-omni
Draft

feat: add engine-pluggable optimize_anything#346
Shangyint wants to merge 59 commits into
mainfrom
feat/optimize-anything-omni

Conversation

@Shangyint

@Shangyint Shangyint commented Apr 29, 2026

Copy link
Copy Markdown
Collaborator

Porting terrarium's multi-backend into gepa. WIP

Introduces a unified Python API that dispatches a single optimization call to any compatible backend (GEPA, Claude Code, Meta-Harness, …) over a shared eval-server contract:

from gepa.omni import optimize_anything, Task
result = optimize_anything(
    task=Task(name="t", initial_candidate="...", objective="..."),
    evaluate=my_eval_fn,
    backend="gepa",
    config={"reflection": {"reflection_lm": "openai/gpt-5"}},
    max_evals=200,
    max_token_cost=5.0,
)

Design choices that drove the layout:

  • Task is pure data (name, initial_candidate, objective, background, train/val/test). No bundled eval_fn — code stays in Python.
  • evaluate is a required, separate parameter; the eval server wraps it and is the single budget/concurrency choke point.
  • One Backend protocol — no Adapter sublayer. Backends call server.evaluate (in-process) or POST to server.url (subprocess).
  • max_token_cost is a single top-level field; the api threads it into whichever per-backend knob enforces it (gepa: max_reflection_cost; claude_code/meta_harness: --max-budget-usd).
  • No Hydra inside gepa.omni — users call the function directly.

Results:

Task Task type Budget Baseline GEPA autoresearch Meta-Harness
Frontier-CS Program $20 7.72 43.8 55.4 50.9
Can't-Be-Late System $20 -96.48 -91.12 -89.52 -88.97
Cloudcast System $20 0.00520 0.00900 0.00917 0.00919
FiNER Prompt 4000 evals 40.0% 49.3% 47.3% 40.7%
Formula Prompt 4000 evals 77.3% 78.7% 87.3% 76.0%
LiveBench-Math Prompt 4000 evals 80.9% 83.0% 81.4% 86.3%

gepa-bot and others added 7 commits April 13, 2026 20:43
When run_dir is provided, save() now writes `gepa_state.json` alongside
the existing pickle and JSON files. This single file gives autonomous
agents (e.g. Claude Code) the full optimization picture:

- All candidates with their texts, parent lineage, and metric call cost
- Per-candidate per-datapoint scores (prog_candidate_val_subscores)
- Per-candidate multi-objective scores
- Pareto frontier details (instance, objective, hybrid, cartesian)
- Derived analytics: best candidate, hardest examples, frontier members
- Full iteration log including evaluation side_info/trajectories

Also capture proposal evaluation data (eval_before, eval_after with
scores, outputs, and trajectories/side_info) into the iteration trace,
so agents can see WHY proposals were accepted or rejected.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Serialize evaluation cache to gepa_state.json: per-candidate per-datapoint
  entries with score, output, and objective_scores (keyed by candidate idx
  and data id)
- Add rejected_proposals section: each rejected proposal includes candidate
  text, parent ids, subsample data ids, before/after scores, and full
  eval_before/eval_after with trajectories/side_info
- Move try_json_serialize to gepa_utils for shared use between engine and
  state export
- Bump schema_version to 2

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replaces the monolithic gepa_state.json export with a directory layout
(candidates/, pareto/, iterations/, rejected_proposals/, eval_cache/)
so agents can navigate run state without loading multi-MB blobs.
Gated behind EngineConfig.write_agent_state / gepa.optimize(write_agent_state=),
default off — when off, run_log.json / full_program_trace also stay lean
(no per-iteration eval payloads or proposed_candidate copies). gepa_state.bin
remains the single source of truth for resume; the tree is output-only.
… state

When write_agent_state is enabled, valset evaluation now bypasses the
eval cache and calls the adapter with capture_traces=True so trajectories
come through. Each accepted candidate's outputs and trajectories are
written to candidates/<idx>/outputs/<val_id>.json and
candidates/<idx>/trajectories/<val_id>.json, covering both the seed and
every reflective/merge acceptance.

Drops the eval_cache/ export from the agent directory — the in-memory
cache was not round-tripping trajectories and adds little over the other
per-candidate files. Adds ValsetEvaluation.trajectories_by_val_id as the
optional handoff channel from eval path to writer.
Introduces a unified Python API that dispatches a single optimization
call to any compatible backend (GEPA, Claude Code, Meta-Harness, …) over
a shared eval-server contract:

    from gepa.omni import optimize_anything, Task
    result = optimize_anything(
        task=Task(name="t", initial_candidate="...", objective="..."),
        evaluate=my_eval_fn,
        backend="gepa",
        config={"reflection": {"reflection_lm": "openai/gpt-5"}},
        max_evals=200,
        max_token_cost=5.0,
    )

Design choices that drove the layout:
- Task is pure data (name, initial_candidate, objective, background,
  train/val/test). No bundled eval_fn — code stays in Python.
- evaluate is a required, separate parameter; the eval server wraps it
  and is the single budget/concurrency choke point.
- One Backend protocol — no Adapter sublayer. Backends call
  server.evaluate (in-process) or POST to server.url (subprocess).
- max_token_cost is a single top-level field; the api threads it into
  whichever per-backend knob enforces it (gepa: max_reflection_cost;
  claude_code/meta_harness: --max-budget-usd).
- No Hydra inside gepa.omni — users call the function directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@semanticdiff-com

semanticdiff-com Bot commented Apr 29, 2026

Copy link
Copy Markdown

Review changes with  SemanticDiff

Changed Files
File Status
  tests/test_attach_existing_run.py  98% smaller
  tests/test_best_example_evals.py  95% smaller
  tests/test_image.py  92% smaller
  tests/test_refiner.py  64% smaller
  docs/scripts/generate_api_docs.py  36% smaller
  src/gepa/proposer/reflective_mutation/reflective_mutation.py  32% smaller
  src/gepa/core/engine.py  14% smaller
  tests/test_import.py  12% smaller
  src/gepa/core/state.py  4% smaller
  tests/test_state.py  1% smaller
  src/gepa/adapters/optimize_anything_adapter/optimize_anything_adapter.py  1% smaller
  .gitignore Unsupported file format
  docs/docs/api/index.md Unsupported file format
  docs/docs/api/optimize_anything/BudgetTracker.md Unsupported file format
  docs/docs/api/optimize_anything/Engine.md Unsupported file format
  docs/docs/api/optimize_anything/EngineConfig.md Unsupported file format
  docs/docs/api/optimize_anything/EvalServer.md Unsupported file format
  docs/docs/api/optimize_anything/Evaluator.md Unsupported file format
  docs/docs/api/optimize_anything/GEPAConfig.md Unsupported file format
  docs/docs/api/optimize_anything/LogContext.md Unsupported file format
  docs/docs/api/optimize_anything/MergeConfig.md Unsupported file format
  docs/docs/api/optimize_anything/OptimizationState.md Unsupported file format
  docs/docs/api/optimize_anything/OptimizeAnythingConfig.md Unsupported file format
  docs/docs/api/optimize_anything/RefinerConfig.md Unsupported file format
  docs/docs/api/optimize_anything/ReflectionConfig.md Unsupported file format
  docs/docs/api/optimize_anything/Result.md Unsupported file format
  docs/docs/api/optimize_anything/Task.md Unsupported file format
  docs/docs/api/optimize_anything/TrackingConfig.md Unsupported file format
  docs/docs/api/optimize_anything/get_log_context.md Unsupported file format
  docs/docs/api/optimize_anything/list_engines.md Unsupported file format
  docs/docs/api/optimize_anything/log.md Unsupported file format
  docs/docs/api/optimize_anything/make_litellm_lm.md Unsupported file format
  docs/docs/api/optimize_anything/optimize_anything_with_server.md Unsupported file format
  docs/docs/api/optimize_anything/register_engine.md Unsupported file format
  docs/docs/api/optimize_anything/set_log_context.md Unsupported file format
  docs/docs/blog/posts/2026-02-18-introducing-optimize-anything/index.md Unsupported file format
  docs/docs/guides/experiment-tracking.md Unsupported file format
  docs/mkdocs.yml  0% smaller
  src/gepa/api.py  0% smaller
  src/gepa/core/adapter.py  0% smaller
  src/gepa/core/callbacks.py  0% smaller
  src/gepa/gepa_launcher.py  0% smaller
  src/gepa/gepa_utils.py  0% smaller
  src/gepa/gskill/gskill/train_optimize_anything.py  0% smaller
  src/gepa/oa/__init__.py  0% smaller
  src/gepa/oa/budget.py  0% smaller
  src/gepa/oa/config.py  0% smaller
  src/gepa/oa/engine.py  0% smaller
  src/gepa/oa/engines/__init__.py  0% smaller
  src/gepa/oa/engines/autoresearch.py  0% smaller
  src/gepa/oa/engines/best_of_n.py  0% smaller
  src/gepa/oa/engines/claude_utils.py  0% smaller
  src/gepa/oa/engines/gepa.py  0% smaller
  src/gepa/oa/engines/meta_harness.py  0% smaller
  src/gepa/oa/ensemble.py  0% smaller
  src/gepa/oa/eval_server.py  0% smaller
  src/gepa/oa/proposers/__init__.py  0% smaller
  src/gepa/oa/proposers/claude_code_agent.py  0% smaller
  src/gepa/oa/registry.py  0% smaller
  src/gepa/oa/sandbox.py  0% smaller
  src/gepa/oa/task.py  0% smaller
  src/gepa/oa/utils.py  0% smaller
  src/gepa/optimize_anything.py Unsupported file format
  tests/test_cache_evaluation_storage.py  0% smaller
  tests/test_candidate_selector.py  0% smaller
  tests/test_evaluator_wrapper.py  0% smaller
  tests/test_optimize_anything_adaptive_sequential.py  0% smaller
  tests/test_optimize_anything_autoresearch_engine.py  0% smaller
  tests/test_optimize_anything_callbacks.py  0% smaller
  tests/test_optimize_anything_eval_server.py  0% smaller
  tests/test_optimize_anything_gepa_config.py  0% smaller
  tests/test_seed_generation.py  0% smaller

Shangyint and others added 22 commits April 29, 2026 12:52
Replaces the ad-hoc kwarg sprawl on optimize_anything (max_evals,
max_token_cost, max_concurrency, output_dir, tracker, run_dir,
stop_at_score, effort, max_thinking_tokens, sandbox) with a single
typed OmniConfig dataclass:

    config = OmniConfig(
        backend="gepa",
        max_evals=200,
        max_token_cost=5.0,
        run_dir="outputs/run1",
        stop_at_score=0.95,
        config={"reflection": {"reflection_lm": "openai/gpt-5"}},
    )
    optimize_anything(task, evaluate, config=config)

Cross-cutting fields (run_dir, stop_at_score, effort, max_thinking_tokens,
sandbox) are typed on OmniConfig directly. Backend-specific options live
in the free-form ``config: dict[str, Any]`` escape hatch.

Each backend now has a single construction path: ``__init__(config)``
reads cross-cutting fields directly and pops backend-specific keys from
``config.config`` onto self. ``run`` reads from self. The api never
injects attributes after construction. Backends warn about unknown keys
in ``config.config`` so typos surface immediately.

Other changes:
- Drop the dual from_config/__init__ split; one path only.
- Drop post-hoc threading of stop_at_score / effort / etc. — backends
  read them off OmniConfig in __init__.
- optimize_anything always builds a timestamped default output_dir
  (``outputs/omni/<task>/<backend>/<timestamp>/``) when unset.
- Drop run_dir auto-derivation from output_dir; explicit only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Construct GEPA's nested dataclasses (EngineConfig, ReflectionConfig,
  MergeConfig, RefinerConfig) explicitly instead of relying on
  GEPAConfig.__post_init__'s dict-to-dataclass conversion. The runtime
  conversion is a feature, but the field annotations are dataclass-typed,
  so pyright (correctly) flagged the dict args.
- Coerce gepa_result.best_candidate (str | dict[str,str]) to str before
  passing to omni's Result.best_candidate. We always feed a str seed
  through, so the runtime value is str — pyright just can't narrow it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Datasets are now `list[Any]` (matching core GEPA's stance). EvalServer is
the single id authority: resolves `item.id` / `item["id"]` if present, else
assigns `f"{split}_{i}"`. Public `iter_split` yields `(id, item)` pairs that
backends and api consume so HTTP `example_id` lookups stay consistent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ote)

Compose multiple backends over the same task. Each helper takes the same
``(task, evaluate, configs: list[OmniConfig])`` shape as optimize_anything
and calls it under the hood. Budgets are pre-partitioned per OmniConfig
— no shared budget pool.

- optimize_sequential: pipeline. Each backend's best becomes the next
  backend's seed. Monotonic — a regressing stage doesn't poison the
  chain (running best is fed forward).
- optimize_parallel: run N backends concurrently, return the list of
  results in input order. Most flexible primitive; the others wrap it.
- optimize_best_of: parallel + max(results, key=best_score). Uses each
  backend's own scoring during its run; no re-score pass.
- optimize_vote: parallel, then re-score every best_candidate via
  ``evaluate`` (outside any budget) and pick the highest. Useful when
  backends have disjoint scoring quirks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ports the terrarium gepa_cc_agent into gepa.omni so the GEPA backend can
run reflection through a sandboxed Claude Code subprocess instead of an
LM callable. Plugged in via OmniConfig.config["claude_code_agent"]; the
backend auto-enables write_agent_state so the proposer can read the
iterations/, pareto/, and history.md tree off run_dir.

Also factors copy_session_transcript into backends/claude_utils.py so
both backends/claude_code.py and the new proposer share it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- sandbox: add macOS Seatbelt fallback (claude_settings_args)
- claude_code: rewrite Strategy as explicit experiment loop, force
  tempdir when sandboxed, refactor program.md into composable helpers
- meta_harness: rich colorized logging, propose/bench timing, val-split
  preference, failure summary rows, tracker integration, Seatbelt
- gepa: install _ReflectiveDatasetDumpCallback when agent proposer
  is selected so iterations/NNNNN/reflective_dataset.json is browsable
- claude_code_agent: append claude_settings_args to argv when sandboxed

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Splits the omni entry point in two:

- optimize_anything(task, evaluate, config)  — convenience. Builds
  EvalServer + BudgetTracker from config and runs (unchanged surface
  for existing callers).
- optimize_anything_with_server(server, config)  — primitive. Caller
  owns the EvalServer (construct + start + stop). Lets framework
  integrators (e.g. terrarium) keep their own outer eval server as the
  single source of truth for budget / persistence / tracking instead
  of nesting two servers.

Both share a private _execute(server, backend, *, owns_server) helper
that holds the actual run-loop + metadata + held-out test eval logic.
The convenience path passes owns_server=True; the primitive trusts the
caller's lifecycle.

Backend.process_result now takes Path | None — when an EvalServer is
constructed without an output_dir (typical for the primitive path),
file-touching backends short-circuit instead of crashing. Updated the
claude_code and meta_harness backends; gepa backend was already a no-op.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two changes from a terrarium ↔ omni adapter audit:

1. backends/gepa.py: the dataset path was assigning task.test_set to
   GEPA's valset. test_set is a held-out split for post-run eval and must
   not enter the optimization loop. Switch to val_set, keeping
   metadata["val_set"] as the secondary source.

2. backends/meta_harness.py: port two terrarium-side improvements:
   - SKILL.md: add Step 0 post-eval reports, expanded anti-tuning
     guidance with concrete mechanism examples, explicit budget-ownership
     note, richer eval-trace instructions, and candidate / summary format
     sections so the proposer no longer re-derives them.
   - _parse_proposer_cost → _parse_proposer_result returning
     (cost, payload); iter*_meta.json now also records the cmd and a
     result_summary block (duration_ms, num_turns, usage, is_error,
     subtype) so reviewers don't have to crack open stdout.json.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both the claude_code and meta_harness backends now treat train+val as one
combined "training" set: train+val are materialized together into
work_dir/train/, scoring runs over the full pool, and the agents never
learn that val existed. Test stays sealed for held-out reporting via the
in-process Python API.

claude_code:
- drop validate.sh and the val-signal branch in _strategy_section /
  _rules_section / build_program_md; eval.sh is now the only signal.
- eval.sh drops the positional split arg; only `./eval.sh <cand>` (full
  visible pool) and `./eval.sh <cand> --ids id1,id2` are accepted, so the
  prior `./eval.sh <cand> test` back-door is closed at the script layer.
- materialize_sandbox writes train + val into train/ together; train_size
  in program.md reflects the combined count.

meta_harness:
- _score_candidate now evaluates on combined train+val ids (no more val-
  preferred / train-fallback ambiguity); _materialize_sandbox writes both
  splits into train/; _build_task_md drops the "hidden val set" wording.

eval_server (HTTP):
- /evaluate_examples now restricts callers to the agent-visible pool: the
  `split` parameter is rejected, and example_ids are validated against
  train+val (any id from test or unknown is 400). The default (no body
  fields) evaluates on the full visible pool.
- /evaluate rejects example_id values outside the visible pool.
- /task reports train_size = train+val combined and zeroes val_size /
  test_size so an HTTP probe cannot infer a sealed split exists.
- The in-process evaluate_examples() API is unchanged so the runner can
  still score on the held-out test set for final reporting.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
frontier.json now records, in addition to the overall best, a
``per_example`` dict mapping each example id to the candidate that scored
highest on it (``{best_name, score, file}``). When different candidates
win different examples, the proposer can read both winners and design a
new candidate that fuses their mechanisms — same shape as the reference
text_classification example's per-task frontier.

- _update_frontier accepts per_example_scores and merges them in with a
  strict-greater-wins rule (first writer keeps its slot on ties).
- The benchmark loop pulls per-example scores from the eval info dict
  (skipped for single-task tasks where there's only ``_single``).
- SKILL.md Step 1 directs the proposer at the per-example frontier and
  flags it as the strongest signal for combination candidates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirror optimize_sequential / parallel / best_of / vote with primitive
forms that take caller-owned EvalServer(s). Lets outer frameworks (e.g.
terrarium) compose multiple omni backends while routing every inner eval
through their own budget-tracking server.

Sequential takes a single shared server (one budget pool); the
parallel-family helpers take one server per config (caller pre-partitions
the budget across N inner BudgetTrackers).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bare ``"sonnet"`` / ``"opus"`` aliases resolve CLI-side to whatever
Anthropic currently points at, breaking experiment reproducibility
across releases. Pin all three omni backend defaults
(``claude_code``, ``meta_harness``, ``gepa``'s claude_code_agent
proposer) to the versioned ID; the meta_harness backend also
switches from opus to sonnet for parity.

Aliases still work for callers who want auto-tracking of the latest
default — explicitly pass ``model="sonnet"`` or ``model="opus"``.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Gepa's ``EngineConfig.cache_evaluation`` defaults to ``False``. Without
caching, the reflective proposer's ``eval_curr`` re-evaluates the same
candidate on the same minibatch every iteration the candidate is
re-selected from the pareto frontier (and the seed in iteration 1,
since iteration 1's curr_prog == seed). On single-instance tasks
(terrarium per-problem frontier_cs) this multiplies real eval-budget
spend by ~2-8× depending on selector behavior.

Default to ``cache_evaluation=True`` in the omni gepa backend's
engine_kwargs. Caller can override via
``config.engine.cache_evaluation=False`` to opt back out.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When ClaudeCodeBackend's ``ralph`` config key is true, the backend resumes
the same pinned session via ``claude --resume <session_id>`` whenever the
CLI exits before the LLM/eval budget is exhausted. Each iteration's
``--max-budget-usd`` is set to the *remaining* budget so the cap doesn't
stack across iterations and the final iteration self-caps in the CLI.
Off by default — single-shot semantics preserved.

Stops when (a) cumulative cost ≥ ``max_token_cost − $0.05`` headroom,
(b) eval-count budget is exhausted, (c) claude exits non-zero, or
(d) ``stop_at_score`` is reached. No fixed iteration cap.

Records per-invocation ``{cost, score, returncode}`` in
``result.metadata["invocations"]`` so callers can see the loop's progress.

Ported from terrarium feat/cc-ralph-loop (Benzhang2004) into gepa.omni's
claude_code backend per the policy that all new adapter behavior lives in
gepa.omni rather than terrarium-side adapters.

Co-Authored-By: Benzhang2004 <zhangjialin04@sina.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rver

``optimize_sequential_with_server`` now takes ``list[EvalServer]`` (one
server per stage) instead of a single shared server, mirroring the
parallel-family helpers. Each stage gets its own ``BudgetTracker``, so
the caller pre-partitions ``total / N`` and a productive early stage
cannot monopolize the pool — the same fair-share semantics already
applied to ``parallel_with_server`` / ``best_of_with_server`` /
``vote_with_server``.

Stage-to-stage seeding still works the same way: between stages the
function mutates ``servers[i].task.initial_candidate`` to the
running-best candidate from earlier stages, then restores the original
tasks on exit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The omni gepa backend's LM-based proposer path constructs
``gepa.lm.LM(model, **reflection_lm_kwargs)`` and was only checking
``OmniConfig.max_thinking_tokens`` to populate the LM's thinking
config — never reading ``OmniConfig.effort``. So a user passing
top-level ``effort=medium`` to the gepa backend got extended thinking
in the claude_code_agent path (which threads effort to ``--effort``
on the proposer subprocess) but no thinking config at all in the
LM-based path: ``lm_kwargs`` stayed empty and litellm fell back to
the provider's default.

Auto-thread ``self.effort`` into ``lm_kwargs.reasoning_effort`` when
``max_thinking_tokens`` isn't set — matching how the native terrarium
GEPAAdapter's ``_apply_effort`` runner hook already worked. Explicit
``reflection_lm_kwargs.reasoning_effort`` from the caller still wins
via ``setdefault``.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The two knobs control extended thinking via different mechanisms but
they're not actually mutually exclusive at the runtime level — the
claude CLI accepts both ``--effort`` and ``MAX_THINKING_TOKENS`` env
var simultaneously, and litellm accepts both ``reasoning_effort`` and
``thinking={…}`` kwargs. Forcing a hard ValueError prevented users
from passing both even when they had a reason to (e.g., comparing
adaptive-vs-fixed configs in a single run, or relying on whichever
the runtime picks).

Changes:
- ``optimize_anything`` and ``optimize_anything_with_server``: drop
  the ``ValueError("config.effort and config.max_thinking_tokens are
  mutually exclusive")`` raise.
- ``claude_code`` backend: drop ``max_thinking_tokens is None and``
  guard around ``--effort`` — pass it whenever ``effort`` is set.
- ``meta_harness`` backend: same.
- ``gepa`` backend's LM path: was ``if max_thinking_tokens / elif effort``;
  now both apply together (``setdefault`` still respects user-supplied
  ``reflection_lm_kwargs.reasoning_effort``).
- ``OmniConfig`` docstring: note both fields are independent.

Behavior with both set is whatever the runtime does — we just stop
preventing it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ran in

Sandboxed runs invoke claude with cwd=jail_root (a TemporaryDirectory),
so the CLI writes its session jsonl under
~/.claude/projects/<slug-of-jail-root>/. The post-call copy was always
keyed on self.run_dir, so the slug lookup missed and silently no-op'd
via the if-not-src.exists() guard in claude_utils. Result: every
sandboxed cca run lost its transcripts.

Inline the copy into both branches with the matching cwd. The
transcript file lives on the host fs (not in the jail), so calling
copy inside the with-block before TemporaryDirectory cleanup is safe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per-save cost was O(N): _save_seed_iteration_dir and
_save_proposal_iteration_dirs rewrote every existing iterations/NNNNN/
dir from scratch on every save, making total run cost O(N^2). Add an
existence guard so each dir is written exactly once.

Also drop the per-iteration trace.json: it duplicated content already
covered by meta.json (subsample scores) and components/ (proposed
candidate text), and its eval_before/eval_after carried fat 3-tuple
outputs that bloated each rejected iteration to ~10KB. Update test
to match.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread src/gepa/core/state.py Outdated
Comment thread src/gepa/core/engine.py
val_ids = list(self.val_evaluation_policy.get_eval_batch(valset, state))

# When write_agent_state is on, bypass the cache so we can capture
# trajectories (which the cache does not store).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Isn't a more principled fix to store these things in the cache rather than bypass? Also I think there's a separate optimize_anything cache and GEPA cache. Is GEPA cache even useful? if not, we should disable and remove it entirely. If it is useful, let's use it here by adding relevant data to it.

from gskill.swe_fitness_fn import create_swe_fitness_fn

from gepa.optimize_anything import EngineConfig, GEPAConfig, ReflectionConfig, TrackingConfig, optimize_anything
from gepa.legacy_optimize_anything import EngineConfig, GEPAConfig, ReflectionConfig, TrackingConfig, optimize_anything

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If we are confident on the parity/perf of the new version, perhaps better to migrate to new one.

Addresses two review comments on the write_agent_state output tree:

- Merge _save_seed_iteration_dir and _save_proposal_iteration_dirs into one
  _save_iteration_dirs that shares the meta/components/val_scores writer while
  keeping the skip-before-recompute guard (O(1) per save).

- Anchor each iteration directory on a random short id (uuid4().hex[:8])
  stamped on the trace entry at slot creation, instead of state.i + 1. The
  loop counter isn't unique under concurrent/agent-swarm proposal backends
  (no shared counter to serialize on); a UUID needs none. Seed keeps the
  reserved id "seed". state.i stays the logical clock (ordering, logging,
  metrics step). Schema bumped v6 -> v7 with a migration that stringifies
  legacy integer ids.

Proposers now receive only on-disk anchors (iteration_id, parent_iteration_id),
not the sequence number; the file-based Claude Code proposer derives
first-iteration from history.md presence.

Co-authored-by: Isaac
@gepa-ai gepa-ai deleted a comment from LakshyAAAgrawal Jun 20, 2026
@Shangyint

Copy link
Copy Markdown
Collaborator Author

@LakshyAAAgrawal iteration idx fixed. now everything will be uuid'd. thx!

@LakshyAAAgrawal

Copy link
Copy Markdown
Contributor

I had pointed my claude-code to refer to this PR, and set up meta-harness on some task I was doing locally. However, that implementation didn't add support for side info. Worth checking if all of that wiring works for all engines or not.

Comment thread src/gepa/optimize_anything.py
Comment thread src/gepa/optimize_anything.py Outdated
Comment thread src/gepa/optimize_anything.py Outdated
Comment thread src/gepa/oa/eval_server.py
Shangyint and others added 3 commits June 22, 2026 20:53
…route

Remove the legacy seed_candidate/evaluator dispatch from the public
optimize_anything entry point. The function now takes the task fields
directly (name, initial_candidate, objective, background, train/val/test
sets, metadata) and builds the Task internally, with a fully documented
signature.

Merge the old _run_engine_optimization and ensemble's thin optimize_anything
wrapper into a single public Task-based runner, optimize_task. The ensemble
helpers, which carry a Task across stages, call optimize_task directly via a
lazy import (avoiding the circular import with gepa.optimize_anything).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…odule docstring

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The public optimize_anything entry point no longer dispatches to the
legacy seed_candidate/evaluator API (removed in 9bb69f2); it takes the
task fields directly and builds the Task internally. Replace the stale
legacy-fallback / _LEGACY_API_ARGS / "cannot mix" tests with coverage of
the actual new behavior: legacy kwargs and missing required args both
raise a natural TypeError from arg binding.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Shangyint

Copy link
Copy Markdown
Collaborator Author

@LakshyAAAgrawal also removing the metadata arg in optimize_anything, it is mergable into engine config (only engine consumes them)

Drop the free-form Task.metadata field entirely — it was a confusing
"magic reserved key" channel. Its only real consumers were:

- gepa engine's metadata["val_set"] valset fallback — dead (no producer);
  removed.
- autoresearch's metadata["optimize_anything_handoffs"] — moved to the
  engine's own engine_config["handoffs"], threaded explicitly through the
  sandbox/program-md builders. Added "handoffs" to _AR_CONFIG_KEYS (renamed
  from _CC_) and documented it in the engine docstring.
- eval server /task scalar-metadata exposure — unconsumed; removed.

Also rename the Task-based runner optimize_task -> optimize_anything_from_task
(the from-a-Task counterpart of optimize_anything), and document where each
engine's engine_config keys are enumerated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread src/gepa/optimize_anything.py Outdated
Shangyint and others added 3 commits June 23, 2026 19:46
…> seed_candidate

- OptimizeAnythingConfig gains an optional `name`, auto-generated as
  `<engine>-<uuid>-<timestamp>` when unset; drop the mandatory `name`
  argument from optimize_anything().
- Rename the Task/public `initial_candidate` field to `seed_candidate`
  and make it optional (None = seedless mode).
- Thread the rename through the engines, the eval server `/task`
  endpoint, and the ensemble helpers; update tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… EvalFn alias

- Mirror gepa.legacy_optimize_anything's shape: leading positional
  `seed_candidate`, keyword-only `evaluator` / `dataset` / `valset` /
  `objective` / `background`. The new API only swaps in
  OptimizeAnythingConfig and adds `test_set`. Restores compatibility with
  the existing examples (circle_packing, aime_math, ...).
- Remove the EvalFn type alias; inline
  Callable[..., tuple[float, dict[str, Any]]] at the use sites.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…se on no budget

- optimize_anything now builds the Task + server and runs inline rather
  than delegating to optimize_anything_from_task (which is now the thin
  Task-unpacking wrapper).
- A missing budget (neither max_evals nor max_token_cost set) now warns
  and runs unbounded instead of raising ValueError.
- Move optimize_anything_from_task and optimize_anything_with_server into
  oa/ensemble.py (still re-exported from gepa.optimize_anything); they
  delegate to optimize_anything / the run primitives via lazy imports.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@LakshyAAAgrawal

Copy link
Copy Markdown
Contributor

Did a full second-pass review against the current head (e700b6e). I worked through every prior inline comment + my issue-level comment, then reviewed the new code against the patterns from your past merged-PR reviews (especially #100 / dynamic validation — same lens: don't break end users, prefer protocols, push back on new indirection, question removed assumptions, DRY, concurrency-ready). Findings below, with criticality-ordered list at the bottom.

Status of your prior comments

# Comment Status Notes
1 uv.lock — revert ❌ not addressed Diff is now 1081 inserts / 567 deletions vs main. Same comment as on every other PR you've reviewed; this one keeps slipping.
2 api.py:88 write_agent_state — should default with run_dir ❌ not addressed Still a bool = False flag in both api.py:88 and optimize_anything.py:472. No numbers provided on agent-state size either.
3 state.py:38 — should . be excluded too? ❌ not addressed Regex still [^A-Za-z0-9_.-]. Leading . would produce a hidden file on Unix; arguably you do want it banned.
4 state.py:399 — state.i+1 uniqueness under concurrency / agent swarms / UUID ✅ addressed new_iteration_id() = uuid.uuid4().hex[:8], SEED_ITERATION_ID = "seed", iteration_ids_by_candidate_idx lives on state. Each entry now anchors on a random short hex id, not state.i+1.
5 autoresearch.py:1 — match Karpathy / ralph terminology ⚠️ partial Code now uses "ralph" naming throughout (RALPH_CONTINUE_PROMPT, _RALPH_MIN_HEADROOM_USD, ralph: bool config knob) and the docstring says "in the spirit of karpathy/autoresearch". Your specific "how confident" ask doesn't have an answer in the PR though — no reference link, no parity test.
6 engine.py:1 — "are these consistent" (linking L195) ⚠️ unclear L195 today is _write_agent_iteration_files. Hard to tell what the consistency check was vs without the original line you pointed at; need the author to explicitly reply.
7 state.py:466 — DRY, could these be one method ⚠️ partial The save methods have an internal write helper now (_save_iteration_dirs lines 469-489). Some consolidation; not the full collapse the comment was pointing at.
8 engine.py:238 — store in cache rather than bypass ❌ not addressed Code at L230-250 still has the comment "When write_agent_state is on, bypass the cache so we can capture trajectories (which the cache does not store)". Your suggestion was to extend the cache to store trajectories, not bypass.
9 gskill/train_optimize_anything.py:28 — migrate to new API ❌ not addressed Still imports from gepa.legacy_optimize_anything. Probably acceptable if intentional, but the original question "are we confident on parity?" still doesn't have a yes.
10 optimize_anything.py:1 — typed API + backwards-compat wrapper ✅ addressed Signature now mirrors legacy: optimize_anything(seed_candidate, *, evaluator, dataset, valset, objective, background, test_set, config). Full docstring per arg. The wrapper-with-warning pattern you sketched isn't there (and no warning fires if user passes config=GEPAConfig(...) — see C2 below), but the named-arg compat is good.
11 optimize_anything.py:27 — confusing sentence ✅ addressed Removed in 0750158.
12 optimize_anything.py:5 — Task class indirection ✅ addressed Task dissolved into top-level args (9bb69f2). Task is still constructed internally but no longer in the user's mental model.
13 eval_server.py:62 — support PR #279 batch evals ❌ not addressed evaluate_examples exists for "one candidate, many examples" but not the candidate×example matrix from #279.
14 eval_server.py:62 — outside-machine safety ✅ addressed ThreadingHTTPServer(("localhost", port), Handler) at L358. Bound to loopback only. (Note: ThreadingHTTPServer also fixes my earlier concurrency concern — good.)
15 eval_server.py:62 — "test" concept ⚠️ partial test_set is still present. Now documented as opt-in (skipped if absent), and sealed at the HTTP layer so HTTP callers/agents can't see it. The conceptual question — should GEPA have test at all? — still open.
16 optimize_anything.py:67 — name mandatory? ✅ addressed config.name: str | None = None; _default_run_name(engine) generates {engine}-{uuid8}-{ts} when omitted.
17 optimize_anything.py:67 — initial_candidate → seed_candidate ✅ addressed Renamed everywhere; also seed_candidate: str | None = None supports seedless mode.
18 optimize_anything.py:67 — background surfaced every iteration is naive ❌ not addressed Task.background docstring still says "Surfaced verbatim by every engine". Your specific suggestion (write background to a file, point coding-agent engines at the file) isn't implemented for any engine.
19 optimize_anything.py:67 — name in config or last arg ✅ addressed Now lives on OptimizeAnythingConfig.name, not as a function arg.
20 Issue-level 2026-06-18 — backwards compat / namespace ⚠️ partial The function signature now matches legacy positional/keyword shape, so most legacy call sites won't break on the function call itself. BUT from gepa.optimize_anything import GEPAConfig, EngineConfig, ... still raises ImportError — the symbols moved to gepa.legacy_optimize_anything. Docs/examples were migrated to import from legacy_* (good; verified by grep), but external user code using those symbols will still break on merge.
21 Issue-level 2026-06-21 — meta_harness missing side_info ⚠️ partial meta_harness does surface the info dict — state/eval_traces/<candidate_name>/*.json writes the full info dict, and the agent prompt explicitly tells the model to read those files for "compile errors, judge messages, per-example scores, logs, status." So the wiring exists for meta_harness. Not verified across other engines: autoresearch.py uses the eval.sh subprocess and HTTP response includes info, but I didn't trace whether the agent prompt actually reads it. Worth an explicit cross-engine smoke test.

Additional issues I found on this pass

These are net-new vs my June 18 review; I've ordered them by criticality.

C1 — Backwards-compat: GEPAConfig import still breaks

Severity: blocker for external users. The function call now matches legacy in shape, but every existing user with from gepa.optimize_anything import GEPAConfig, EngineConfig, ReflectionConfig, MergeConfig, RefinerConfig, TrackingConfig, SideInfo hits ImportError. In-repo docs are migrated to legacy_optimize_anything (verified — no broken imports left in docs/ or examples/), but external code wasn't audited and won't be. The simplest fix is adding a compatibility re-export at the bottom of optimize_anything.py:

# Backwards-compat re-exports for callers using the legacy config classes.
# Will be removed in a future major; users should migrate to OptimizeAnythingConfig.
from gepa.legacy_optimize_anything import (  # noqa: F401, E402
    EngineConfig,
    Evaluator,
    GEPAConfig,
    LogContext,
    MergeConfig,
    OptimizationState,
    RefinerConfig,
    ReflectionConfig,
    SideInfo,
    TrackingConfig,
    get_log_context,
    log,
    make_litellm_lm,
    set_log_context,
)

That gets you 100% legacy-import compatibility for zero ongoing maintenance cost.

C2 — optimize_anything(..., config=GEPAConfig(...)) silently breaks

A user porting from the legacy API who passes config=GEPAConfig(...) (a perfectly natural thing to try) gets an AttributeError deep inside _build_engine when it accesses config.max_evals. There's no isinstance(config, GEPAConfig) check that either routes them to legacy_optimize_anything or raises a clear TypeError: GEPAConfig is the legacy config class; use OptimizeAnythingConfig, or import optimize_anything from gepa.legacy_optimize_anything. Cheap addition; saves an angry forum thread later.

C3 — Held-out test scoring silently swallows evaluator errors

_score_test() at optimize_anything.py:218–222:

try:
    score, _ = server.eval_fn(candidate, ex)
    scores[eid] = score
except Exception:
    scores[eid] = 0.0

Same shape as the cache-bypass in engine.py:238bypass rather than surface. A genuine evaluator crash on the test set is indistinguishable from a real 0. The pattern across the new code is "swallow on test, swallow on process_result, bypass on cache" — three different places following the same anti-pattern. At minimum, log the exception; ideally also surface counts via result.metadata["test_errors"].

C4 — engine.process_result errors silently swallowed (same pattern)

optimize_anything.py:175–178:

try:
    engine.process_result(result, server.output_dir)
except Exception:
    pass

A bug in artifact persistence is invisible. Log it at minimum.

C5 — Cache is still bypassed when write_agent_state=True

You flagged this on the previous round (#8 above). Beyond the principled-fix angle, this also means write_agent_state runs do every eval twice for any candidate that gets re-touched — exactly the situation the cache was designed to amortize. If the only thing the cache is missing is trajectories, the right fix is to thread capture_traces through the cache key (or skip caching just for trace-captures, but log misses), not bypass.

C6 — evaluate_examples HTTP endpoint flattens train + val

eval_server.py agent-visible pool is train_set + val_set merged for the HTTP layer ("the agent never knows val existed"). For engines that should internally treat val differently (e.g., select-on-val, train-on-train), the HTTP layer's flattening loses the distinction. Either expose both, or document the flattening prominently. Combined with your #15 question ("should GEPA even have test?") this is a broader axis the API hasn't picked a stance on.

C7 — EvalFn arity dispatch is fragile

At eval_server.py:157–160:

if example is not None:
    score, info = self.eval_fn(candidate, example)
else:
    score, info = self.eval_fn(candidate)

The single-task path is now well-defined (example=None), so this is better than my earlier "arity detection" criticism. But (score, info) still relies on the user returning a 2-tuple — a legacy evaluator returning just score (allowed by legacy_optimize_anything) will fail with a TypeError: cannot unpack non-iterable float. Either normalize (wrap a bare float as (score, {})) or document explicitly that the new API requires the 2-tuple.

C8 — Cross-engine side_info parity (your 06-21 observation)

Your local meta_harness test from June 21 surfaced this. Current state: meta_harness has state/eval_traces/*.json written by _capture_eval_traces and the agent prompt explicitly instructs reading them. Autoresearch / BestOfN: the info dict is in the HTTP /evaluate response body, but I couldn't confirm the agent prompt routes the model to consume it. An explicit cross-engine integration test that asserts each engine's prompt/file layout actually surfaces the side_info dict to the model is the only way to keep this from regressing as engines are added.

C9 — Task.background "surfaced verbatim by every engine" is still naive

Same as your #18. Particularly painful for subprocess engines where prompt token count is the main cost driver. The natural fix matches the pattern meta_harness already uses for traces: write background.md into work_dir, instruct the agent to read it. Then the long-form context isn't repeated in every Claude /print invocation.

C10 — Task.metadata removed

8ce195d removed Task.metadata: dict[str, Any] = field(default_factory=dict). This was an extension point. The replacement is OptimizeAnythingConfig.engine_config, which is engine-coupled. Tasks no longer carry domain-typed context (e.g., task.metadata["run_mode"]). If this was intentional, fine; if not, it's a silent regression of API surface area. Worth confirming.

C11 — optimize_anything_with_server is in __all__ but not in the module

Imports happen via from gepa.oa.ensemble import optimize_anything_with_server, which works, but the public docs still point at it. Verify that mkdocs-generated pages actually render. (Minor; just spot-check the rendered docs.)

C12 — Engine process_result is "best effort" but its failure mode isn't tested

There's no test that asserts an engine's process_result is even called, let alone that artifacts land where expected. Easy to write a fake-engine test: assert the file shows up after a run, and assert a raising process_result doesn't crash the run.

C13 — Result.best_candidate: str (was a string-only constraint last review)

Still str. The legacy API supported dict[str, str] (multi-component candidates) and str. Internally the adapter still has the machinery, but Task.seed_candidate: str | None and Result.best_candidate: str foreclose the multi-component public shape. If a user ports a legacy multi-component setup, they hit a typing wall.

C14 — Run name uniqueness

_default_run_name(engine) returns {engine}-{uuid8}-{ts}. The UUID + timestamp combo is plenty unique, but the output_dir path also has its own timestamp in _resolve_output_dir. Two timestamps a couple lines apart in the same run. Probably OK; mention as nit.

C15 — OptimizeAnythingConfig.max_evals = 100 default

OptimizeAnythingConfig.max_evals: int | None = 100. That's the same kind of "default too low" that I just landed warnings about in #376 (the budget-floor PR). For any non-trivial valset, 100 evals is well under the ~16 × len(valset) floor. Either:

  • Drop the default to None and rely on the warning that now fires, or
  • Bump to something more like 200 with the same caveat.

Status quo silently configures small runs as a footgun.

Cross-cutting patterns from your past reviews (#100, dynamic validation)

Things you consistently push for, that this PR could still do better on:

  • "Can we ensure that the end user's experience is not changed." (PR Dynamic Validation in GEPAState #100 README comment.) C1 + C2 are the open ones.
  • "We can create a python Protocol for X." Engine is a Protocol — good. EvalServer is not. The eval-server interface is now load-bearing for every external engine; promoting it to a Protocol (or ABC) makes the future "swap in a remote eval server" / "swap in an MCP-backed eval server" story cleaner. Optional, but in the spirit of your PR Dynamic Validation in GEPAState #100 protocol comment.
  • "Should we introduce a notion of schema versions here?" (PR Dynamic Validation in GEPAState #100 state.py comment.) The iterations/ directory layout and gepa-result.json shape are both implicit schemas now. A _VERSION constant on the agent-state directory layout would save someone six months from now.
  • DRY in _save_* methods — only partially addressed.
  • Sensible defaults — don't force flags when context implies. write_agent_state is the obvious one. If run_dir is set and an agent-proposer is configured, defaulting write_agent_state=True is the same logic you've used elsewhere.
  • Naming clarity — generally improved here (initial→seed, name→config.name); good.

Criticality-ordered fix list

Blockers (must address before merge):

  1. uv.lock — revert (your standing comment).
  2. C1 — back-compat re-exports of GEPAConfig / EngineConfig / ReflectionConfig / MergeConfig / RefinerConfig / TrackingConfig / SideInfo / Evaluator / log / set_log_context / get_log_context / make_litellm_lm from gepa.optimize_anything. ~12 lines.
  3. C2 — isinstance(config, GEPAConfig) guard with a clear TypeError (or, better, automatic routing to legacy_optimize_anything.optimize_anything).
  4. C3 + C4 + C5 — stop swallowing exceptions in _score_test, process_result wrapper, and the cache-bypass branch. At minimum logger.exception(...); ideally surface counts.

High (should address):

  1. misc: split tb adapter into adapter folder #8 / C5 — extend cache to store trajectories rather than bypass it. Aligns with your principled-fix comment.
  2. Support pareto tracking of multiple scores returned by metric #2 / write_agent_state default — if run_dir + agent proposer is configured, default to True. The current default forces every agent-proposer user to flip the flag.
  3. C8 — cross-engine side_info integration test so the gap you found locally on meta_harness can't recur silently on a new engine.
  4. Centralize all configs into a single GEPAConfig #3 / state.py:38 regex — exclude . (or at least leading .).
  5. C9 / Fix token #18 — Stop putting background in every engine prompt; write to file for subprocess engines.
  6. C7 — Normalize/document the (score, info) contract so a bare-float evaluator from legacy doesn't blow up.
  7. C15 — max_evals=100 default — drop or raise per the budget guide.

Medium (nice to have, would clean up the API):

  1. feat: add terminal bench example #6 / engine.py:195 consistency question — author to confirm.
  2. feat: GEPAAdapter for any maths, reasoning dataset in HuggingFace; Ollama-friendly #7 / state.py DRY — collapse the remaining duplicated save paths.
  3. C13 — re-introduce multi-component candidate shape (str | dict[str, str]) on Task.seed_candidate and Result.best_candidate if you want to keep that capability publicly.
  4. C10 — confirm Task.metadata removal was intentional; if not, restore as a documented extension point.
  5. C6 — pick a stance on train/val flattening at HTTP and document.
  6. Schema version stamp on iterations/ and gepa-result.json (per your Dynamic Validation in GEPAState #100 pattern).
  7. C5 / autoresearch parity / Add a progress bar #5 — at minimum link the reference for the Karpathy autoresearch comparison.
  8. EvalServer as a Protocol for future remote/MCP swaps.

Low (nits):

  1. Two timestamps in default output_dir (C14).
  2. C11 — verify mkdocs renders the API pages.
  3. C12 — add a test for process_result failure isolation.
  4. Test coverage for the seed_candidate=None seedless mode — I didn't see one explicitly.

Net: the function shape is now genuinely close to legacy, which is the right move. The remaining backwards-compat gap (C1 + C2) plus the swallowed-exceptions pattern (C3 + C4 + C5) are the two patterns I'd want resolved before this lands.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread tests/test_attach_existing_run.py Outdated
Shangyint and others added 2 commits June 26, 2026 12:55
Rename src/gepa/legacy_optimize_anything.py to src/gepa/gepa_launcher.py
and update all references (gepa.legacy_optimize_anything -> gepa.gepa_launcher)
across source, tests, and generated API docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Drop the awkward "Legacy ... previously backed gepa.gepa_launcher"
phrasing now that the module is named gepa_launcher.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@LakshyAAAgrawal

Copy link
Copy Markdown
Contributor

optimize_anything orphans GEPAConfig.tracking — native wandb/mlflow is silently disabled (plus a few other core features)

Running the gepa engine through optimize_anything produces a near-empty wandb dashboard — only
eval/score, eval/best_score, eval/used, and per-iteration metrics. None of GEPA core's rich
tracking (Pareto-front evolution, per-candidate tables, the candidate-tree HTML) shows up, even
though docs/docs/guides/experiment-tracking.md advertises it and the standalone gepa.optimize()
path produces it.

Root cause. core exposes tracking as a top-level GEPAConfig field:

# gepa_launcher.py
@dataclass
class GEPAConfig:
    engine: EngineConfig
    reflection: ReflectionConfig
    tracking: TrackingConfig      # use_wandb / wandb_attach_existing / wandb_step_metric / mlflow
    merge: MergeConfig | None
    refiner: RefinerConfig | None
    stop_callbacks: ... 
    callbacks: ...

But GepaEngine (oa/engines/gepa.py) only forwards a subset of OptimizeAnythingConfig.engine_config:

_GEPA_CONFIG_KEYS = ("engine", "reflection", "merge", "refiner",
                     "objective", "background", "reflection_lm_kwargs",
                     "callbacks", "claude_code_agent")
# no "tracking" → GEPAConfig(...) is built without a TrackingConfig

So GEPA core runs with its default (stdout) tracker. The only wandb signal is the separate, thinner
OptimizeAnythingConfig.tracker hook (log_eval(used, score, best_score, cost) / log_metrics),
fired from the eval server and _ProgressCallback. Worse, because tracking isn't in
_GEPA_CONFIG_KEYS, a user who tries engine_config={"tracking": {"use_wandb": True}} gets it
warned-and-dropped — there's no way to turn the native tracker on through the OA path.

This is unfortunate because the native TrackingConfig is excellent and seems purpose-built for this
exact embedding: wandb_attach_existing=True + wandb_step_metric="gepa/iteration" would log GEPA's
full dashboard into the caller's existing run without step-collisions. mlflow is orphaned the
same way.

Suggested fix. Have GepaEngine translate tracking config into the GEPAConfig it builds — e.g.
add "tracking" to _GEPA_CONFIG_KEYS and pass GEPAConfig(tracking=TrackingConfig(**tracking), ...),
and/or, when OptimizeAnythingConfig.tracker/a wandb run is active, default to
TrackingConfig(use_wandb=True, wandb_attach_existing=True, wandb_step_metric="gepa/iteration"). Then
document, in experiment-tracking.md, that the OA tracker field and core TrackingConfig are two
different things.


Same mechanism hides other core features behind the OA layer. Because GepaEngine only forwards
the keys above, anything that lives at GEPAConfig top level (rather than inside the four
sub-config dicts) is unreachable via optimize_anything:

  • tracking — wandb + mlflow (above).
  • stop_callbacks — the OA path hardcodes only ScoreThresholdStopper (from stop_at_score)
    and MaxReflectionCostStopper (from max_token_cost). The other exported stoppers
    (NoImprovementStopper, MaxMetricCallsStopper, FileStopper, SignalStopper,
    CompositeStopper) and any custom StopperProtocol can't be passed through.
  • Multi-component candidates — core optimize_anything takes seed_candidate: str | Candidate
    where Candidate = dict[str, str] (jointly optimizing several named components, with the evaluator
    receiving the dict). The OA entry point and Task.seed_candidate are str only, so
    multi-component / multi-module optimization — one of GEPA's signature capabilities — isn't reachable
    through optimize_anything.
  • Custom logger (LoggerProtocol) — the lower-level core entry point accepts a logger; the
    OA path doesn't expose one.

And a set of features that are reachable but effectively undiscoverable — they only work via the
opaque engine_config["engine"|"reflection"] kwargs passthrough, aren't surfaced on
OptimizeAnythingConfig, and aren't shown in any OA example:

  • All EngineConfig knobs, including seed (reproducibility), frontier_type
    ("hybrid"/"objective"/...), candidate_selection_strategy, acceptance_criterion,
    val_evaluation_policy, num_parallel_proposals, display_progress_bar, track_best_outputs,
    best_example_evals_k.
  • Multi-objective Pareto. The adapter does honor info["scores"]objective_scores
    (optimize_anything_adapter.py) and frontier_type defaults to "hybrid", so you can optimize,
    e.g., correctness and speedup jointly — but the info["scores"] contract is documented nowhere a
    user would find it; the CUDA-kernel appendix returns a scalar score and never mentions it.

Net: the engine-as-narrow-passthrough design means GEPA's tracking, stop_callbacks,
multi-component candidates, and logger are silently lost through optimize_anything, and a large
slice of EngineConfig/multi-objective behavior is reachable only by reading core's dataclasses. A
short "GEPA via optimize_anything: what maps to what" doc plus forwarding tracking/stop_callbacks
(and a string-or-dict seed_candidate) would close most of the gap.

@LakshyAAAgrawal

Copy link
Copy Markdown
Contributor

The OA tracker is untyped and its contract is undocumented

OptimizeAnythingConfig.tracker: Any with the docstring "wandb/mlflow wrapper", but the required
methods — log_eval(used, score, best_score, cost) and log_metrics(metrics, step=None) — are
discoverable only by grepping oa/eval_server.py and the callbacks. Given Engine got a proper
Protocol, the tracker deserves one too (e.g. an OATracker Protocol), and
guides/experiment-tracking.md should document the OA tracker explicitly and contrast it with the
core ExperimentTracker (they are different objects with different method sets — a real source of
confusion, see #2).

engine_config typo handling. Unknown keys warn (good, and documented) but warnings are
easy to miss in long runs. Consider an opt-in strict mode, and recording the accepted-vs-ignored
keys in the run summary so a silent typo is auditable after the fact.

Two same-named entry points. gepa.optimize_anything.optimize_anything
(OptimizeAnythingConfig) vs gepa.gepa_launcher.optimize_anything (GEPAConfig) share a name
and most of a signature but take different config classes.

With the introduction of the new API, all the code examples in the optimize_anything blogpost will break, but it should be cleanly migrated to the new API.

@Shangyint

Shangyint commented Jun 28, 2026 via email

Copy link
Copy Markdown
Collaborator Author

@devin-ai-integration devin-ai-integration 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.

Devin Review found 8 potential issues.

Open in Devin Review

Comment on lines 151 to +158
if self.custom_candidate_proposer is not None:
return self.custom_candidate_proposer(candidate, reflective_dataset, components_to_update), empty, {}
new_texts = self.custom_candidate_proposer(
candidate,
reflective_dataset,
components_to_update,
metadata=metadata,
)
return new_texts, empty, {}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Existing custom candidate proposers crash when called with the new metadata keyword argument

The custom proposer is now called with an unconditional metadata=metadata keyword argument (self.custom_candidate_proposer(...) at src/gepa/proposer/reflective_mutation/reflective_mutation.py:152-157) but existing user-defined proposers following the documented 3-positional-arg signature will not accept it, so they raise TypeError at runtime.

Impact: Any existing user of custom_candidate_proposer whose function does not accept **kwargs or an explicit metadata parameter will crash on the first iteration.

Mechanism: the call site unconditionally passes a new keyword argument

The old call was:

return self.custom_candidate_proposer(candidate, reflective_dataset, components_to_update), empty, {}

The new call is:

new_texts = self.custom_candidate_proposer(
    candidate,
    reflective_dataset,
    components_to_update,
    metadata=metadata,
)

The public API documentation at src/gepa/api.py:150 still describes the old signature as (candidate, reflective_dataset, components_to_update) -> dict[str, str]. The gepa/gskill/gskill/train_optimize_anything.py:680 also passes a custom_candidate_proposer=proposer that may not accept metadata.

While the ProposalFn Protocol at src/gepa/core/adapter.py:38-46 now includes metadata as a keyword-only parameter with a default, this is a typing-level contract. At runtime, any Python function that doesn't list metadata or **kwargs in its signature will reject the unexpected keyword argument.

The fix should either: (a) inspect the custom proposer's signature before passing metadata, or (b) wrap the call in a try/except to fall back to the 3-arg form, or (c) use **kwargs on the call side only when the callee accepts it.

Prompt for agents
In src/gepa/proposer/reflective_mutation/reflective_mutation.py around lines 151-158, the custom_candidate_proposer is called with metadata=metadata unconditionally. This breaks any existing custom proposer that doesn't accept **kwargs or an explicit metadata parameter. The fix should inspect the proposer's signature (using inspect.signature) to determine whether it accepts a 'metadata' keyword argument or **kwargs, and only pass metadata when supported. Alternatively, wrap the call in a try/except TypeError and fall back to the 3-arg form. The same pattern used by EvaluatorWrapper._filter_kwargs in gepa_launcher.py (signature inspection once at construction) would be appropriate here.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread src/gepa/core/engine.py
Comment on lines +237 to +260
# When write_agent_state is on, bypass the cache so we can capture
# trajectories (which the cache does not store).
if self.write_agent_state:
batch = valset.fetch(val_ids)
eval_result = self.adapter.evaluate(batch, program, capture_traces=True)
outputs_by_val_idx = dict(zip(val_ids, eval_result.outputs, strict=False))
scores_by_val_idx = dict(zip(val_ids, eval_result.scores, strict=False))
objective_by_val_idx = (
dict(zip(val_ids, eval_result.objective_scores, strict=False))
if eval_result.objective_scores is not None
else None
)
trajectories_by_val_idx = (
dict(zip(val_ids, eval_result.trajectories, strict=False))
if eval_result.trajectories is not None
else None
)
state.increment_evals(len(val_ids))
return ValsetEvaluation(
outputs_by_val_id=outputs_by_val_idx,
scores_by_val_id=scores_by_val_idx,
objective_scores_by_val_id=objective_by_val_idx,
trajectories_by_val_id=trajectories_by_val_idx,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚩 write_agent_state bypasses evaluation cache entirely on every full-eval pass

When write_agent_state=True, the _evaluate_on_valset method at src/gepa/core/engine.py:239-260 skips the evaluation cache (state.cached_evaluate_full) and always calls self.adapter.evaluate() directly to capture trajectories. This means every accepted candidate triggers a fresh full-valset evaluation even if the same (candidate, example) pair was previously scored. For users who rely on cache_evaluation=True alongside write_agent_state=True, this silently nullifies the caching benefit on the full-eval path — potentially doubling the number of metric calls compared to the non-agent-state path. The tradeoff is documented in the comment ('the cache does not store trajectories') but users enabling both flags may not expect this cost increase.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +363 to +367
def stop(self) -> None:
if self._server:
self._server.shutdown()
self._server = None
self._pool.shutdown(wait=False)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚩 EvalServer.stop() shuts down ThreadPoolExecutor with wait=False — in-flight evals may be abandoned

At src/gepa/oa/eval_server.py:367, self._pool.shutdown(wait=False) is called during server stop. This means any in-flight parallel evaluations submitted via evaluate_examples will be abandoned without waiting for completion. If an engine calls evaluate_examples and then the budget is exhausted (triggering BudgetExhausted → server.stop() via the optimize_anything runner), partially-completed batch results may never be collected. The eval_log and budget counter may undercount actual eval_fn invocations that completed but whose futures were abandoned.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread src/gepa/core/engine.py
Comment on lines +214 to +224
for val_id, output in outputs.items():
path = os.path.join(out_dir, f"{val_id}.json")
with open(path, "w") as f:
json.dump(try_json_serialize(output), f, indent=2, default=json_default)
trajectories = valset_evaluation.trajectories_by_val_id or {}
if trajectories:
traj_dir = os.path.join(base, "trajectories")
os.makedirs(traj_dir, exist_ok=True)
for val_id, traj in trajectories.items():
path = os.path.join(traj_dir, f"{val_id}.json")
with open(path, "w") as f:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟥 Path traversal via unsanitized val_id in filesystem paths

User-controlled dataset item IDs (resolved via _resolve_id) are interpolated directly into filesystem paths when writing per-evaluation output files (_write_agent_iteration_files at src/gepa/core/engine.py:214-224). A dataset item with an id like ../../etc/passwd would write outside the intended directory tree.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +568 to +590
cmd += [
"claude",
"--print",
"--output-format",
"json",
"--model",
self.model,
]
if resume:
cmd.extend(["--resume", session_id])
else:
cmd.extend(["--session-id", session_id])
cmd.append(DENY_WEB_TOOLS)
# Single source of the permission posture (see claude_permission_args):
# bypassPermissions in the bwrap jail / unsandboxed, or the macOS
# Seatbelt settings whose --permission-mode default enforces the whitelist.
cmd.extend(claude_permission_args(work_dir, sandboxed=self.sandbox))
if self.max_thinking_tokens is None and self.effort is not None:
cmd.extend(["--effort", self.effort])
if self.max_token_cost is not None:
remaining = max(0.0, self.max_token_cost - adapter_cost)
cmd.extend(["--max-budget-usd", f"{remaining:.6f}"])
cmd.append(prompt)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟥 Subprocess command injection via unsanitized model name in claude CLI arguments

Engine-specific config values like model from OptimizeAnythingConfig.engine_config are passed directly into subprocess.Popen/subprocess.run command lists (src/gepa/oa/engines/autoresearch.py:575). While list-based subprocess calls prevent shell injection, the model string is not validated against an allowlist, and other string config values (like effort) flow into CLI arguments without sanitization.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +325 to +361
def start(self, port: int = 0) -> int:
"""Start the HTTP server. Returns the bound port."""
server_ref = self

class Handler(BaseHTTPRequestHandler):
def do_POST(self):
if self.path == "/evaluate":
server_ref._handle_evaluate(self)
elif self.path == "/evaluate_examples":
server_ref._handle_evaluate_examples(self)
elif self.path == "/validate":
server_ref._handle_validate(self)
else:
self.send_error(404)

def do_GET(self):
if self.path == "/status":
server_ref._handle_status(self)
elif self.path == "/task":
server_ref._handle_task_info(self)
else:
self.send_error(404)

def log_message(self, format, *args):
pass

# threading.HTTPServer would be better for high concurrency, but the
# underlying eval is already serialized through ``_eval_semaphore``;
# we keep the simple single-threaded HTTPServer to avoid surprising
# ordering, and rely on the thread pool inside evaluate_examples to
# parallelize batches.
from http.server import ThreadingHTTPServer

self._server = ThreadingHTTPServer(("localhost", port), Handler)
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
self._thread.start()
return self.port

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟨 HTTP eval server binds without authentication on localhost

The EvalServer HTTP endpoint (src/gepa/oa/eval_server.py:358) binds to localhost with no authentication mechanism. Any local process can POST to /evaluate or /evaluate_examples to consume the eval budget, inject arbitrary candidates, or read task metadata via GET /task.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread src/gepa/oa/sandbox.py
Comment on lines +229 to +258
def claude_permission_args(
work_dir: Path | str,
*,
sandboxed: bool,
extra_writable: list[Path | str] | None = None,
) -> list[str]:
"""Resolve the *single* tool-permission posture for a ``claude --print`` call.

Callers must use this instead of hardcoding ``--permission-mode`` so the
argv never carries two conflicting modes. Exactly one mode is emitted:

- **macOS + sandboxed** → ``--settings <seatbelt json> --permission-mode
default`` (via :func:`claude_settings_args`). ``default`` is what makes
the settings' ``permissions.allow`` whitelist enforce: in ``--print``
mode every unlisted tool auto-denies (no human to approve), so the
allowlist becomes a strict tool-layer whitelist layered on top of the
Seatbelt filesystem confinement.
- **Linux + sandboxed** → ``--permission-mode bypassPermissions``. The
bwrap jail (:func:`bwrap_prefix`) is the OS-level confinement and there
is no human to answer prompts in ``--print`` mode, so tool permissions
are bypassed inside the jail.
- **unsandboxed** (either platform) → ``--permission-mode bypassPermissions``.
"""
if sandboxed:
# On macOS this carries its own ``--permission-mode default``; on Linux
# it is empty (bwrap handles confinement), so we fall through to bypass.
settings = claude_settings_args(work_dir, extra_writable=extra_writable)
if settings:
return settings
return ["--permission-mode", "bypassPermissions"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟨 Sandbox bypass permission sets bypassPermissions for unsandboxed runs

When sandbox=False, claude_permission_args returns ['--permission-mode', 'bypassPermissions'] (src/gepa/oa/sandbox.py:251), granting the Claude Code subprocess unrestricted file and tool access. This is the default mode since sandbox defaults to False in OptimizeAnythingConfig.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread src/gepa/oa/budget.py
Comment on lines +54 to +60
def record(self, score: float) -> None:
"""Record one eval call. Raises BudgetExhausted if over eval limit."""
with self._lock:
if self.max_evals is not None and self._used >= self.max_evals:
raise BudgetExhausted(f"Eval budget exhausted: {self._used}/{self.max_evals} used")
self._used += 1
self._log.append({"eval": self._used, "score": score, "time": time.time()})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟨 Budget check has TOCTOU race allowing over-budget evaluations

The BudgetTracker.record() method (src/gepa/oa/budget.py:56-60) checks the budget and increments the counter under a lock, but EvalServer.evaluate() calls budget.check() and budget.record() as two separate operations (src/gepa/oa/eval_server.py:155-162). Concurrent callers can pass the check simultaneously before any records, exceeding the budget.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

…OATracker

Config forwarding for the optimize_anything engines is now uniform and typed:

- gepa engine: engine_config maps 1-to-1 to a GEPAConfig (built + validated in
  __init__, TypeError on unknown keys). tracking/reflection/merge/refiner/
  callbacks/stop_callbacks all flow through untouched — fixes wandb/mlflow
  tracking being silently dropped. The OA layer only sets the eval budget,
  run_dir, and the stop_at_score / max_reflection_cost limits as EngineConfig
  fields; GEPA core installs the matching stoppers.
- agent engines (autoresearch / meta_harness / best_of_n): each gets a typed
  config dataclass (AutoResearchConfig / MetaHarnessConfig / BestOfNConfig)
  built via Cls(**engine_config); the hand-maintained _*_CONFIG_KEYS tuples are
  gone.
- effort / max_thinking_tokens are no longer top-level OptimizeAnythingConfig
  fields — they live in each agent engine's engine_config (the gepa engine takes
  reasoning knobs via reflection.reflection_lm_kwargs).
- OATracker removed wholesale (module, config field, eval-server hook, engine
  calls); experiment tracking for gepa is configured through GEPAConfig.tracking.

Core changes:
- persist iterations/<id>/reflective_dataset.json under write_agent_state via a
  ReflectiveDatasetDumpCallback auto-registered by the launcher (was a private
  OA callback patching a core gap).
- EngineConfig.stop_at_score field + its ScoreThresholdStopper.
- MaxReflectionCostStopper binds to the effective cost source
  (custom_candidate_proposer or reflection_lm) so the cap fires in agent mode.

Rename oa/_helpers.py -> oa/utils.py.
@Shangyint

Copy link
Copy Markdown
Collaborator Author

@LakshyAAAgrawal latest commit refactored a bunch of things: now engine_config is parsed exactly into our old GEPAConfig (in the original optimize_anything). Now the tracking should work exactly the same as the old one! For autoresearch or mh tracking, we should address them in a later PR.

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.

4 participants