feat: stateful sessions and coding agent support for LLM reflections#305
feat: stateful sessions and coding agent support for LLM reflections#305lukedhlee wants to merge 31 commits into
Conversation
…r, ArtifactStore Add four new core protocols to support coding agent integration natively: - Session: forkable/resumable interaction context for LLMs and CLI agents (fork, reset, send). Includes MessageListSession and NullSession. - WorkspaceManager: isolated workspace lifecycle for code candidates (create, fork, resolve, diff, cleanup). Includes DirectoryCopy and Null. - ArtifactStore: pluggable persistence for candidates, lineage, and eval results. Includes InMemory and FileSystem implementations. - AgentProposer: protocol composing Session + WorkspaceManager for session-aware candidate generation. Also adds ArtifactStoreCallback for engine integration and optional session_id field on CandidateProposal. No changes to engine, state, adapter, result, or optimize_anything. All existing tests pass.
Changed Files
|
AgentProposer was a separate protocol for agent-driven proposals, but coding agents should work through the existing reflection LM mechanism. A Session wrapping Claude Code satisfies LanguageModel = (str) -> str. Changes: - Delete agent_proposer.py — no separate proposer needed - Revert session_id on CandidateProposal — sessions are internal to LM - Add make_session_lm() to session.py — bridges Session into LanguageModel protocol so any session can be used as a reflection LM
SessionManager combines three concerns: - Registry: candidate_idx → Session mapping (the session tree) - Strategy: pluggable selection of continue/branch/fork/custom - Provider: current_session() for dynamic make_session_lm binding Built-in strategies: - "continue": reuse parent's session (history grows) - "branch": fresh session, no history (e.g. new exploration from any candidate) - "fork": deep-copy parent's session (history preserved, diverges after) - Custom callable: (parent_idx, sessions) -> Session Also updates make_session_lm to accept either a fixed Session or a callable provider (e.g. manager.current_session).
…ations Session operations (fork/reset/send) are primitives on a Session. A strategy is the higher-level policy that governs the session tree — when to fork vs continue vs reset, which candidate to branch from. New SessionStrategy protocol with built-in implementations: - AlwaysContinueStrategy: reuse parent's session (history grows) - AlwaysFreshStrategy: fresh session each time (no history) - AlwaysForkStrategy: deep-copy parent's session (diverges after) Custom strategies implement SessionStrategy.select() and can do anything: random, LLM-decided, explore-exploit, population-based, etc.
Session now has four clearly defined operations: - send(): continue conversation (history grows, mutates current) - fork(): new session with history copied (original untouched) - branch(): new session with no history, same config (original untouched) - reset(): clear history in-place (same session object) branch() is distinct from fork()+reset() — it skips the expensive history copy. For Claude Code: branch() = new `claude -p` (cheap), fork() = checkpoint + resume (uses cache). AlwaysFreshStrategy renamed to AlwaysBranchStrategy to use the session primitive directly (parent.branch() instead of factory()).
With branch() (new session, no history, original untouched), there's no need for reset() (destructive in-place clear). branch() is strictly better: you get a clean session AND preserve the original. Session protocol is now three clean operations: - send(): continue conversation (history grows) - fork(): new session with history copied (original untouched) - branch(): new session with no history (original untouched)
"resume" better describes the intent: continuing the conversation. Session protocol is now: resume(), fork(), branch().
Session protocol: - Remove branch() — creating fresh sessions is the manager's job (create) - Two operations remain: resume() and fork() SessionStrategy protocol: - Add checkpoint() method for pluggable checkpoint policy - select() now receives current live session + immutable checkpoints - Rename: factory → create, sessions → checkpoints Built-in strategies: - AlwaysContinueStrategy → AlwaysResume - AlwaysBranchStrategy → AlwaysCreate - AlwaysForkStrategy → AlwaysFork SessionManager: - register() → checkpoint(candidate_idx, accepted) - _sessions → _checkpoints - Fix checkpoint immutability: stored snapshots can't be mutated by the live session continuing to grow
SessionStrategy simplified to a single method: select(sessions: list[Session], create) -> Session The strategy sees the pool of existing sessions and picks one (resume), forks one, or creates new. It doesn't know about candidates — that mapping is the engine's concern. SessionManager simplified: - Tracks a pool of sessions (list), not a candidate->session map - select() delegates to strategy, adds new sessions to pool - No checkpoint() method — no candidate awareness Built-in strategies: - AlwaysResume: reuse the most recent session - AlwaysFork: fork the most recent session - AlwaysCreate: fresh session every time Session protocol unchanged: resume() + fork()
Session protocol: - send() — talk to the session (was resume) - fork() — clone session with history (strategy: "continue with context") - reset() — new session, no history, same config (strategy: "clean slate") Strategy is a binary decision: fork or reset. - AlwaysFork: fork most recent session from pool - AlwaysReset: reset most recent session from pool - create() factory only needed to bootstrap the first session Dropped AlwaysResume/AlwaysCreate — fork and reset replace them. Default strategy changed to AlwaysFork.
- Remove label parameter from fork() and reset() — session_id is auto-generated (LLM API) or backend-assigned (Claude Code) - Add RandomStrategy: randomly fork or reset with configurable probability - Add RoundRobin: alternate between fork and reset - Fix NullSession fork/reset to generate unique session IDs
Integration:
- Add session_strategy field to ReflectionConfig
Accepts: "fork", "reset", "random", "round_robin", or a SessionStrategy instance
Default: None (no session, backward compatible)
- Build SessionManager in optimize_anything wiring code (step 11)
- Add optional session_manager param to ReflectiveMutationProposer
- In propose_new_texts: if session_manager exists, select() a session
and use make_session_lm() as the reflection LM for that iteration
Usage:
optimize_anything(
...,
reflection=ReflectionConfig(session_strategy="fork"),
)
- Change default from None to "reset" so sessions are always exercised - AlwaysReset = stateless, identical to current GEPA behavior - Swapping to "fork" or adding a ClaudeCodeSession is now just config - SessionManager always built in optimize_anything wiring
- Add session_strategy param to gepa.optimize() in api.py - Build SessionManager and pass to proposer, same as optimize_anything - Both entry points now always go through sessions - Default "reset" = stateless, identical to current behavior - Users can switch to "fork", "random", "round_robin" via config
Architecture (hybrid of Option A + B): - Engine owns session lifecycle: select() per iteration - Proposer unchanged: sees a plain LanguageModel callable - Entry points use resolve_session_strategy() helper + minimal wiring - No duplicated strategy resolution logic Changes: - session.py: rename MessageListSession → LLMSession, add resolve_session_strategy() - engine.py: +session_manager param, select() at iteration start - api.py: 6-line hybrid wiring (resolve + SessionManager + dynamic LM) - optimize_anything.py: same 6-line pattern - reflective_mutation.py: reverted — no session awareness
Agent sessions implement the Session protocol using CLI tools: - ClaudeCodeSession: claude -p, --resume, --fork-session - OpenCodeSession: opencode run, --session, --fork Both support: - send() via non-interactive CLI with JSON output - fork() via native fork flags (prompt cache efficient) - reset() creates a fresh process - Cost tracking: last_send_cost, total_cost properties String resolution in entry points: - "claude_code/sonnet" → ClaudeCodeSession(model="sonnet") - "opencode/opus" → OpenCodeSession(model="opus") - Other strings → litellm (existing path)
- LLMSession.send() passes list[dict] to api_call, but underlying LMs expect str. Added _session_api_call bridge that extracts last user content as plain string. - make_session_lm: add fallback param for multimodal prompts (images) that sessions can't handle — bypasses session, calls LM directly. - Fix pyright type errors with type: ignore[assignment] for dynamic LM.
…tegies - Move ClaudeCodeSession → src/gepa/agents/claude_code.py - Move OpenCodeSession → src/gepa/agents/opencode.py - Extract AlwaysFork, AlwaysReset, RandomStrategy, RoundRobin → src/gepa/strategies/session_strategy.py - core/session.py keeps only: protocol, LLMSession, NullSession, SessionManager, resolve_session_strategy, make_session_lm
| "-p", | ||
| "--model", self._model, | ||
| "--output-format", "json", | ||
| "--dangerously-skip-permissions", |
There was a problem hiding this comment.
Let's not make this default. Is there a safe option by default, that never uses any file writes, and only performs reads?
There was a problem hiding this comment.
Reads do not need permission in CC. Removing this flag should suffice
Shangyint
left a comment
There was a problem hiding this comment.
Looks good!
I think a core feature is not supported, though. A common way to use Session is to use Session from the parent (probably means we fork every time we generate another child candidate?) If we want to support this, we probably need to track state, or at least candidate index for sessions
Another point is: do we want sessions as a separate concept, or can we just upgrade current proposer with sessions? i.e. all proposers are stateful by default, and backcompitiblility can be provided by resetting sessions every proposer call.
| "-p", | ||
| "--model", self._model, | ||
| "--output-format", "json", | ||
| "--dangerously-skip-permissions", |
There was a problem hiding this comment.
Reads do not need permission in CC. Removing this flag should suffice
…tracking Replace SessionSelector+SessionAction split with a single SessionStrategy protocol that has two hooks: select(ctx) before mutation, observe(outcome) after. The manager now holds a keyed dict[int|str, SessionEntry] store instead of a flat list, enabling candidate-linked session tracking. Key changes: - SessionEntry(session, val_score) for score-aware strategies - SessionContext/SessionOutcome frozen dataclasses for pure strategies - 5 built-in strategies: AlwaysFork, AlwaysReset, Random, RoundRobin, ParentLinked - ParentLinked: forks from parent candidate's session (lineage-aware) - Remove --dangerously-skip-permissions from ClaudeCodeSession defaults - LLMSession/NullSession now explicitly inherit Session protocol - Delete session_components.py (replaced by session_strategy.py)
…ude Code Tests simulate the full optimization loop (select → send → evaluate → observe) for both LLMSession and ClaudeCodeSession paths. Covers: - Fork/reset/parent-linked strategies accumulating history across iterations - Rejected candidates not binding in the session store - val_score tracking and best-candidate lookup - dynamic_lm routing through SessionManager.current_session - CLI flag correctness (--resume, --fork-session, no --dangerously-skip-permissions)
…ests Engine now calls session_manager.observe() at both accept and reject points in the optimization loop, so the session store actually populates during real runs. _run_full_eval_and_add returns val_score for the observe call. Integration tests call gepa.optimize() with session_strategy='fork', 'reset', and None (default) using a MinimalAdapter + mock LM, proving the full wiring works end-to-end.
The proposer knows the parent candidate (it picks it), so it's the natural place to call session_manager.select(parent_candidate_idx=curr_prog_id). The engine keeps observe() since it knows the evaluation outcome. - Proposer: optional session_manager param, select() after parent pick - Engine: remove select() call, keep observe() at accept/reject points - api.py/optimize_anything.py: pass session_manager to both (with comments) - Add parent_linked integration test via gepa.optimize()
…anager.py session.py (160 lines): Session protocol, LLMSession, NullSession — just the conversation primitive. session_manager.py (250 lines): SessionEntry, SessionContext, SessionOutcome, SessionStrategy, SessionManager, resolve_session_strategy, make_session_lm — the orchestration layer. A reader opening session.py sees "Session = send/fork/reset" and nothing else.
…essage opencode CLI has no --system flag. System prompt is now prepended to the first user message instead. Verified with real opencode CLI: send, fork, and reset all work correctly.
Resolve conflicts with acceptance_criterion, parallel proposals, callbacks, and propose_output refactor from main. Wire session observe() into _accept_reflective_proposal.
…ry loss - Remove observe() from SessionStrategy protocol — bookkeeping (store on accept, skip on reject) now lives in SessionManager.observe() - Remove SessionOutcome dataclass (no longer needed) - All 5 built-in strategies now only implement select() - Strategies explicitly inherit SessionStrategy protocol - Add BestScore strategy: fork from the highest-scoring session - Fix LLMSession history loss: pass full message list to reflection_lm instead of extracting only the last user message - Colocate LM resolution and session setup in api.py/optimize_anything.py - Rename dynamic_lm → session_lm - Add CodingAgent native session path (no double-wrapping)
Shangyint
left a comment
There was a problem hiding this comment.
I added several comments regarding how to make session_manager less stateful. For example, I think session_manager._current is not useful and may make the code much harder to maintain later. We only need a map from candidates to sessions IMO.
| ``"parent_linked"``, ``"best_score"``) or an already-instantiated ``SessionStrategy``. | ||
| """ | ||
| if not isinstance(strategy, str): | ||
| return strategy |
There was a problem hiding this comment.
Check if strategy is an instance of SessionStrategy?
| # --------------------------------------------------------------------------- | ||
|
|
||
|
|
||
| def make_session_lm( |
There was a problem hiding this comment.
Not sure if we want this. Is this for backward compatibility? Otherwise LLMSession can just provide everything we need for LanguageModel?
| """The iteration counter, incremented on each ``select()`` call.""" | ||
| return self._iteration | ||
|
|
||
| def select(self, parent_candidate_idx: int | None = None) -> Session: |
There was a problem hiding this comment.
By the way, I don't link we need session_manager.current at all. IMO, we can always pass the session around as a return value of session_manager.select.
| is_seed_candidate = curr_prog_id == 0 | ||
|
|
||
| return ProposalContext( | ||
| iteration=i, |
There was a problem hiding this comment.
you can add the needed session as part of the context here.
| """ | ||
|
|
||
| def lm(prompt: str | list[dict[str, Any]]) -> str: | ||
| sess = session() if callable(session) and not isinstance(session, Session) else session |
There was a problem hiding this comment.
What's the reason to allow a lambda instead of a concrete session instance here? Can we just allow session: Session?
|
|
||
| # Select session for this mutation — the LM calls below route through it | ||
| if self.session_manager is not None: | ||
| self.session_manager.select(parent_candidate_idx=curr_prog_id) |
There was a problem hiding this comment.
Similar problem here: this is not good. It works, but it is easier to introduce bugs because we are setting some internal state of session_manager. We should just return select and put it into the proposal context.
| create=_coding_agent_ref.create_session, # type: ignore[union-attr] | ||
| strategy=_strategy, | ||
| ) | ||
| session_lm = make_session_lm(session_manager.current_session) # type: ignore[assignment] |
There was a problem hiding this comment.
Ideally this should look like make_session_lm(session_manager.new_session())
…ontract Replaces the reset/read bracket pattern (which stashed cost in LM.threading.local and had the proposer reach in to read it back) with a straight return-value chain: LM.call_with_cost -> run_with_metadata -> propose_new_texts -> CandidateProposal.reflection_cost. Why: Shangyin's feedback on #305 flagged the same pattern ("setting internal state of session_manager... easier to introduce bugs"). Cost tracking had the same producer/consumer-via-mutable-state smell. Returning cost directly eliminates it. Changes: - LM: drop threading.local, reset_call_cost, get_call_cost. Add call_with_cost(prompt) -> (text, cost). __call__ is now a thin wrapper around call_with_cost that returns just the text (backward compatible). - BasePromptSignature.run_with_metadata: return 4-tuple including cost. Duck-types on call_with_cost for the LM; falls back to lm(prompt) with cost=0.0 for plain callables. - ReflectiveMutationProposer.propose_new_texts: returns 4-tuple with total_cost summed across component calls. execute_proposal drops the bracket entirely — cost arrives as a return value. Session-friendly: when agent-mode sessions land, make_session_lm just needs to attach a call_with_cost closure that reads session.total_cost deltas. The rest of the chain (run_with_metadata, propose_new_texts, engine accept/reject) works unchanged. Parallel safety: no longer relies on threading.local — each call's cost is computed and returned synchronously within one stack frame. Parallel workers each accumulate into their own local total_cost variable, so there's no shared mutable state to race on. Test updates: - TestLMCostTracking -> TestLMCallWithCost - Thread-local isolation test replaced with a ThreadPoolExecutor-based test that witnesses per-call cost conservation under concurrency - New test verifying plain callables (no call_with_cost method) still work via run_with_metadata's getattr fallback — this is the session-friendly path that make_session_lm closures will use - E2E sequential + parallel tests unchanged, still pass All 8 tests in test_reflection_cost_tracking.py pass in 0.78s. Ruff + pyright clean on edited files.
* feat(cost): track per-proposal reflection LM cost in GEPAState Persists reflection LM cost in gepa_state.bin so users can plot cumulative cost vs metric-call budget — for both accepted and rejected proposals — by reading state directly during or after a run. - LM (lm.py): accumulate litellm.completion_cost() per call into a threading.local slot; expose reset_call_cost() / get_call_cost(). Thread-local keeps parallel proposal workers from racing on a shared counter. - GEPAState: bump schema to v6. Add reflection_cost_by_candidate (parallel to program_candidates), reflection_cost_rejected, and num_metric_calls_at_rejection so every proposal has a (metric_calls, cost) point. Backfills in _upgrade_state_dict keep older pickles loadable. - CandidateProposal: add reflection_cost: float = 0.0. - ReflectiveMutationProposer: reset/read the LM cost counter around propose_new_texts via duck-typed getattr — custom reflection_lm callables without the contract contribute 0.0 gracefully. - Engine: reject branch appends to reflection_cost_rejected / num_metric_calls_at_rejection; accept branch threads the cost through _run_full_eval_and_add → update_state_with_new_program. - test_candidate_selector: fixture now appends to the new list to satisfy the is_consistent() invariant. After a run: state = GEPAState.load(run_dir) accepted = sum(state.reflection_cost_by_candidate) rejected = sum(state.reflection_cost_rejected) # zip with num_metric_calls_by_discovery / num_metric_calls_at_rejection # for a step-function plot of cost vs budget. * feat(cost): add total_reflection_cost property to GEPAState Derived accessor summing reflection_cost_by_candidate + reflection_cost_rejected so users can print a running total without assembling the sum themselves. Purely computed — no new pickled field, no schema change. * test(cost): add E2E reflection cost tracking tests Covers three layers: 1. LM unit tests (7): per-thread accumulation, reset, thread-local isolation across concurrent workers, graceful fallback when litellm.completion_cost raises or returns None. 2. E2E sequential (1): optimize_anything with a real LM + mocked litellm, asserting every proposal (accepted + rejected) lands in the parallel state lists with the expected per-call cost, and that total_reflection_cost conserves cost_per_call * total LM calls. 3. E2E parallel (1): same pipeline with num_parallel_proposals=2, which exercises the ThreadPoolExecutor dispatch path and witnesses that the LM's threading.local isolation survives the chain through proposer bracket -> engine accept/reject -> state persistence. Per-entry cost == cost_per_call catches cases where one worker's reset would clobber another's accumulated slot. 9 tests, all passing locally. * refactor(cost): return-value-driven cost tracking, session-friendly contract Replaces the reset/read bracket pattern (which stashed cost in LM.threading.local and had the proposer reach in to read it back) with a straight return-value chain: LM.call_with_cost -> run_with_metadata -> propose_new_texts -> CandidateProposal.reflection_cost. Why: Shangyin's feedback on #305 flagged the same pattern ("setting internal state of session_manager... easier to introduce bugs"). Cost tracking had the same producer/consumer-via-mutable-state smell. Returning cost directly eliminates it. Changes: - LM: drop threading.local, reset_call_cost, get_call_cost. Add call_with_cost(prompt) -> (text, cost). __call__ is now a thin wrapper around call_with_cost that returns just the text (backward compatible). - BasePromptSignature.run_with_metadata: return 4-tuple including cost. Duck-types on call_with_cost for the LM; falls back to lm(prompt) with cost=0.0 for plain callables. - ReflectiveMutationProposer.propose_new_texts: returns 4-tuple with total_cost summed across component calls. execute_proposal drops the bracket entirely — cost arrives as a return value. Session-friendly: when agent-mode sessions land, make_session_lm just needs to attach a call_with_cost closure that reads session.total_cost deltas. The rest of the chain (run_with_metadata, propose_new_texts, engine accept/reject) works unchanged. Parallel safety: no longer relies on threading.local — each call's cost is computed and returned synchronously within one stack frame. Parallel workers each accumulate into their own local total_cost variable, so there's no shared mutable state to race on. Test updates: - TestLMCostTracking -> TestLMCallWithCost - Thread-local isolation test replaced with a ThreadPoolExecutor-based test that witnesses per-call cost conservation under concurrency - New test verifying plain callables (no call_with_cost method) still work via run_with_metadata's getattr fallback — this is the session-friendly path that make_session_lm closures will use - E2E sequential + parallel tests unchanged, still pass All 8 tests in test_reflection_cost_tracking.py pass in 0.78s. Ruff + pyright clean on edited files. * feat(result): expose total_reflection_cost on GEPAResult Users can now read the total reflection LM cost directly from the result object without loading GEPAState from disk: result = gepa.optimize(...) print(f"${result.total_reflection_cost:.4f}") Populated from state.total_reflection_cost in GEPAResult.from_state; round-trips through to_dict/from_dict. Defaults to 0.0 when cost tracking isn't available. Also trims the test file from 8 tests to 4 logical tests (6 runs after parametrization): - test_call_with_cost_returns_tuple (3 params: normal, None, raises) - test_plain_callable_bypasses_call_with_cost - test_cost_populates_state_e2e (2 params: sequential, parallel) Dropped: - test_call_still_returns_plain_str (trivial, covered by test_lm.py) - test_parallel_calls_each_return_own_cost (obsolete — with the return-value refactor there's no shared state to race on; the E2E parallel test exercises the real concurrency path) - test_completion_cost_{exception,none}_handled (merged into the parametrized call_with_cost test) All tests pass in 0.77s. * fix(result): drop float() coercion on total_reflection_cost getattr CI failed on tests/test_module_selector.py which passes a generic Mock() as state. Mocks auto-create child Mocks for any attribute access, so getattr(state, "total_reflection_cost", 0.0) returns a Mock rather than hitting the default — and float(mock) raises. Matches the pattern used for other getattr fallbacks in from_state (total_metric_calls, num_full_val_evals, best_outputs_valset) which pass through whatever the state returns without coercion. Real GEPAState always stores a float; Mock-based tests now pass through a Mock without crashing. * rename(cost): reflection_cost_rejected -> reflection_cost_by_rejected The plain "rejected" name reads like a scalar total. Matching the existing "reflection_cost_by_candidate" naming makes both fields obviously parallel lists indexed by their respective proposal outcomes. Rename is internal-only — no breaking changes for users (both the field and the schema version are new in this PR, no old pickles in the wild). * feat(cost): add MaxReflectionCostStopper for USD budget control Addresses Shangyin's ask on #325 — a stopping condition keyed to the reflection cost total, following the MaxMetricCallsStopper pattern. - src/gepa/utils/stop_condition.py: new MaxReflectionCostStopper that reads state.total_reflection_cost and stops when it crosses the USD budget. - src/gepa/utils/__init__.py: re-export the new class. - src/gepa/optimize_anything.py: add max_reflection_cost to EngineConfig and wire into the stop_callbacks_list assembly, symmetric to max_metric_calls / max_candidate_proposals. - src/gepa/api.py: add max_reflection_cost to gepa.optimize() and thread it into the stopper list. Docstring updated. - tests/test_reflection_cost_tracking.py: E2E test that sets a $0.025 budget, runs with mocked $0.01/call, and verifies the run stops between [budget, budget + one_call_cost] — matches the "fire on crossing" semantic. Honest behavior for custom reflection LMs: they report 0.0 cost, total_reflection_cost never grows, and the stopper never fires. Users who want budget enforcement must either pass an LM (or a custom class exposing call_with_cost) or use a different stopper. 7/7 cost-tracking tests pass.
feat: stateful sessions and coding agents for reflection
Summary
Adds two composable primitives to GEPA's reflection layer:
Session— a forkable LLM conversation. Reflection can now remember prior proposals across iterations.CodingAgent— a first-class abstraction for CLI coding tools (Claude Code, OpenCode) that plugs intoreflection_lmthe same way a regular LM does.Zero breaking changes.
session_strategy=None(default) preserves current stateless behavior.Why
ParentLinked,RandomStrategy, or custom strategies) explores distinct lineages rather than collapsing onto one trajectory.AlwaysReseton a schedule — orRoundRobin— drops stale history before it drags the LLM into its own loops.SessionRecord.val_scorelets future strategies ("pick the best lineage") make score-aware decisions.Usage
Stateless (default, unchanged)
Stateful LLM sessions
Lineage-aware sessions
Coding agent as reflection LM
Concepts
Sessionprotocolfork()andreset()leave the original untouched — both return a new session.send(content)fork()reset()Implementations:
LLMSession(in-memory),NullSession(no-op),ClaudeCodeSession/OpenCodeSession(CLI subprocess with native--resume/--fork-session).SessionStrategyprotocol (one hook)select(ctx)— before each mutation: pick the session to use. Called by the proposer right after it picks the parent candidate, soctx.parent_candidate_idxis always available.Bookkeeping (storing accepted candidates, tracking scores) is handled by
SessionManager.observe()— strategies only decide which session to use.SessionContextandSessionRecordSessionRecordis what gets stored per accepted candidate — the session and its score.SessionContextis a snapshot rebuilt everyselect()call so the strategy can see the full store, which candidate is being mutated, and a factory to create fresh sessions.How session lifecycle works (step by step)
The
SessionManagerholds adict[int | str, SessionRecord]— a store that maps candidate indices to their sessions and scores. It starts empty and grows as candidates are accepted.Each iteration has two phases:
Phase 1:
select()— proposer picks a session before the LM callsPhase 2:
observe()— engine records the outcome after evaluationThe store grows iteration by iteration:
Keys are candidate indices (not iteration numbers) — rejected candidates don't appear.
The types:
SessionRecord(session, val_score)— what's stored per accepted candidate.SessionContext— rebuilt everyselect()call. Gives the strategy a read-only view of the store plus "what's happening now" (parent, iteration).Built-in strategies
All built-in strategies except
ParentLinkedoperate on the most recent session in the store. They differ only in the action (fork vs reset):AlwaysForkAlwaysResetRandomStrategy(p)p, else resetRoundRobinParentLinkedBestScoreCustom strategies can combine any selection logic with any action. For example, "pick the best-scoring session and fork it" is a ~10-line class.
Writing a custom strategy is one method:
(This is already built-in as
BestScore/session_strategy="best_score")SessionManager(keyed store + strategy delegation)The manager holds
dict[int | str, SessionRecord]. Accepted candidates are stored by index; rejected candidates are not stored.CodingAgentA thin factory that creates a
Sessionfor a given CLI backend. Each harness encapsulates its own quirks (JSON output format, CLI flags, cost tracking) behind theSessionprotocol.Passing a
CodingAgentasreflection_lmtriggersisinstancedetection inapi.pyand auto-wires the session pipeline — no boilerplate.Architecture
Session lifecycle is split between the proposer and the engine — each owns the part it has the information for:
select(parent_candidate_idx)— it knows the parent (it just picked it).observe(candidate_idx, accepted, val_score)— it knows the outcome (it just evaluated it).session_strategy="fork".The proposer sees
reflection_lmas a plainlm(prompt) -> strcallable. Under the hood,make_session_lmcreates a closure that readssession_manager.current_session(), whichselect()just set. The proposer never imports or touches any session object.History preservation:
LLMSession.send()passes the full conversation history to the underlying LM callable. For plain LMs (e.g.LMwrapping litellm), the callable receives the fulllist[dict]message list — no lossy extraction. ForCodingAgent, the CLI backend (Claude Code/OpenCode) natively manages its own history via--resume/--fork-sessionflags.Custom proposers that don't know about sessions keep working — they just call
reflection_lm(prompt)and sessions work transparently via the closure. Advanced custom proposers can injectsession_managerand callselect()themselves for full control.Session tree ≅ Candidate tree (with
ParentLinked)Each accepted candidate is bound to the session that produced it. Re-visiting the same parent forks independently, so siblings never interfere.
Tests
tests/test_session.py) — Session protocol, LLMSession, NullSession, make_session_lm, all 5 strategies, SessionManager with keyed store, val_score tracking, resolver, end-to-end mock loops (fork/reset/parent_linked),gepa.optimize()integration with all strategiestests/test_agents.py) — ClaudeCodeSession, OpenCodeSession, CodingAgent, strategy composition with agents, end-to-end mock loops (fork/parent_linked/CLI flags), auto-wire simulation, real CLI (skipped if not installed)ruff checkclean on all changed filesTODO (follow-ups)
Session persistence (codec layer).
SessionCodecprotocol to serialize/restore sessions to disk alongsidegepa_state.binfor checkpoint/resume.Performance benchmark. A/B on AIME math comparing
session_strategy=Nonevs"fork"vs"parent_linked".Richer state presentation. Human-readable JSON/YAML with all trajectories and scores for agent reflection.
note.mdscratchpad. Persistent file where reflection LLMs can write down insights across iterations.Files changed
src/gepa/core/session.pySessionprotocol,LLMSession,NullSession— the conversation primitivesrc/gepa/core/session_manager.pySessionStrategyprotocol (select only),SessionContext/SessionRecorddataclasses,SessionManager(owns observe bookkeeping),resolve_session_strategy,make_session_lm— the orchestration layersrc/gepa/strategies/session_strategy.pyAlwaysFork,AlwaysReset,RandomStrategy,RoundRobin,ParentLinked,BestScore— all inheritSessionStrategysrc/gepa/agents/__init__.pyCodingAgentfactorysrc/gepa/agents/claude_code.pyClaudeCodeSession— wrapsclaude -pCLIsrc/gepa/agents/opencode.pyOpenCodeSession— wrapsopencode runCLIsrc/gepa/core/engine.pysession_manager.observe()at accept/reject pointssrc/gepa/proposer/reflective_mutation/reflective_mutation.pysession_manager,select(parent_candidate_idx)after parent picksrc/gepa/api.pysession_strategyparam,CodingAgentauto-wire, session wiring to proposer + engine. LM resolution and session setup are colocated — CodingAgent uses native sessions directly, plain LMs are wrapped inLLMSessionwith full message history passed through.src/gepa/optimize_anything.pyreflection_lmpassed directly asLLMSession.api_call(no lossy bridge)tests/test_session.pytests/test_agents.py