Skip to content

feat: stateful sessions and coding agent support for LLM reflections#305

Open
lukedhlee wants to merge 31 commits into
mainfrom
feature/agent-mode-core-abstractions
Open

feat: stateful sessions and coding agent support for LLM reflections#305
lukedhlee wants to merge 31 commits into
mainfrom
feature/agent-mode-core-abstractions

Conversation

@lukedhlee

@lukedhlee lukedhlee commented Apr 3, 2026

Copy link
Copy Markdown
Collaborator

feat: stateful sessions and coding agents for reflection

Summary

Adds two composable primitives to GEPA's reflection layer:

  1. Session — a forkable LLM conversation. Reflection can now remember prior proposals across iterations.
  2. CodingAgent — a first-class abstraction for CLI coding tools (Claude Code, OpenCode) that plugs into reflection_lm the same way a regular LM does.

Zero breaking changes. session_strategy=None (default) preserves current stateless behavior.

Why

  • Use more context. Instead of rebuilding state from scratch every iteration, the LLM carries forward what it already learned from prior mutations.
  • Promote diversity. Forking from different branches of the optimization tree (via ParentLinked, RandomStrategy, or custom strategies) explores distinct lineages rather than collapsing onto one trajectory.
  • Prevent context rot. AlwaysReset on a schedule — or RoundRobin — drops stale history before it drags the LLM into its own loops.
  • Track quality per session. SessionRecord.val_score lets future strategies ("pick the best lineage") make score-aware decisions.

Usage

Stateless (default, unchanged)

optimize(..., reflection_lm="openai/gpt-4.1")
# session_strategy=None is the default — same as before

Stateful LLM sessions

optimize(
    ...,
    reflection_lm="openai/gpt-4.1",
    session_strategy="fork",  # each mutation forks the most recent conversation
)

Lineage-aware sessions

optimize(
    ...,
    reflection_lm="openai/gpt-4.1",
    session_strategy="parent_linked",  # fork from the parent candidate's session
)

Coding agent as reflection LM

from gepa.agents import CodingAgent

agent = CodingAgent(harness="claude_code", model="sonnet")
optimize(
    ...,
    reflection_lm=agent,             # auto-wires sessions
    session_strategy="fork",
)

Concepts

Session protocol

class Session(Protocol):
    def send(self, content: str) -> str: ...   # call LLM, history grows
    def fork(self) -> Session: ...              # new session, history copied, diverges
    def reset(self) -> Session: ...             # new session, empty history, same config
    @property
    def session_id(self) -> str: ...
    @property
    def history(self) -> list[dict]: ...

fork() and reset() leave the original untouched — both return a new session.

Op Creates new? History Mutates original?
send(content) No Grows Yes
fork() Yes Copied No
reset() Yes Empty No

Implementations: LLMSession (in-memory), NullSession (no-op), ClaudeCodeSession / OpenCodeSession (CLI subprocess with native --resume / --fork-session).

SessionStrategy protocol (one hook)

class SessionStrategy(Protocol):
    def select(self, ctx: SessionContext) -> Session: ...
  • select(ctx) — before each mutation: pick the session to use. Called by the proposer right after it picks the parent candidate, so ctx.parent_candidate_idx is always available.

Bookkeeping (storing accepted candidates, tracking scores) is handled by SessionManager.observe() — strategies only decide which session to use.

SessionContext and SessionRecord

@dataclass(frozen=True)
class SessionRecord:
    session: Session
    val_score: float | None = None

@dataclass(frozen=True)
class SessionContext:
    parent_candidate_idx: int | None
    iteration: int
    sessions: Mapping[int | str, SessionRecord]   # read-only store snapshot
    create: Callable[[], Session]                  # factory for new sessions

SessionRecord is what gets stored per accepted candidate — the session and its score. SessionContext is a snapshot rebuilt every select() 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 SessionManager holds a dict[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 calls

# Proposer just picked parent candidate 3. Manager builds a snapshot and asks the strategy:
ctx = SessionContext(
    parent_candidate_idx=3,
    iteration=5,
    sessions={...},       # read-only snapshot of the store (all accepted candidates so far)
    create=factory,       # factory for brand-new sessions when store is empty
)
session = strategy.select(ctx)   # strategy returns a Session to use

# Example: ParentLinked looks up candidate 3's session and forks it
# Example: AlwaysFork ignores the parent and forks the most recent session

Phase 2: observe() — engine records the outcome after evaluation

# Engine evaluated the mutation. It was accepted as candidate 6 with score 0.85.
manager.observe(candidate_idx=6, accepted=True, val_score=0.85)

# Internally: accepted → stored as {6: SessionRecord(session, val_score=0.85)}
# Rejected → nothing stored

The store grows iteration by iteration:

After iter 0 (accepted):  store = {0: SessionRecord(s_root)}
After iter 1 (accepted):  store = {0: ..., 1: SessionRecord(s_1, 0.4)}
After iter 2 (rejected):  store = {0: ..., 1: ...}                      ← unchanged
After iter 3 (accepted):  store = {0: ..., 1: ..., 3: SessionRecord(s_3, 0.7)}
After iter 4 (rejected):  store = {0: ..., 1: ..., 3: ...}              ← unchanged
Iter 5 select():          strategy sees {0, 1, 3} and picks accordingly
After iter 5 (accepted):  store = {0: ..., 1: ..., 3: ..., 6: SessionRecord(s_6, 0.85)}

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 every select() 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 ParentLinked operate on the most recent session in the store. They differ only in the action (fork vs reset):

Strategy Picks Action When to use
AlwaysFork Most recent Fork Default — carry forward full history
AlwaysReset Most recent Reset Stateless (current GEPA behavior)
RandomStrategy(p) Most recent Fork with prob p, else reset Balanced exploration
RoundRobin Most recent Alternate fork/reset Predictable diversity schedule
ParentLinked Parent candidate's session Fork Lineage-aware — session tree ≅ candidate tree
BestScore Highest val_score session Fork Exploit the best lineage so far

Custom 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:

class PickBest(SessionStrategy):
    def select(self, ctx):
        entries = [e for e in ctx.sessions.values() if e.val_score is not None]
        if not entries:
            return ctx.create()
        return max(entries, key=lambda e: e.val_score).session.fork()

(This is already built-in as BestScore / session_strategy="best_score")

SessionManager (keyed store + strategy delegation)

manager = SessionManager(create=factory, strategy=AlwaysFork())

session = manager.select(parent_candidate_idx=3)   # strategy picks session
response = session.send("Improve the code...")
manager.observe(candidate_idx=7, accepted=True, val_score=0.87)  # stores accepted candidate

The manager holds dict[int | str, SessionRecord]. Accepted candidates are stored by index; rejected candidates are not stored.

CodingAgent

A thin factory that creates a Session for a given CLI backend. Each harness encapsulates its own quirks (JSON output format, CLI flags, cost tracking) behind the Session protocol.

agent = CodingAgent(
    harness="claude_code",        # or "opencode"
    model="sonnet",               # passed through to the CLI
    system_prompt="...",
    timeout=300,
    max_budget_usd=5.0,           # claude_code only
)

Passing a CodingAgent as reflection_lm triggers isinstance detection in api.py and 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:

  • Proposer calls select(parent_candidate_idx) — it knows the parent (it just picked it).
  • Engine calls observe(candidate_idx, accepted, val_score) — it knows the outcome (it just evaluated it).
  • Users see none of this — they just set session_strategy="fork".
                  ┌─────────────────────────────────────────────────┐
                  │                     ENGINE                      │
                  │          observe() after accept/reject          │
                  └──────────────────┬──────────────────────────────┘
                                     │
                  ┌──────────────────┼──────────────────────────────┐
                  │             SessionManager                      │
                  │   ┌──────────────────────────────────────┐      │
                  │   │         SessionStrategy               │     │
                  │   │  select(ctx) → Session                │     │
                  │   └──────────────────────────────────────┘      │
                  │   dict[int|str, SessionRecord]  (keyed store)   │
                  └──────────────────┬──────────────────────────────┘
                                     │
                  ┌──────────────────┼──────────────────────────────┐
                  │                PROPOSER                         │
                  │  1. Pick parent candidate                       │
                  │  2. select(parent_candidate_idx) ← right here  │
                  │  3. Build reflective dataset                    │
                  │  4. reflection_lm(prompt) → routes via session  │
                  │  5. Return proposal                             │
                  └─────────────────────────────────────────────────┘

The proposer sees reflection_lm as a plain lm(prompt) -> str callable. Under the hood, make_session_lm creates a closure that reads session_manager.current_session(), which select() 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. LM wrapping litellm), the callable receives the full list[dict] message list — no lossy extraction. For CodingAgent, the CLI backend (Claude Code/OpenCode) natively manages its own history via --resume / --fork-session flags.

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 inject session_manager and call select() themselves for full control.

Session tree ≅ Candidate tree (with ParentLinked)

Candidate tree          Session store (keyed by candidate idx)

      A                 0 → SessionRecord(s_A)           [root]
     / \                1 → SessionRecord(s_B, 0.6)      [forked from s_A]
    B   F               2 → SessionRecord(s_C, 0.8)      [forked from s_B]
   / \                  3 → SessionRecord(s_D, 0.7)      [forked from s_B — sibling of C]
  C   D                 4 → SessionRecord(s_F, 0.4)      [forked from s_A — sibling of B]

Each accepted candidate is bound to the session that produced it. Re-visiting the same parent forks independently, so siblings never interfere.

Tests

  • 62 unit + integration 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 strategies
  • 82 unit + integration tests (tests/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 check clean on all changed files

TODO (follow-ups)

  • Session persistence (codec layer). SessionCodec protocol to serialize/restore sessions to disk alongside gepa_state.bin for checkpoint/resume.

  • Performance benchmark. A/B on AIME math comparing session_strategy=None vs "fork" vs "parent_linked".

  • Richer state presentation. Human-readable JSON/YAML with all trajectories and scores for agent reflection.

  • note.md scratchpad. Persistent file where reflection LLMs can write down insights across iterations.

Files changed

File What
src/gepa/core/session.py Session protocol, LLMSession, NullSession — the conversation primitive
src/gepa/core/session_manager.py SessionStrategy protocol (select only), SessionContext/SessionRecord dataclasses, SessionManager (owns observe bookkeeping), resolve_session_strategy, make_session_lm — the orchestration layer
src/gepa/strategies/session_strategy.py AlwaysFork, AlwaysReset, RandomStrategy, RoundRobin, ParentLinked, BestScore — all inherit SessionStrategy
src/gepa/agents/__init__.py CodingAgent factory
src/gepa/agents/claude_code.py ClaudeCodeSession — wraps claude -p CLI
src/gepa/agents/opencode.py OpenCodeSession — wraps opencode run CLI
src/gepa/core/engine.py session_manager.observe() at accept/reject points
src/gepa/proposer/reflective_mutation/reflective_mutation.py Optional session_manager, select(parent_candidate_idx) after parent pick
src/gepa/api.py session_strategy param, CodingAgent auto-wire, session wiring to proposer + engine. LM resolution and session setup are colocated — CodingAgent uses native sessions directly, plain LMs are wrapped in LLMSession with full message history passed through.
src/gepa/optimize_anything.py Same wiring in the second entry point — reflection_lm passed directly as LLMSession.api_call (no lossy bridge)
tests/test_session.py 62 tests
tests/test_agents.py 82 tests

…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.
@semanticdiff-com

semanticdiff-com Bot commented Apr 3, 2026

Copy link
Copy Markdown

Review changes with  SemanticDiff

Changed Files
File Status
  src/gepa/optimize_anything.py  19% smaller
  src/gepa/api.py  1% smaller
  src/gepa/agents/__init__.py  0% smaller
  src/gepa/agents/claude_code.py  0% smaller
  src/gepa/agents/opencode.py  0% smaller
  src/gepa/core/engine.py  0% smaller
  src/gepa/core/session.py  0% smaller
  src/gepa/core/session_manager.py  0% smaller
  src/gepa/proposer/reflective_mutation/reflective_mutation.py  0% smaller
  src/gepa/strategies/session_strategy.py  0% smaller
  tests/test_agents.py  0% smaller
  tests/test_session.py  0% smaller

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
@lukedhlee lukedhlee changed the title feat: core abstractions for agent mode — Session, WorkspaceManager, ArtifactStore feat: session primitives for stateful LLM reflections Apr 4, 2026
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
@lukedhlee lukedhlee changed the title feat: session primitives for stateful LLM reflections feat: core abstractions for agent support — Session, WorkspaceManager, ArtifactStore Apr 4, 2026
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
@lukedhlee lukedhlee self-assigned this Apr 4, 2026
@lukedhlee lukedhlee changed the title feat: core abstractions for agent support — Session, WorkspaceManager, ArtifactStore feat: session management and agent support for stateful LLM reflections Apr 4, 2026
@lukedhlee lukedhlee changed the title feat: session management and agent support for stateful LLM reflections feat: session management and agent support Apr 4, 2026
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)
@lukedhlee lukedhlee changed the title feat: session management and agent support feat: session management and agent support for stateful LLM reflections Apr 4, 2026
- 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
@lukedhlee lukedhlee changed the title feat: session management and agent support for stateful LLM reflections feat: stateful sessions and coding agent support for LLM reflections Apr 5, 2026
Comment thread src/gepa/agents/claude_code.py Outdated
"-p",
"--model", self._model,
"--output-format", "json",
"--dangerously-skip-permissions",

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.

Let's not make this default. Is there a safe option by default, that never uses any file writes, and only performs reads?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reads do not need permission in CC. Removing this flag should suffice

@Shangyint Shangyint left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Comment thread src/gepa/agents/claude_code.py Outdated
"-p",
"--model", self._model,
"--output-format", "json",
"--dangerously-skip-permissions",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reads do not need permission in CC. Removing this flag should suffice

Comment thread src/gepa/core/session.py Outdated
Comment thread src/gepa/core/engine.py Outdated
lukedhlee added 11 commits April 5, 2026 17:46
…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 Shangyint left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Check if strategy is an instance of SessionStrategy?

# ---------------------------------------------------------------------------


def make_session_lm(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Ideally this should look like make_session_lm(session_manager.new_session())

lukedhlee added a commit that referenced this pull request Apr 11, 2026
…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.
Shangyint pushed a commit that referenced this pull request Apr 11, 2026
* 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants