Skip to content

feat(optimization): enforce run-context usage, budgets, and cancellation in the prompt optimizers (RFC #6357, PR 2/2)#6359

Draft
caohy1988 wants to merge 9 commits into
google:mainfrom
caohy1988:feat/optimization-run-context-enforcement
Draft

feat(optimization): enforce run-context usage, budgets, and cancellation in the prompt optimizers (RFC #6357, PR 2/2)#6359
caohy1988 wants to merge 9 commits into
google:mainfrom
caohy1988:feat/optimization-run-context-enforcement

Conversation

@caohy1988

@caohy1988 caohy1988 commented Jul 10, 2026

Copy link
Copy Markdown

RFC: #6357 — stacked on #6358 (contracts). Review #6358 first; this PR contains its commits plus one enforcement commit.

Updated to match the RFC's precision pass — including the load-bearing fix it demanded: GEPA's engine catches iteration exceptions and continues (verified at gepa/core/engine.py:588), so a typed budget error raised from the reflection callable would be swallowed and sampler calls would keep scheduling. This PR implements the sentinel/stopper/post-check bridge instead.

What this adds

  • SimplePromptOptimizer: capability opt-in; each candidate-generation call is one recorded logical invocation (streamed usage + model version; provider exceptions preserve usage-so-far); in-band LlmResponse.error_code terminates a governed run with OptimizationProviderError (usage preserved) instead of silently succeeding; cancellation observed before every iteration; return_partial keeps the best-so-far agent, schedules nothing further (final validation included, so overall_score=None), and leaves run status to the authoritative snapshot — the result schema is unchanged.
  • GEPARootAgentPromptOptimizer — the sentinel bridge:
    1. reflection commits usage (call status completed; run status budget_exceeded on overshoot);
    2. the reflection callable raises a private _GovernedRunAbort sentinel — GEPA converts the interrupted reflection to "no proposal" and keeps looping;
    3. the stop_callbacks stopper observes any terminal run status (not just cancellation) at the next loop boundary;
    4. a post-check after gepa.optimize returns maps the committed state to raise or return_partial — never a false success — with GEPA's best-so-far candidates available in the partial path.
      Cancellation is additionally observed before each sampler evaluation is scheduled onto the main loop; native task cancellation requests the cooperative stop, drains the executor worker once the active operation reaches a boundary, then re-raises.
  • GEPARootAgentOptimizer (root/skill surface): rejects a supplied context with UnsupportedOptimizationContextError before any sampler or model work; conservative capabilities. Instrumenting this surface is deliberate fast-follow per the RFC.
  • No-context paths are observably equivalent for all three optimizers.

Verification

106/106 passing across tests/unittests/optimization/ (all pre-existing optimizer tests + the contracts suite + enforcement tests). New in this PR, including the RFC-mandated regression: no sampler call is scheduled after the overshoot commits, proven against a gepa stub with faithful engine semantics (iteration exceptions swallowed, stoppers checked at loop boundaries); raise-mode post-check mapping; in-band provider-error termination; snapshot-authoritative return_partial with overall_score=None; capability opt-ins; typed rejection before work on the root/skill optimizer.

Downstream consumer: GoogleCloudPlatform/BigQuery-Agent-Analytics-SDK#318.

Round 3: reflection requests build before slot admission (construction failures are GEPA-native no-proposals with a clean ledger); post-admission operations live in one settlement boundary with local scheduling failures settling as abortedOptimizationFailedError (never provider-typed, never success); pre-cancellation wins over sampler discovery in both optimizers (asserted on every sampler method); the governed gepa.optimize branch pins raise_on_exception=True with the legacy call shape preserved exactly when no context is supplied; typed _ReflectionCapture/adapter internals; real-GEPA durable coverage extended to in-band provider errors, post-sampler cancellation, and reflection-request failure.

…ntracts (OptimizationRunContext, OptimizerCapabilities, terminal_status)

RFC: google#6357. PR 1 of 2 (contracts only; no optimizer behavior
change). Adds the one-shot OptimizationRunContext ledger with truthful usage
coverage (verified/partial/unreported, never zero-coerced), call/token budgets
with commit-then-terminate overshoot semantics and configurable
on_budget_exceeded (raise | return_partial), cooperative cancellation,
conservative OptimizerCapabilities defaults on AgentOptimizer, an optional
keyword-only run_context parameter on optimize(), and an experimental
terminal_status field on OptimizerResult. Enforcement in the two prompt
optimizers lands in the stacked PR 2.
@caohy1988

Copy link
Copy Markdown
Author

Code Review Results

Scope: google/adk-python PR #6359. Enforcement delta e726c58..d30a0ad (4 files, +420/-15), plus integration against the full stack on main.
Intent: Enforce RFC #6357 usage, budgets, provider failures, and cancellation in SimplePromptOptimizer and GEPARootAgentPromptOptimizer, while preserving the no-context path; reject contexts early on GEPARootAgentOptimizer.
Mode: report-only external PR review
Review coverage: correctness, testing, maintainability, project standards, API contract, reliability, concurrency, provider-failure handling, GEPA 0.1.x behavior, and adversarial lifecycle probes. No subagents or peer-model passes were used, per the user's standing instruction.

Scope Check

REQUIREMENTS MISSING. The Simple happy-path accounting is present and the root/skill optimizer rejects early, but the GEPA sentinel/stopper/post-check contract, governed provider-error terminal path, cancellation boundaries, partial-score semantics, and no-context cancellation parity are incomplete.

Triage Groups

Group Findings Context Preferred resolution Why
Make terminal control survive GEPA #1, #2 GEPA 0.1.x catches reflection exceptions, so public budget/provider exceptions cannot be the cross-thread control mechanism Implement the RFC's private iteration-abort sentinel, terminal-aware stopper, and post-check before shaping partial results One adapter protocol fixes budget, provider-error, and frontier correctness together
Close cancellation without changing legacy behavior #3, #4, #6 Governed cancellation needs more boundaries, but unconditional shielding changes old callers Separate governed and ungoverned cancellation paths; add before/after boundary checks only to governed runs This preserves compatibility while making governed runs drainable and auditable

P1 -- High

# File Issue Confidence
1 src/google/adk/optimization/gepa_root_agent_prompt_optimizer.py:330 GEPA swallows budget exceptions, continues later model calls, and returns normal success 10/10
2 src/google/adk/optimization/simple_prompt_optimizer.py:138 (+ GEPA :322) Governed provider failures can be recorded as completed or return normal success; GEPA also loses usage-so-far 10/10
3 src/google/adk/optimization/simple_prompt_optimizer.py:186 Simple cancellation can schedule sampler work, return normal success, or leave an open ledger event 10/10
4 src/google/adk/optimization/gepa_root_agent_prompt_optimizer.py:370 Unconditional shield/drain changes no-context task cancellation into an unbounded wait 10/10
5 src/google/adk/optimization/simple_prompt_optimizer.py:300 return_partial publishes a training-batch score as overall_score without validation 10/10
  • How to use api_transport "REST"? in Agent #1: end_model_call() raises OptimizationBudgetExceeded, but GEPA 0.1.0 and 0.1.1 catch reflection/proposal exceptions and convert them to no proposal. The stopper at line 352 checks only cancel_requested, not budget terminal state. Real GEPA probes showed that max_model_calls=0 returned an unmarked normal result and max_total_tokens=1 allowed more than one 100-token reflection call before returning normal success. The except OptimizationBudgetExceeded block is therefore not the control path under GEPA 0.1.x; if reached in a future version, it also returns the seed rather than the previously committed frontier. Implement the RFC's exact sequence: commit -> set terminal request -> raise a private iteration-abort sentinel -> let GEPA discard the proposal -> stop at the next loop head -> post-check and map to typed raise or the existing gepa_results frontier. Add a regression asserting no sampler or reflection call is scheduled after the triggering overshoot.
  • Google cloud requirement #2: Neither optimizer checks LlmResponse.error_code. Simple treats an in-band RESOURCE_EXHAUSTED response as a completed call and returns success. GEPA's raised-error path stores last_usage inside _generate, then calls end_model_call(handle, error_message=...) without that usage when future.result() raises; GEPA catches the exception and the outer optimizer returns normal success. Real GEPA reproduced this with every reflection failing: the result had terminal_status=None, provider-error events existed, and the run had no terminal failure. Detect in-band errors, keep usage in state visible to the exception handler, commit a sanitized code/type, set failed terminal state, cross GEPA through the private sentinel, and post-check into OptimizationProviderError.
  • we need typescript version #3: A pre-cancelled context still performs Simple's baseline sampler call because the first cancellation check is inside the iteration loop. If cancellation is requested during candidate generation, the code performs candidate scoring and final validation, then returns normal success with cancel_requested=True and no terminal control state; the probe observed three sampler calls. Native Task.cancel() bypasses except Exception and leaves one started event with no terminal state. Check immediately after attach, after each model call, before/after governed sampler work, and before final validation; add explicit native-cancellation finalization so every admitted event terminates.
  • fix: Update README evaluate link #4: The executor future is always shielded. With no run_context, the cancellation handler has no stopper to request and waits for the entire GEPA worker. A red/green probe shows PR feat(optimization): run-scoped usage, budget, and cancellation contracts (RFC #6357, PR 1/2) #6358's task is cancelled immediately, while feat(optimization): enforce run-context usage, budgets, and cancellation in the prompt optimizers (RFC #6357, PR 2/2) #6359 remains pending until the worker is manually released. Keep the old direct-await behavior when no context is supplied; use shield/request/drain only for a governed context, where the cooperative signal exists.
  • Evaluation link broken #5: best_score is the random training-batch score, while the existing result contract uses final validation for overall_score. On budget termination no validation completed, yet the partial result exposes the training score. Return overall_score=None unless a validation score was already completed, as RFC RFC: run-scoped usage, budget, and cancellation controls for AgentOptimizer (OptimizationRunContext + OptimizerCapabilities) #6357 specifies; do not run post-stop validation solely to fill it.

P2 -- Moderate

# File Issue Confidence
6 src/google/adk/optimization/gepa_root_agent_prompt_optimizer.py:156 The GEPA adapter does not observe cancellation immediately after an in-flight sampler future settles 10/10
7 src/google/adk/optimization/gepa_root_agent_prompt_optimizer.py:348 (+ root :337) The enforcement delta adds 15 strict mypy errors and does not pass Ruff/Pyink 10/10
  • Elevated Permission Error on Windows #6: If cancellation arrives during sample_and_score, future.result() returns and the adapter hands the result to GEPA without the RFC-required post-boundary check. The deterministic adapter probe returned a normal EvaluationBatch with cancellation already requested. Call raise_if_cancelled() immediately after the future settles, before processing or returning its result.
  • docs: update ToolboxTool docstring #7: Strict mypy reports 42 errors on the three optimizers versus 27 on stacked base feat(optimization): run-scoped usage, budget, and cancellation contracts (RFC #6357, PR 1/2) #6358. The new errors include the untyped root run_context, untyped adapter context, and the dynamically inferred kwargs spread producing a large incompatible-argument set. Type the public/internal parameters and pass a typed stop_callbacks value directly. Ruff also fails on unused OptimizationCancelledError; Pyink would reformat the Simple implementation and new tests.

Inherited blockers from PR #6358

PR #6359 is currently based on main, so the full GitHub diff also contains every unresolved PR #6358 contract finding: result serialization drift, incomplete final snapshot state, incomplete capability/preflight shape, non-atomic handle completion, provider-error contract gaps, mutable evidence/configuration, the attachment sentinel bug, and missing public exports. Those are documented at #6358 (comment) and remain blockers for the stack; they are not renumbered here as #6359 enforcement-delta findings.

Verification

Testing gaps

  • The new enforcement test file has no GEPARootAgentPromptOptimizer governed-run test.
  • No GEPA 0.1.0/latest matrix.
  • No regression proving zero sampler/model work after a reflection budget stop.
  • No raised or in-band provider-error terminal test for either optimizer.
  • No pre-cancel no-work assertion, mid-call cancellation test, or native cancellation ledger test for Simple.
  • No no-context cancellation-parity test for GEPA.
  • No partial-result assertion that Simple score is None and GEPA uses the prior committed frontier.
  • No after-sampler cancellation-boundary test.

Verdict: Not ready.

Reasoning: The Simple happy path and early root/skill rejection work, and all 53 existing tests pass. The load-bearing GEPA budget path does not terminate, token overshoot can continue spending on later reflections, governed provider failures can return success, and no-context cancellation regresses. These are enforcement failures, not API naming preferences.

Fix order: Fix PR #6358's contract -> implement GEPA sentinel/terminal post-check (#1, #2) -> close Simple/provider cancellation paths (#2, #3, #5, #6) -> restore no-context parity (#4) -> add the missing GEPA/provider/cancellation matrix -> clear static checks (#7).

Actionable Findings

  1. [P1] gepa_root_agent_prompt_optimizer.py:330 - implement the sentinel/terminal-aware stopper/post-check bridge and return the committed frontier.
  2. [P1] simple_prompt_optimizer.py:138 (+ GEPA :322) - make raised and in-band provider errors typed terminal failures with preserved usage.
  3. [P1] simple_prompt_optimizer.py:186 - enforce pre/post cancellation boundaries and finalize native cancellation events.
  4. [P1] gepa_root_agent_prompt_optimizer.py:370 - shield/drain only governed runs; preserve no-context cancellation behavior.
  5. [P1] simple_prompt_optimizer.py:300 - return overall_score=None when partial termination precedes validation.
  6. [P2] gepa_root_agent_prompt_optimizer.py:156 - post-check cancellation after the sampler future settles.
  7. [P2] gepa_root_agent_prompt_optimizer.py:348 (+ root :337) - resolve new typing/lint/format failures.

…e token compliance, granular capabilities, OptimizerResult unchanged

Per the precision pass on RFC google#6357: logical-call limits are the only hard
enforcement (atomic slot admission); reported-token ceilings terminate
reactively over authoritative totals with within_limit/exceeded/indeterminate
compliance (missing totals are never proof of compliance); call status is
split from run status; capabilities gain accepts_run_context and split
logical-call vs reported-token enforceability; terminal_status is removed
from OptimizerResult -- the caller-owned snapshot is authoritative; adds
OptimizationProviderError for governed in-band error termination.
@caohy1988

Copy link
Copy Markdown
Author

Fresh review of the enforcement PR

Reviewed the complete stack at c1f214ef22cf836c41da3f0c0fac42132d5730ae against the current #6357 body and actual GEPA exception behavior.

Verdict: keep this PR in draft / request changes. The budget sentinel fix is real and important, but provider-error termination, cancellation boundaries, and no-context compatibility are still incomplete. The current 57 green tests do not cover these paths.

What is now right

  • The private GEPA sentinel, stop callback, and post-check correctly prevent further reflection scheduling after a committed budget overshoot.
  • The GEPA partial path returns real best-so-far candidates rather than the seed.
  • Simple optimizer partial results use overall_score=None and skip final validation.
  • GEPARootAgentOptimizer rejects a supplied context before work begins and advertises unsupported capabilities honestly.

[P1] GEPA provider errors can still become successful optimization results

gepa_root_agent_prompt_optimizer.py:292-360

The reflection generator never checks LlmResponse.error_code. On a raised provider exception it commits error_message=str(e), but the context does not set run_status=FAILED; the original exception is then re-raised. GEPA catches per-iteration reflection failures and continues, while the stop callback sees no terminal context. The run can therefore return normal success after either an in-band or raised provider error.

The raised-exception path also loses usage accumulated inside _generate(). If usage and a provider error cross the token threshold together, OptimizationBudgetExceeded masks the provider failure.

Required bridge:

  1. Capture usage/model metadata in a holder visible to the exception path.
  2. Detect in-band error_code immediately.
  3. Atomically commit sanitized provider failure with run_status=FAILED and provider-error-first precedence.
  4. Convert the resulting typed failure to _GovernedRunAbort so GEPA cannot swallow it into a later success.
  5. Map the final state back to OptimizationProviderError in the post-check.

[P1] SimplePromptOptimizer does not honor its advertised cancellation contract

simple_prompt_optimizer.py:189-248 and simple_prompt_optimizer.py:280-325

  • Pre-cancelled runs still perform the baseline sampler evaluation before the first check.
  • There is no check immediately after candidate generation.
  • There are no checks immediately before and after candidate scoring.
  • Final validation has no cancellation boundary.
  • Cancellation during the final model call can still schedule candidate scoring and final validation.
  • Native asyncio.CancelledError is not caught by except Exception, leaving the call event open and the run without CANCELLED status.

Add the RFC-pinned boundaries before and after every sampler/model operation, finalize native cancellation, and re-raise it. Tests must assert model and sampler call counts, not only started_calls.

[P1] GEPA misses the post-sampler boundary and changes ungoverned cancellation behavior

gepa_root_agent_prompt_optimizer.py:154-170 and gepa_root_agent_prompt_optimizer.py:399-435

  • The adapter checks cancellation before sampler scheduling but not immediately after future.result().
  • asyncio.shield(executor_future) is unconditional. Even with run_context=None, task cancellation now waits for the GEPA executor to drain, potentially indefinitely, instead of preserving the existing immediate-cancellation behavior.
  • For governed native cancellation, request_cancel() is set but the function re-raises without transitioning the final snapshot to CANCELLED.

Keep the legacy direct-await path when no context is supplied. Use the shield/request/drain behavior only for governed runs, add the post-sampler check, and commit the cancellation terminal state before re-raising. The caller watchdog remains the bounded hard backstop.

[P1] Successful and failed optimizer exits do not finalize the caller-owned snapshot

Neither optimizer marks a normal run COMPLETED. Simple provider failures and native cancellation do not reliably mark FAILED/CANCELLED; GEPA has the same problem. Consequently the object #6357 calls authoritative is indistinguishable from an in-progress run after many terminal exits.

Once #6358 exposes lock-protected terminal transitions, wrap both implementations so every return or exception path produces exactly one final status. Do not infer partial success from “any non-None status,” as Simple currently does; branch specifically on BUDGET_EXCEEDED plus return_partial.

[P2] The test and merge gates give false confidence

Verification on the reviewed head:

  • Official optimization suite: 57 passed.
  • Fresh enforcement probes covering normal completion, pre/mid/native cancellation, provider-error precedence, in-band and raised GEPA failures, the post-sampler boundary, and no-context cancellation parity: 11 failed out of 11.
  • Full contract-plus-enforcement adversarial set: 16 failed out of 16.
  • Ruff: unused OptimizationCancelledError import.
  • Pyink: three files require formatting.
  • Isort: two files require sorting.
  • Mypy comparison: this PR adds 15 errors over the feat(optimization): run-scoped usage, budget, and cancellation contracts (RFC #6357, PR 1/2) #6358 head in the reviewed optimization surface.

The critical GEPA tests currently use a fake optimizer with only the budget exception behavior. Please add real tests against both the minimum supported GEPA and latest GEPA, as #6357 requires, plus provider-error and cancellation tests using faithful exception-swallowing loop behavior. The substantive CI matrix is not running on these fork PRs, so local green tests are not a substitute for those gates.

The budget-abort path is now a solid foundation. The next pass needs to apply the same rigor to provider failures, every cancellation boundary, and observational compatibility when the context is absent.

…en ledger, provider-failure precedence, independent type-cleanliness

- Explicit first-terminal-wins state machine (completed/budget_exceeded/
  cancelled/failed) with finalize_success/finalize_cancelled/finalize_failed;
  begin_model_call rejects any terminal context; a late cancellation cannot
  overwrite an earlier terminal state.
- Provider failure is a governed terminal: end_model_call with sanitized
  error_code/error_type commits usage, transitions the run to failed, and
  raises OptimizationProviderError; provider failure takes precedence over a
  simultaneous token overshoot while preserving usage and token-compliance
  evidence. Raw exception text never enters the ledger.
- Ledger integrity: handles bound to their context (foreign commit is a typed
  error), atomic close under the context lock (concurrent double-close commits
  once), private unattached sentinel (attach(None) is one-shot), frozen
  OptimizationBudgets (ge=0 validated), frozen snapshots/events (tuples),
  snapshot carries budgets + terminal sequence + terminal error metadata;
  finalize_cancelled closes open call events.
- Stage is an extensible string with documented constants.
- Built-in optimizers accept the keyword and reject a supplied context with
  UnsupportedOptimizationContextError in this PR, so the abstract-signature
  change is independently type-clean; real support lands in the stacked
  enforcement PR.
- isort + pyink applied.
@caohy1988 caohy1988 force-pushed the feat/optimization-run-context-enforcement branch from c1f214e to 0542043 Compare July 10, 2026 11:33
@caohy1988

Copy link
Copy Markdown
Author

Review disposition — all four P1s and the P2 gates addressed (head 0542043, rebased on the hardened contracts)

[P1] GEPA provider errors → success → closed at every layer: the reflection generator detects in-band error_code immediately; usage/model metadata accumulate in a holder visible to the exception path (raised errors keep usage-so-far); the provider failure commits atomically with run_status=FAILED and provider-error-first precedence (contracts PR); every typed run-context error is converted to the private sentinel so GEPA cannot swallow a governed terminal into a later success; the post-check maps FAILEDOptimizationProviderError. New tests prove both in-band and raised provider errors cannot become success through the swallowing loop. One additional case the fresh pass surfaced during implementation: the seed evaluation runs outside GEPA's swallowing loop, so an escaped sentinel is now mapped to its typed outcome rather than leaking raw — covered by the real-GEPA pre-cancel test.

[P1] Simple cancellation contract → all pinned boundaries implemented: before the baseline sampler evaluation (a pre-cancelled run performs zero model and zero sampler calls — asserted on call counts), after candidate generation (cancel during model work prevents the next sampler call — tested), after candidate scoring, and before final validation. Native asyncio.CancelledError closes the open call event, finalizes CANCELLED, and re-raises — tested.

[P1] GEPA boundaries + absent-context parity → the adapter now checks cancellation immediately after future.result() as well as before scheduling; the shield is governed-only — with run_context=None the legacy direct await loop.run_in_executor(...) is preserved exactly, so ungoverned task-cancellation propagation is unchanged; governed native cancellation requests the cooperative stop, drains the worker, finalizes the snapshot to CANCELLED, then re-raises.

[P1] Snapshot finalization on every exit → both optimizers finalize COMPLETED on normal return (tested for both), FAILED on non-provider crashes, CANCELLED on native cancellation; the Simple partial branch now keys specifically on BUDGET_EXCEEDED, not any-non-None.

[P2 gates]ruff --select F401,F811,F841 clean; isort + pyink applied to all changed files. The critical GEPA tests now run at two levels: the faithful exception-swallowing stub (loop-boundary stoppers, per-iteration swallow) for the no-sampler-after-overshoot regression, plus real-GEPA integration tests (budget overshoot in raise mode; pre-cancelled stop) against the installed engine. The min-plus-latest gepa version matrix remains a CI-configuration concern per the RFC — these fork PRs don't run the substantive CI, so that gate lands with maintainer review.

Verification: full tests/unittests/optimization/: 81/81 on this head (60 contracts-side + 21 enforcement/integration + pre-existing optimizer tests).

@caohy1988

Copy link
Copy Markdown
Author

Fresh adversarial review of the hardened enforcement head

Reviewed at 0542043f3b88c16aefddbac6db9b0701d01982fc, including faithful-stub behavior, real GEPA 0.1.0 and 0.1.1, native/context cancellation, setup failures, malformed provider codes, and no-context equivalence.

Verdict: keep draft / request changes. The sentinel/stopper/post-check bridge is now sound on the tested budget and provider paths. Three P1 enforcement gaps remain, including a concrete regression for callers that do not use the new context.

Verified improvements

  • Budget overshoot cannot schedule the next sampler evaluation through GEPA's exception-swallowing loop.
  • Raised and in-band provider errors terminate as typed governed failures through both the faithful stub and real GEPA.
  • Provider usage survives the ordinary GEPA raised-error path.
  • Post-sampler context cancellation maps to OptimizationCancelledError.
  • Governed native GEPA cancellation requests stop, drains the worker after the active operation settles, finalizes CANCELLED, and re-raises native cancellation.
  • No-context native task cancellation preserves the legacy direct-await propagation behavior.
  • Normal Simple and GEPA runs finalize COMPLETED; partial results retain the correct score/frontier semantics.
  • The full optimization suite is 81/81 with both gepa==0.1.0 and the current gepa==0.1.1. I also ran a real-GEPA provider-error probe successfully against both versions.
  • Ruff, Pyink, Isort, and diff whitespace checks are clean on this stacked head. It still inherits the five Mypy errors from feat(optimization): run-scoped usage, budget, and cancellation contracts (RFC #6357, PR 1/2) #6358.

[P1] No-context GEPA behavior is not observationally equivalent

_generate() reacts to LlmResponse.error_code unconditionally and returns the accumulated text immediately. The governed post-processing is conditional, but the stream-control change is not.

Before this PR, a no-context reflection continued consuming the stream because it did not inspect error_code. I reproduced a stream with an error-coded response followed by a normal final response: the legacy path returns "legacy final"; this head returns an empty prompt.

Fix: branch on in-band errors only when run_context is not None. Keep the original no-context _generate() control flow unchanged. Add a regression test with multiple streamed responses so the parity guarantee covers content/results, not only executor cancellation.

[P1] GEPA failures before the executor boundary do not finalize the attached context

The context is consumed at optimize():274-275, but failure finalization begins only around the governed executor await at lines 440-486.

Missing optional dependency, adapter construction, model construction, and train/validation ID retrieval all escape without a terminal snapshot. I reproduced sampler.get_train_example_ids() raising after attachment: the original error propagates, but ctx.snapshot().run_status remains None forever.

Fix: finish fallible setup before attachment where possible, then wrap every post-attachment path in one outer finalization boundary. The final snapshot guarantee must apply to setup failure as well as executor failure.

[P1] Simple native cancellation loses usage already reported

_generate_candidate_prompt() retains last_usage, but its native-cancellation branch calls only finalize_cancelled(). I reproduced a streamed response reporting seven tokens before cancellation; the final event became cancelled with total_tokens=None, usage_coverage=unreported, and completed_calls=0.

This breaks the stated goal of reconciling provider-reported usage whenever it was available.

Fix: add/use a cancellation-aware call commit that records usage/model metadata and closes the specific handle as CANCELLED before finalizing the run.

[P1] Raised provider codes are not normalized before the contract boundary

Both Simple and GEPA pass arbitrary exception.error_code directly. A numeric provider code such as 429 produces a Pydantic ValidationError during snapshot construction instead of the promised OptimizationProviderError, and corrupts subsequent snapshot reads.

The context should enforce normalization, but adapters should also convert provider-specific values into the normalized experimental contract explicitly.

[P2] Cancellation during final validation can return normal success

Simple checks before final validation but not after it. If cancellation is requested while validation runs, finalize_success() is called immediately afterward, producing run_status=COMPLETED plus cancel_requested=True.

If late cancellation is intentionally allowed to lose to completed validation, pin that outcome in the RFC and a test. Otherwise add a post-validation boundary and make success finalization atomically cancellation-aware. I recommend the latter because it gives one unambiguous final outcome.

Test and description drift

The implementation behaves correctly in several places that have no first-class regression test. The repository currently has no GEPA tests for:

  • governed native task cancellation;
  • no-context native cancellation parity;
  • cancellation requested during a sampler call and observed immediately afterward;
  • real-GEPA provider error termination.

My independent versions of all four pass, but the no-context stream-content parity probe above fails. These should join the suite because they are load-bearing claims in the RFC/disposition.

The PR body also still says 57/57 and describes the earlier boundary set. Update it to the current 81-test stack and the real current behavior.

GEPA matrix status

The code currently passes the complete optimization suite on both ends of the admitted range that exist today: GEPA 0.1.0 and 0.1.1. The remaining RFC gate is automation: CI still does not install and test both explicitly. That is an honest maintainer/configuration dependency, not a current compatibility failure.

The provider sentinel work is now strong. The next pass should preserve legacy behavior when the seam is absent and make the final snapshot truthful for setup failure and mid-stream native cancellation.

@adk-bot adk-bot added the core [Component] This issue is related to the core interface and implementation label Jul 11, 2026
@caohy1988

Copy link
Copy Markdown
Author

Fresh review round — enforcement PR (0542043)

Verdict: keep draft / changes required. I reran the stack against current ADK main and both GEPA 0.1.0 and 0.1.1. The sentinel/stopper/post-check bridge works and fixes the original swallowed-exception defect, but no-context compatibility and lifecycle terminalization are still incomplete.

P1 — no-context GEPA behavior regresses

The response loop returns immediately on any in-band error_code, regardless of whether governance is active:

async def _generate():
response_text = ""
async with Aclosing(llm.generate_content_async(llm_request)) as agen:
async for llm_response in agen:
llm_response: LlmResponse
if getattr(llm_response, "usage_metadata", None) is not None:
holder["usage"] = llm_response.usage_metadata
if getattr(llm_response, "model_version", None):
holder["model_version"] = llm_response.model_version
error_code = getattr(llm_response, "error_code", None)
if error_code:
holder["error_code"] = str(error_code)
return response_text

I reproduced this with a legacy stream containing an error-bearing chunk followed by a valid "legacy final" chunk. The pre-PR/no-context path consumes the valid final response; this implementation returns early and produces an empty/different reflected prompt. That directly contradicts the PR body’s “No-context paths are observably equivalent” claim.

Required fix: only terminate an in-band provider error through the governed path. When run_context is None, preserve the previous stream iteration semantics exactly.

P1 — attached GEPA contexts can escape nonterminal

The context is attached at the beginning of optimize(), but the terminalizing try/except starts only around the executor future:

if run_context is not None:
run_context.attach(owner=self)
if initial_agent.sub_agents:
_logger.warning(
"The GEPARootAgentPromptOptimizer will not optimize prompts for"
" sub-agents."
)
_logger.info("Setting up the GEPA optimizer...")
try:
import gepa # lazy import as gepa is not in core ADK package
_AgentGEPAAdapter = _create_agent_gepa_adapter_class()
except ImportError as e:
raise ImportError(MISSING_EVAL_DEPENDENCIES_MESSAGE) from e
loop = asyncio.get_running_loop()
adapter = _AgentGEPAAdapter(
initial_agent=initial_agent,
sampler=sampler,
main_loop=loop,
run_context=run_context,
)
llm = self._llm_class(model=self._config.optimizer_model)

sampler.get_train_example_ids() and get_validation_example_ids() execute before that boundary:

train_ids = sampler.get_train_example_ids()
val_ids = sampler.get_validation_example_ids()

Reproduction: a sampler setup exception propagates while the caller-owned snapshot remains run_status=None after attachment.

Required fix: after attach() succeeds, wrap the entire remaining lifecycle in a terminalization boundary. Every exit must commit COMPLETED, FAILED, CANCELLED, or BUDGET_EXCEEDED.

P1 — Simple optimizer cancellation loses evidence or becomes success

The streaming path accumulates usage in last_usage, but native cancellation invokes finalize_cancelled() without settling the open call with that evidence:

response_text = ""
last_usage = None
model_version = None
try:
async for llm_response in self._llm.generate_content_async(llm_request):
if getattr(llm_response, "usage_metadata", None) is not None:
last_usage = llm_response.usage_metadata
if getattr(llm_response, "model_version", None):
model_version = llm_response.model_version
error_code = getattr(llm_response, "error_code", None)
if error_code and run_context is not None and handle is not None:
# Governed runs preserve usage-so-far and terminate on an in-band
# provider error instead of silently succeeding. end_model_call
# transitions the run to FAILED and raises the typed error.
run_context.end_model_call(
handle,
usage_metadata=last_usage,
error_code=str(error_code),
error_type=type(llm_response).__name__,
)
if not (llm_response.content and llm_response.content.parts):
continue
for part in llm_response.content.parts:
if part.text and not part.thought:
response_text += part.text
except asyncio.CancelledError:
# Native task cancellation: close the open call event, finalize the
# governed run as cancelled, and re-raise promptly.
if run_context is not None:
run_context.finalize_cancelled("task_cancelled")
raise
except OptimizationProviderError:
raise
except Exception as e:
if run_context is not None and handle is not None:
run_context.end_model_call(
handle,
usage_metadata=last_usage,
error_code=getattr(e, "error_code", None) or "PROVIDER_EXCEPTION",
error_type=type(e).__name__,
)
raise
if run_context is not None and handle is not None:
run_context.end_model_call(
handle,
usage_metadata=last_usage,
returned_model_version=model_version,
)

Reproduction: after a response reports seven tokens, cancelling the task produces a cancelled event with no token usage and completed_calls == 0.

There is also no cancellation boundary after final validation. If cancellation is requested during _run_final_validation(), control resumes and immediately calls finalize_success():

if run_context is not None:
# Boundary: before final validation.
run_context.raise_if_cancelled()
final_score = await self._run_final_validation(best_agent, sampler)
logger.info("Final validation score: %f", final_score)
if run_context is not None:
run_context.finalize_success()
return OptimizerResult(

Required fix: settle the active call with usage/model metadata before native-cancel finalization, and check cancellation after final validation and before committing success. PR #6358 should also make finalize_success() defensive.

P1 — provider metadata is not normalized at the adapter boundary

Simple’s raised-provider path forwards getattr(e, "error_code", None) without string normalization, unlike its in-band path. A numeric provider code therefore reaches PR #6358’s Optional[str] snapshot field and causes Pydantic validation failure rather than the promised typed provider outcome.

Normalize in the adapter and enforce the invariant again in OptimizationRunContext; the context must remain the final trust boundary.

Verification

  • Full optimization suite with GEPA 0.1.0: 81 passed.
  • Full optimization suite with GEPA 0.1.1: 81 passed.
  • Independent boundary/provider probes: 4 passed / 11 failed overall.
  • Independently passing GEPA paths: post-sampler cancellation maps to the typed outcome; governed native cancellation drains/finalizes; no-context native cancellation preserves immediate propagation; real-GEPA in-band provider failure becomes typed.
  • git diff --check: pass.
  • Mypy on the affected optimization surface: current main has 24 existing errors; this stacked PR has 50, so 26 new errors are introduced.
  • Ruff introduces no new failure relative to current main.
  • The PR body still reports 57 tests; the current stack runs 81.

The sentinel bridge is the strongest part of this implementation: it correctly survives GEPA swallowing iteration exceptions, stops at the next loop boundary, and prevents false success after a governed provider/budget terminal. The remaining blockers are around that bridge—preserving legacy behavior when the context is absent, ensuring the entire attached lifecycle terminates authoritatively, and retaining evidence on cancellation.

…pes (round-2 adversarial findings)

- Validate-before-commit: usage counters must be finite non-negative numbers
  (NaN/inf/negative -> not reported, never a crash on a half-closed record);
  error metadata is normalized to bounded single-token identifiers at the
  context boundary (numeric provider codes become strings; multiline/path
  text is scrubbed); the event, counters, and terminal transition then
  commit atomically under one lock acquisition.
- Every caller observes the committed terminal: an already-admitted call
  settles for truthful accounting and then receives the existing terminal
  error; finalize_success observes pending cancellation and commits/raises
  CANCELLED instead of recording a false success.
- OptimizationFailedError added for non-provider failures;
  OptimizationProviderError is reserved for governed provider-call
  terminals (terminal provenance tracked).
- end_model_call(cancelled=True) settles a call as cancelled while
  preserving usage evidence; completed_calls documented and counted as
  settled calls regardless of call status (finalize_cancelled counts its
  closures).
- Event timestamps are wall-clock epoch seconds for durable, cross-worker
  attempt records.
- Built-in optimize() signatures fully typed (Optional[OptimizationRunContext]).
- 10 new regression tests reproduce every round-2 finding. Suite: 70/70.
@caohy1988 caohy1988 force-pushed the feat/optimization-run-context-enforcement branch from 0542043 to b9293de Compare July 11, 2026 07:52
@caohy1988

Copy link
Copy Markdown
Author

Round-2 disposition — all four P1s implemented (head b9293de, rebased on the round-2 contracts)

[P1] No-context GEPA stream regression → fixed exactly as specified: the in-band error_code early-return is now governed-only (run_context is not None); with no context, legacy stream semantics continue iterating. Your reproduction is a passing regression test: an error-bearing chunk followed by a valid "legacy final" chunk reflects the final text on the no-context path.

[P1] Attached lifecycle terminalization → the entire GEPA lifecycle after a successful attach() is now a terminalization boundary: your reproduction (sampler ID setup raising before the executor boundary) is a passing test — the snapshot finalizes FAILED with terminal_error_type=ValueError while the original exception propagates. Typed run-context outcomes pass through untouched; native cancellation inside the boundary finalizes CANCELLED.

[P1] Cancellation evidence/finality in Simple → native cancellation now settles the open call with accumulated usage via the contracts PR's new end_model_call(cancelled=True) before finalizing — your seven-token reproduction is a passing test (cancelled event carries total_tokens=7, settled count 1, run CANCELLED). The missing post-validation boundary is added: cancellation requested during _run_final_validation raises the typed error and can never finalize COMPLETED (tested), with finalize_success additionally defensive per the contracts PR.

[P1] Provider metadata normalization → normalized at the adapter (str(...) on raised-path codes) and enforced again at the context boundary (the final trust boundary sanitizes and bounds all error metadata). Numeric error_code=429 through the raised path lands as "429" — tested.

Gates: ruff --select F401,F811,F841 clean; isort/pyink applied; PR body refreshed with current counts. GEPA 0.1.0/0.1.1 parity: thank you for running both — the real-GEPA integration tests here run against the installed release; the min-plus-latest CI matrix remains the maintainer-side gate flagged in the RFC.

Verification: full tests/unittests/optimization/: 96/96 on this head, including five new round-2 regression tests reproducing each finding above.

@caohy1988

Copy link
Copy Markdown
Author

Round-3 fresh review — enforcement head b9293de

Verdict: keep draft / changes required. Round 2 fixes the earlier no-context stream regression, setup terminalization, native-cancellation evidence loss, final-validation race, and numeric provider-code path. The stack passes 96/96 on both GEPA 0.1.0 and 0.1.1. Two new lifecycle-ordering failures remain.

P1 — reflection setup failure can become governed success with an open call

reflection_lm() admits a call here:

handle = None
if run_context is not None:
try:
handle = run_context.begin_model_call(
STAGE_REFLECTION,
requested_model=self._config.optimizer_model,
)
except OptimizationRunContextError as e:
raise _GovernedRunAbort(str(e)) from e

It then constructs LlmRequest and schedules the coroutine outside the guarded future.result()/settlement block:

llm_request = LlmRequest(
model=self._config.optimizer_model,
config=self._config.model_configuration,
contents=[
genai_types.Content(
parts=[genai_types.Part(text=prompt)],
role="user",
)
],
)
# Usage/model metadata accumulate in a holder visible to the exception
# path, so a raised provider error cannot lose usage-so-far.
holder = {"usage": None, "model_version": None, "error_code": None}
async def _generate():
response_text = ""
async with Aclosing(llm.generate_content_async(llm_request)) as agen:
async for llm_response in agen:
llm_response: LlmResponse
if getattr(llm_response, "usage_metadata", None) is not None:
holder["usage"] = llm_response.usage_metadata
if getattr(llm_response, "model_version", None):
holder["model_version"] = llm_response.model_version
error_code = getattr(llm_response, "error_code", None)
if error_code and run_context is not None:
# Governed-only: terminate on the in-band provider error. With
# no context, legacy stream semantics continue iterating and
# may consume a later valid chunk (observable parity).
holder["error_code"] = str(error_code)
return response_text
generated_content: genai_types.Content = llm_response.content
if not generated_content.parts:
continue
response_text = "".join(
part.text
for part in generated_content.parts
if part.text and not part.thought
)
return response_text
future = asyncio.run_coroutine_threadsafe(_generate(), loop)
try:
response_text = future.result()

GEPA's reflective proposer converts reflection/proposal exceptions into None (no proposal), so this exception need not escape gepa.optimize(). The optimizer later calls finalize_success():

if run_context is not None:
run_context.finalize_success()
return GEPARootAgentPromptOptimizerResult(
optimized_agents=optimized_agents,
gepa_result=gepa_results.to_dict(),
)

Reproduction: force LlmRequest construction to raise. The optimizer returns normally with run_status=COMPLETED, started_calls=1, completed_calls=0, and an open event. This is the exact false-success shape the seam exists to prevent.

Suggested fix:

  1. Build and validate the request before reserving the model-call slot.
  2. Once a handle is admitted, put every subsequent operation, including run_coroutine_threadsafe(), inside one settlement boundary.
  3. Add a generic call-abort/failure settlement path for local optimizer/scheduling failures so they become OptimizationFailedError, not provider failures and not fake cancellation.
  4. Convert that committed typed outcome to _GovernedRunAbort, so GEPA cannot turn it into success.
  5. Harden feat(optimization): run-scoped usage, budget, and cancellation contracts (RFC #6357, PR 1/2) #6358 finalize_success() to reject open calls as a second line of defense.

Add regressions for request construction failure, coroutine scheduling failure, response iteration failure before the first chunk, and finalization with an open handle.

P1 — pre-cancellation loses to sampler discovery

Simple attaches and calls train-ID discovery before its first cancellation boundary:

if run_context is not None:
run_context.attach(owner=self)
try:
train_example_ids = sampler.get_train_example_ids()
if self._config.batch_size > len(train_example_ids):
logger.warning(
"Batch size (%d) is larger than the number of training examples"
" (%d). Using all training examples for each evaluation.",
self._config.batch_size,
len(train_example_ids),
)
self._config.batch_size = len(train_example_ids)
best_agent, _ = await self._run_optimization_iterations(
initial_agent, sampler, train_example_ids, run_context

GEPA constructs its adapter, whose constructor calls both discovery methods, and calls discovery again before its first cancellation boundary:

if run_context is not None:
run_context.attach(owner=self)
try:
if initial_agent.sub_agents:
_logger.warning(
"The GEPARootAgentPromptOptimizer will not optimize prompts for"
" sub-agents."
)
_logger.info("Setting up the GEPA optimizer...")
try:
import gepa # lazy import as gepa is not in core ADK package
_AgentGEPAAdapter = _create_agent_gepa_adapter_class()
except ImportError as e:
raise ImportError(MISSING_EVAL_DEPENDENCIES_MESSAGE) from e
loop = asyncio.get_running_loop()
adapter = _AgentGEPAAdapter(
initial_agent=initial_agent,
sampler=sampler,
main_loop=loop,
run_context=run_context,
)
llm = self._llm_class(model=self._config.optimizer_model)

def __init__(
self,
initial_agent: Agent,
sampler: Sampler[UnstructuredSamplingResult],
main_loop: asyncio.AbstractEventLoop,
run_context=None,
):
self._initial_agent = initial_agent
self._sampler = sampler
self._main_loop = main_loop
self._run_context = run_context
self._train_example_ids = set(sampler.get_train_example_ids())
self._validation_example_ids = set(sampler.get_validation_example_ids())

Reproduced behavior:

  • pre-cancelled Simple calls get_train_example_ids() once;
  • pre-cancelled GEPA calls train and validation discovery twice each;
  • if discovery raises, the setup exception wins and the context becomes FAILED even though cancellation was already pending.

The existing tests say “zero work” but assert only sample_and_score() call counts.

Suggested fix: immediately after successful attach(), inside the lifecycle try, call raise_if_cancelled() before imports, model construction, adapter construction, or sampler discovery. Add tests asserting every sampler method remains untouched and cancellation wins over a competing discovery exception.

Type gate — stacked PR adds 25 Mypy errors over main

Repository-config command:

python -m mypy --no-error-summary --no-pretty src/google/adk/optimization
  • current main: 47 errors;
  • stack: 72 errors;
  • delta: 25 new errors.

Five come from #6358. The enforcement delta adds 20 around the untyped adapter context, the heterogeneous metadata holder, and dynamically inferred kwargs passed into gepa.optimize().

Suggested fix: type the adapter context and _generate() return; replace the heterogeneous holder dict with a small typed dataclass/TypedDict; avoid an untyped kwargs bag by passing a typed stop_callbacks value explicitly or using two fully typed call branches. Re-run the normalized comparison until the delta is empty.

GEPA behavior pin and test attribution

The repository stub says engine.py:588 swallows reflection errors and continues. The admitted releases behave more specifically:

The sentinel remains necessary for reflection, but the comment/source attribution should be corrected.

Suggested fix: explicitly pass raise_on_exception=True for governed runs so generic evaluator/sampler failures remain fail-closed even if the upstream default changes under ADK's unbounded gepa>=0.1 range. Preserve legacy omission on the no-context branch if exact call-shape parity matters.

Missing durable integration coverage

Real-GEPA repository tests currently cover overshoot and pre-cancel. Add real provider-error, native-cancellation, post-sampler cancellation, and reflection-setup-failure tests under both minimum and latest admitted GEPA versions. The local compatibility result is good; the missing part is durable CI and the remaining boundary scenarios.

Verification

  • Official suite with GEPA 0.1.0: 96 passed.
  • Official suite with GEPA 0.1.1: 96 passed.
  • Real GEPA sampler failure currently propagates and finalizes correctly because raise_on_exception=True.
  • Fresh enforcement probes reproduce the reflection false-success and pre-cancel precedence failures.
  • No-context multi-chunk parity remains fixed.
  • Native Simple cancellation usage and final-validation cancellation remain fixed.
  • Pyink, Isort, git diff --check, and Ruff delta: clean.

The sentinel/stopper/post-check bridge is still the right adaptation. The remaining work is around it: observe cancellation before setup, and make the interval from call admission through settlement exception-complete.

…zation, local-abort settlement, private module packaging, typed internals

- finalize_success is an invariant boundary: idempotent only on COMPLETED;
  any other existing terminal re-raises its typed outcome; a pending
  cancellation commits CANCELLED and raises; an open (unsettled) call
  commits FAILED (OPEN_MODEL_CALLS) and raises OptimizationFailedError so a
  run can never escape nonterminal with an open event.
- returned_model_version is normalized (bounded optional string) before
  mutation -- a malformed value from a custom BaseLlm can no longer make
  every subsequent snapshot raise ValidationError.
- Fractional token counters are unreported, not silently truncated
  (ints excluding bool; floats only when integral); zero and very large
  integers accepted -- tested.
- abort_model_call settles an admitted call that failed locally as
  call-status ABORTED with run-status FAILED via OptimizationFailedError:
  truthfully distinct from provider failures and cancellation (needed by
  the enforcement PR's post-admission exception boundary).
- ADK visibility rule: module renamed to _run_context.py; the supported
  experimental surface is exported from google.adk.optimization with
  explicit __all__; internal imports and tests use the package surface.
- Typed _CallRecord/_CallHandle constructors and attributes; optional
  .value access narrowed (the five repo-config mypy errors named in review;
  exact gate reproduction is blocked in this environment by a numpy-stub /
  python_version=3.11 syntax failure, noted honestly).
- New tests: success-with-open-call, success-after-budget/provider/cancel,
  malformed model version, fractional/integral/zero/large usage, local
  abort, concurrent attach (1 of 16 wins), concurrent admission (exactly k
  of 16, unique sequences, shared terminal), success-vs-settlement race.
  Suite: 54 contract tests, full optimization directory green.
Lets consumers distinguish a provider-call FAILED terminal from a local/
optimizer FAILED terminal without private state -- required by the stacked
enforcement PR's post-check mapping (OptimizationProviderError vs
OptimizationFailedError).
@caohy1988 caohy1988 force-pushed the feat/optimization-run-context-enforcement branch from b9293de to ef7016b Compare July 11, 2026 09:06
@caohy1988

Copy link
Copy Markdown
Author

Round-3 disposition — every finding implemented (head ef7016b, rebased on the round-3 contracts)

[P1] Reflection setup false-success → closed with the exact five-step bridge you specified: (1) LlmRequest builds and validates before slot reservation — a construction failure is now a GEPA-native no-proposal with a clean ledger (regression: started_calls == 0, run completes truthfully, against both the stub and real GEPA); (2) from admission onward, every operation including run_coroutine_threadsafe lives in one settlement boundary; (3) local scheduling failures settle via the contracts PR's new abort_model_call — call-status aborted, run FAILED, typed OptimizationFailedError, not provider-typed and not fake cancellation (regression: patched scheduling raises → FAILED, terminal_from_provider_call=False, call settled, zero sampler evaluations after); (4) the committed outcome converts to _GovernedRunAbort so GEPA cannot swallow it; (5) finalize_success rejects open calls as the second line of defense (contracts PR).

[P1] Pre-cancellation precedence → both optimizers observe raise_if_cancelled() immediately after attach(), before imports, model construction, adapter construction, and any sampler discovery. New tests assert every sampler method remains untouched for both optimizers (not just sample_and_score counts), and that cancellation wins over a competing discovery exception.

[Type gate] → the heterogeneous holder is now a typed _ReflectionCapture; the adapter's run_context parameter is typed; the kwargs bag is gone — gepa.optimize is two fully typed branches, with the legacy call shape preserved exactly on the no-context branch. Same environment caveat as on #6358 for reproducing the repo-config mypy count (numpy-stub syntax failure under python_version=3.11); the named error sources are all addressed structurally.

[GEPA behavior pin] → attribution corrected in the sentinel docstring and stopper comment (reflection errors become no-proposal in reflective_mutation.py, independent of the engine flag; other iteration failures default raise_on_exception=True), and the governed branch pins raise_on_exception=True explicitly so evaluator/sampler failures stay fail-closed under the unbounded gepa>=0.1 range.

[Durable integration coverage] → real-GEPA repository tests extended: in-band provider error (typed, provenance-verified), post-sampler cancellation, and reflection-request failure — alongside the existing overshoot and pre-cancel tests. The min-plus-latest version matrix remains the maintainer-side CI item.

Verification: full tests/unittests/optimization/: 106/106 on this head; ruff/isort/pyink clean; PR body refreshed.

@caohy1988

Copy link
Copy Markdown
Author

Round-4 fresh review — enforcement head ef7016b

Verdict: keep draft / changes required. The sentinel/stopper/post-check bridge, pre-cancel ordering, post-admission local-abort settlement, and governed raise_on_exception=True pin are all real improvements. Three enforcement gaps remain, one of them a governed false-success path.

Verified Round-3 closures

  • Pre-cancellation wins before dependency/model/adapter/sampler discovery in both prompt optimizers.
  • Post-admission reflection scheduling failures settle as ABORTED and map to OptimizationFailedError, not provider failure.
  • Governed GEPA explicitly pins raise_on_exception=True; the no-context call shape remains unchanged.
  • In-band and raised provider errors survive the GEPA swallowing boundary through the private sentinel and post-check.
  • No sampler call is scheduled after a reflection token overshoot commits.
  • Real-GEPA tests cover in-band provider failure and post-sampler cancellation.
  • The complete suite passes locally on both GEPA 0.1.0 and 0.1.1.

P1 — a local request-construction crash is recorded as successful governance

reflection_lm() constructs LlmRequest before call admission. Pre-admission construction is correct for ledger accounting: no provider call has begun, so no call event should exist. The failure semantics are not correct, however.

GEPA converts the exception to no proposal, the context remains nonterminal, and ADK later commits COMPLETED. The repository test explicitly pins this result:

test_reflection_request_failure_leaves_clean_ledger

started_calls = 0
run_status = COMPLETED

A malformed ADK request/configuration is an optimizer-internal failure, not a successful governed optimization. A downstream governance wrapper can currently persist and potentially accept this attempt as a clean success.

Potential fix: keep request construction before admission, but catch its exception only on the governed path, call finalize_failed(error_code="REQUEST_CONSTRUCTION_FAILURE", error_type=...), and raise _GovernedRunAbort. The call ledger stays correctly empty while the run ends truthfully as FAILED; the existing post-check then maps it to OptimizationFailedError. Preserve the legacy no-context behavior.

P2 — scheduling-failure handling leaks an unawaited coroutine

run_coroutine_threadsafe(_generate(), loop) creates the coroutine before scheduling. When scheduling raises, the coroutine is never closed. The new regression test passes while emitting:

RuntimeWarning: coroutine ... _generate was never awaited

Potential fix: construct the coroutine into a local, attempt scheduling, and call coroutine.close() if scheduling fails before performing the existing abort_model_call() path. Apply the same pattern to the adapter's sampler coroutine passed to run_coroutine_threadsafe().

P2 — provider-error and cancellation terminals discard model-version evidence

Both optimizers capture model_version, but terminal settlements for provider errors and Simple native cancellation omit returned_model_version. I reproduced an in-band provider failure that reported provider-model-v9; the final event preserved its seven tokens and error code but stored returned_model_version=None.

This makes terminal attempt evidence weaker than successful-call evidence even though the provider supplied the field.

Potential fix: pass the captured model version to end_model_call() on every terminal path: in-band provider errors, raised provider errors after a streamed response, and Simple cancellation. Add one regression per optimizer proving terminal usage and model identity survive together.

Inherited #6358 blockers

This stack also inherits the contracts findings at b12baac:

  • throwing usage accessors can strand calls open;
  • one new strict-Mypy error remains;
  • the success/settlement race test emits unhandled thread exceptions;
  • duplicate terminal settlement can return normally;
  • admission/cancellation metadata can corrupt snapshots.

Verification

  • GEPA 0.1.1: 106 passed, 31 warnings.
  • GEPA 0.1.0: 106 passed, 31 warnings.
  • The scheduling-failure regression emits the unawaited-coroutine warning on both runs.
  • Strict Mypy: 48 errors, versus 47 on current main.
  • Ruff, Pyink, Isort, and git diff --check: pass.
  • GitHub currently exposes lightweight CLA/header/triage/workflow checks only; the unit, type, and minimum-plus-latest GEPA matrix are not durable PR gates.

The runtime architecture remains right. The key correction is semantic: a clean ledger means "no model call began," not "the optimization succeeded."

…he ledger; duplicate terminal settlement re-raises; race test is warning-clean

- _extract_usage is defensive around attribute ACCESS as well as
  conversion: a throwing usage property or hostile numeric degrades that
  counter to unreported and the call settles atomically (no more stranded
  open events). All str() normalization paths survive hostile __str__
  (error metadata degrades to UNSTRINGABLE; optional fields to None).
- Duplicate settlement of an already-closed call on a TERMINAL run
  re-raises the committed terminal (governance callers cannot continue
  past it); the no-op return remains only for a closed call on a
  nonterminal run. abort_model_call follows the same rule.
- Admission and cancellation metadata are normalized at the boundary:
  stage must be a non-empty bounded string (ValueError otherwise, no slot
  consumed), requested_model and cancel reason are bounded normalized
  strings -- hostile values can no longer make every snapshot raise.
- The remaining strict-mypy optional-.value read is narrowed via a local.
- The success/settlement race test uses a Barrier, captures both threads'
  outcomes, asserts the two legal resolutions explicitly and
  completed_calls == started_calls -- clean under
  -W error::pytest.PytestUnhandledThreadExceptionWarning.
- Seven new regression tests reproduce every round-4 contract finding.
…settlement, pre-cancel precedence, typed internals, GEPA behavior pin

- GEPA reflection builds and validates LlmRequest BEFORE reserving the
  model-call slot (a construction failure is a GEPA-native no-proposal with
  a clean ledger, never an open call); from admission onward every
  operation -- including run_coroutine_threadsafe scheduling -- lives inside
  one settlement boundary; local scheduling failures settle via the new
  abort_model_call as call-status ABORTED / run-status FAILED
  (OptimizationFailedError, truthfully distinct from provider failures and
  cancellation) and convert to the sentinel so GEPA cannot swallow them
  into success.
- Snapshot terminal provenance (terminal_from_provider_call) distinguishes
  provider FAILED from local FAILED in the post-check and sentinel-escape
  mappings.
- Pre-cancellation wins over discovery: both optimizers observe
  raise_if_cancelled immediately after attach, before imports, model/
  adapter construction, and any sampler method; cancellation also wins over
  a competing discovery exception (tested with sampler-method
  assert_not_called for both optimizers).
- finalize_success is gated to genuinely non-terminal runs on the governed
  GEPA partial path (the authoritative BUDGET_EXCEEDED terminal survives).
- Typed _ReflectionCapture replaces the heterogeneous holder dict; adapter
  run_context parameter typed; gepa.optimize is two fully typed branches --
  the legacy call shape preserved exactly for absent-context parity, the
  governed branch pinning raise_on_exception=True so generic evaluator/
  sampler failures stay fail-closed under the unbounded gepa>=0.1 range;
  sentinel docstring attribution corrected (reflective_mutation.py converts
  reflection errors to no-proposal independent of the engine flag).
- New real-GEPA durable coverage: in-band provider error, post-sampler
  cancellation, reflection-request failure. Full suite: 106/106.
@caohy1988 caohy1988 force-pushed the feat/optimization-run-context-enforcement branch from ef7016b to 50acc88 Compare July 11, 2026 09:50
…D, coroutine hygiene, terminal evidence parity

- A governed LlmRequest construction failure keeps the call ledger
  correctly empty (no provider call began) but finalizes the run FAILED
  (REQUEST_CONSTRUCTION_FAILURE) and aborts via the sentinel -- a malformed
  optimizer request can never be recorded as a successful governed
  optimization. Legacy no-context behavior preserved. The two round-3
  tests pinning the superseded clean-ledger-success shape are updated to
  the corrected semantics (stub + real GEPA).
- Scheduling failures close the never-awaited coroutine before settling
  the abort (reflection and the adapter's sampler coroutine) -- the
  regression now runs with RuntimeWarning promoted to error.
- Terminal settlements carry the same evidence as successes: in-band and
  raised provider errors and Simple native cancellation all preserve
  returned_model_version alongside usage (regressions per optimizer).
- Full suite: 116/116.
@caohy1988 caohy1988 force-pushed the feat/optimization-run-context-enforcement branch from 50acc88 to 8a19693 Compare July 11, 2026 09:50
@caohy1988

Copy link
Copy Markdown
Author

Round-4 disposition — all findings implemented (head 8a19693, rebased on round-4 contracts)

[P1] Request-construction false success → fixed exactly per your semantics: construction stays before admission (the call ledger correctly stays empty — no provider call began), but on the governed path the failure now finalizes FAILED (REQUEST_CONSTRUCTION_FAILURE) and raises the sentinel, mapping to OptimizationFailedError in the post-check. The two round-3 tests that pinned the superseded clean-ledger-success shape are updated to the corrected semantics (stub + real GEPA): started_calls == 0, run_status == FAILED, terminal_from_provider_call == False. Legacy no-context behavior preserved. Your one-sentence summary is now true in code: a clean ledger means "no model call began," not "the optimization succeeded."

[P2] Unawaited-coroutine leak → the reflection coroutine is constructed into a local and close()d when scheduling fails, before the existing abort_model_call path; the same pattern applied to the adapter's sampler coroutine. The scheduling-failure regression now runs with RuntimeWarning promoted to error.

[P2] Terminal evidence parityreturned_model_version now travels with every terminal settlement: GEPA in-band and raised provider errors, and Simple in-band, raised, and native-cancellation paths. Regressions per optimizer prove usage and model identity survive together (your provider-model-v9 reproduction is one of them).

[Inherited #6358 blockers] → all closed on the contracts head d0b10d5 (see the disposition there) and this branch is rebased on it.

Verification: full tests/unittests/optimization/: 116/116 on this head; ruff --select F401,F811,F841 clean (one pre-existing unused import in a GEPA test file fixed in passing); isort/pyink applied.

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

Labels

core [Component] This issue is related to the core interface and implementation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants