You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Optimizer-owned model calls are currently opaque to callers: AgentOptimizer.optimize(initial_agent, sampler) accepts no per-run control, exposes no usage ledger, no cancellation signal, and no capability declaration. Governance wrappers (quality flywheels, budget-enforced CI optimization, cost-attributed experimentation) cannot observe or stop the model calls the optimizer itself makes.
This RFC proposes the smallest additive seam: an optional run-scoped OptimizationRunContext on optimize(...), instrumentation of SimplePromptOptimizer and GEPARootAgentPromptOptimizer, and conservative OptimizerCapabilities defaults for every optimizer so a wrapper can fail preflight instead of discovering opacity after spend.
SimplePromptOptimizer calls BaseLlm.generate_content_async() directly per candidate; LlmResponse.usage_metadata is available but discarded.
GEPARootAgentPromptOptimizer / GEPARootAgentOptimizer own their reflection LLM callable and discard reflection usage; max_metric_calls bounds sampler evaluations, not reflection calls or tokens.
RunConfig.max_llm_calls is enforced inside InvocationContext, but optimizer reflection bypasses Runner/InvocationContext; reusing it would mean manufacturing sessions and callback contexts for a non-agent workload.
OTel spans alone are observational: they cannot reject an opaque optimizer at preflight or stop the next logical model invocation on budget exhaustion.
The context attaches to exactly one optimization. Reuse or concurrent attachment is rejected. An unsupported-optimizer preflight happens before attachment, so a typed unsupported rejection does not consume the context.
OptimizationRunContext — control and audit state
The context owns a concurrency-safe, run-local ledger of logical BaseLlm.generate_content_async() invocations. It records control-plane metadata only; no prompt or response content is retained.
Call admission is one atomic operation: check cancellation, reserve the logical-call slot, assign the sequence ID, and create the ledger entry. A preflight rejection is not a call event.
Per-call fields:
invocation ID/sequence and extensible stage string (initial values: candidate_generation, reflection);
the invocation ID that triggered a budget stop, when applicable; and
cancellation reason / terminal error metadata.
This separation preserves the fact that the charged call which crosses a reported-token limit completed successfully even though the run subsequently terminates for budget_exceeded.
Budgets and truthful coverage
Logical-call limits are hard. The slot is reserved atomically before the provider call begins, so exactly the configured number of optimizer-owned logical calls can start. Provider/transport retries inside one BaseLlm.generate_content_async() invocation are not claimed as separate calls.
Token limits apply to authoritative provider-reported totals. Token totals commit after a terminal response. If cumulative verified total_token_count crosses max_provider_reported_tokens, the completed call is committed first and the run terminates. This is deliberately not described as a hard bound on billing or physical tokens: if any call is partial or unreported, actual-token compliance is indeterminate even when all known totals are below the configured limit. Downstream policy may fail closed on that state.
Terminal behavior is configurable: on_budget_exceeded: "raise" | "return_partial" (default "raise"). Both modes stop scheduling immediately: a logical-call preflight rejection starts no call, while a reported-token overshoot stops after the triggering in-flight call settles.
"return_partial" returns only the best result accepted before termination. The caller-owned context snapshot remains the authoritative marker with run_status="budget_exceeded"; the existing OptimizerResult schema is unchanged.
For SimplePromptOptimizer, a partial result has overall_score=None unless a validation score had already completed; budget termination never schedules a final validation solely to populate the score. For GEPA, a partial result contains only the previously committed state/frontier, never a half-proposed candidate. An over-budget run can never carry run_status="completed".
Provider errors
With no context, existing optimizer behavior is unchanged. With a context, both raised provider exceptions and in-band LlmResponse.error_code responses commit the call as provider_error, preserve any reported usage, finalize the snapshot as failed, and terminate with a typed OptimizationProviderError(snapshot, error_code). A governed run cannot silently return success after an optimizer-owned provider error.
Cancellation and worker lifecycle
Cancellation is explicit and cooperative: thread-safe idempotent request_cancel(reason), observed before and after each optimizer-owned logical model invocation, before a GEPA sampler call is scheduled, immediately after an in-flight sampler future settles, and through a context-backed GEPA stopper.
In-flight provider/sampler operations are allowed to settle. Context-requested cancellation ends with OptimizationCancelledError(snapshot). Native task cancellation requests the same cooperative stop, shields and drains the GEPA executor future to its next boundary, then re-raises asyncio.CancelledError. A normal GEPA stopper return is post-checked so it cannot masquerade as success.
The no-orphan guarantee is conditional on the active provider/sampler operation eventually reaching a cancellation boundary; cooperative cancellation is not a time-bounded kill mechanism. A caller watchdog may request cooperative cancellation at a soft deadline and wait for a bounded grace period. If the grace period expires, the caller may abandon the wait, but then ADK cannot also guarantee that the still-blocked executor worker was drained. An absolute wall-clock bound requires provider/sampler timeouts or process isolation; native task cancellation alone cannot forcibly terminate an arbitrary blocked provider call or Python executor thread.
GEPA 0.1.x adaptation
GEPA checks stop callbacks between iterations, which is too late by itself to prevent candidate evaluation after a reflection call crosses the reported-token limit. The ADK adapter therefore uses this sequence:
commit the completed reflection call and set the context terminal request;
raise a private ADK iteration-abort sentinel from the reflection callable;
allow GEPA 0.1.x to convert that reflection failure into no proposal;
have the context-backed stopper end the run at the next loop boundary; and
post-check the context after gepa.optimize() returns, mapping it to raise or the previously committed partial frontier.
Optional-dependency types and the sentinel stay private to the adapter. A regression test must prove that no sampler call is scheduled after the triggering reflection invocation commits.
OptimizerCapabilities — conservative and granular by default
Non-abstract instance property on AgentOptimizer, with all support flags defaulting to false:
accepts_run_context;
optimizer_calls_observable;
logical_call_limit_enforced;
reported_token_limit_enforced;
cooperative_cancellation; and
sampler_usage_included (always false in this RFC — sampler/Runner accounting belongs to the supplied sampler).
Callers must omit the run_context keyword entirely when accepts_run_context is false; this preserves runtime compatibility with existing third-party optimizer implementations whose override still has the old two-argument signature.
SimplePromptOptimizer and GEPARootAgentPromptOptimizer opt into the supported capabilities. GEPARootAgentOptimizer accepts the keyword only to issue a typed unsupported error before attachment or work; its capabilities remain false until the 2.4 root/skill surface is instrumented as a fast-follow. Third-party optimizers inherit unsupported defaults untouched.
Delivery
The in-memory ledger and final immutable snapshot are the only delivery mechanisms in the first implementation. The snapshot is readable from the caller-owned context on success and failure, so a wrapper can persist the complete attempt in finally.
Explicit non-goals for ADK
Price catalogs, currency estimates, storage/BigQuery writes, promotion policy, holdout governance, incumbent registries, sampler/Runner usage accounting, and a hard wall-clock/thread-kill mechanism are downstream or separate concerns. This seam observes and stops optimizer-owned calls; nothing more.
Implementation plan — one RFC, two independently reviewable stacked PRs
PR 1 — contracts:OptimizationRunContext, immutable snapshots, typed terminal exceptions, granular OptimizerCapabilities, and optional keyword-only optimize(...) plumbing (new optimization/run_context.py + minimal touch to agent_optimizer.py and built-in signatures). Additive only; no optimizer enforcement behavior yet. Stack PR 2 immediately so the contract is reviewed with a concrete consumer rather than as dead API.
the no-context path is observationally equivalent in call order, results, exceptions, and existing serialization;
an old third-party optimizer override is never passed run_context after capability preflight;
exact atomic logical-call limits, with provider retries explicitly out of scope;
reported-token overshoot commits then terminates in both raise and return_partial modes;
verified / partial / unreported classification, no zero coercion, and indeterminate compliance when coverage is incomplete;
call status remains completed while the run status becomes budget_exceeded for the triggering successful call;
partial Simple results never run post-stop validation and partial GEPA results expose only the prior committed frontier;
raised and in-band provider errors preserve usage and cannot return governed success;
context-requested and native cancellation cross the GEPA executor bridge, with the executor future shielded/drained once the in-flight operation reaches a boundary;
no sampler work is scheduled after a reflection overshoot commits;
context one-shot, pre-cancel, unsupported-preflight, reuse, concurrent-attachment, and atomic-call-admission behavior; and
stopper/callback adaptation against both the declared minimum gepa==0.1.0 and the latest version admitted by ADK's unbounded gepa>=0.1 dependency. If that matrix is not sustainable, define and test an upper bound.
Questions for maintainers
Optional keyword-only context on optimize(...) (recommended for per-run state), or constructor injection for built-in optimizers?
OptimizerCapabilities as an instance property (recommended — support can depend on model/config), or class-level?
Is normalized-only usage (input/output/reasoning/cached/tool_use/total + coverage) the right experimental v1, leaving raw provider metadata out until a consumer proves need?
Is the caller-owned final snapshot an acceptable authoritative terminal marker for return_partial, preserving the existing OptimizerResult schema, or would maintainers prefer a context-aware outcome wrapper?
Related follow-ups, not acceptance dependencies
GEPA optimizer crashes with KeyError on failed eval cases #6004 should independently define exact score coverage as a Sampler postcondition or replace the raw GEPA KeyError with a typed incomplete-result failure. No answer is required to accept or implement this RFC.
Instrumenting GEPARootAgentOptimizer is a fast-follow after the prompt-surface contract proves stable.
Definition of done
A caller can run either instrumented optimizer with an optional one-shot context; receive honest, granular capabilities for supported and unsupported optimizers; reconcile every ADK-owned logical model invocation with provider-reported usage when available; distinguish verified token-limit compliance from indeterminate coverage; terminate deterministically on logical-call or reported-token overshoot in either configured mode; stop cooperatively and drain GEPA's worker once the active provider/sampler operation reaches a boundary; and read the final immutable ledger snapshot on success or failure — with observationally equivalent behavior for existing no-context callers and no new required dependencies.
Summary
Optimizer-owned model calls are currently opaque to callers:
AgentOptimizer.optimize(initial_agent, sampler)accepts no per-run control, exposes no usage ledger, no cancellation signal, and no capability declaration. Governance wrappers (quality flywheels, budget-enforced CI optimization, cost-attributed experimentation) cannot observe or stop the model calls the optimizer itself makes.This RFC proposes the smallest additive seam: an optional run-scoped
OptimizationRunContextonoptimize(...), instrumentation ofSimplePromptOptimizerandGEPARootAgentPromptOptimizer, and conservativeOptimizerCapabilitiesdefaults for every optimizer so a wrapper can fail preflight instead of discovering opacity after spend.Target reviewed:
mainat9d306f5(2026-07-10). Downstream consumer with a pinned contract waiting on this seam: GoogleCloudPlatform/BigQuery-Agent-Analytics-SDK#318.Why the current surface is not enough
SimplePromptOptimizercallsBaseLlm.generate_content_async()directly per candidate;LlmResponse.usage_metadatais available but discarded.GEPARootAgentPromptOptimizer/GEPARootAgentOptimizerown their reflection LLM callable and discard reflection usage;max_metric_callsbounds sampler evaluations, not reflection calls or tokens.RunConfig.max_llm_callsis enforced insideInvocationContext, but optimizer reflection bypasses Runner/InvocationContext; reusing it would mean manufacturing sessions and callback contexts for a non-agent workload.KeyError) and fix(eval): wire App.plugins / context-cache / resumability through adk eval #5534 (App plugin plumbing foradk eval, which leavescli_optimizeas follow-up). Neither addresses optimizer-owned call observability; this RFC does not change sampler/evaluator telemetry, which belongs to the supplied sampler/Runner.Proposed contract (names directional)
Public shape
Existing callers pass nothing and remain observationally equivalent:
Governed callers may supply a one-shot context:
The context attaches to exactly one optimization. Reuse or concurrent attachment is rejected. An unsupported-optimizer preflight happens before attachment, so a typed unsupported rejection does not consume the context.
OptimizationRunContext— control and audit stateThe context owns a concurrency-safe, run-local ledger of logical
BaseLlm.generate_content_async()invocations. It records control-plane metadata only; no prompt or response content is retained.Call admission is one atomic operation: check cancellation, reserve the logical-call slot, assign the sequence ID, and create the ledger entry. A preflight rejection is not a call event.
Per-call fields:
candidate_generation,reflection);completed|provider_error|cancelled;verified(provider suppliedtotal_token_count) |partial(some counters, no authoritative total) |unreported(none). Missing values staynull— never coerced to zero.The final immutable snapshot separately records run-level state:
completed|budget_exceeded|cancelled|failed;within_limit|exceeded|indeterminate;This separation preserves the fact that the charged call which crosses a reported-token limit completed successfully even though the run subsequently terminates for
budget_exceeded.Budgets and truthful coverage
Logical-call limits are hard. The slot is reserved atomically before the provider call begins, so exactly the configured number of optimizer-owned logical calls can start. Provider/transport retries inside one
BaseLlm.generate_content_async()invocation are not claimed as separate calls.Token limits apply to authoritative provider-reported totals. Token totals commit after a terminal response. If cumulative verified
total_token_countcrossesmax_provider_reported_tokens, the completed call is committed first and the run terminates. This is deliberately not described as a hard bound on billing or physical tokens: if any call ispartialorunreported, actual-token compliance isindeterminateeven when all known totals are below the configured limit. Downstream policy may fail closed on that state.Terminal behavior is configurable:
on_budget_exceeded: "raise" | "return_partial"(default"raise"). Both modes stop scheduling immediately: a logical-call preflight rejection starts no call, while a reported-token overshoot stops after the triggering in-flight call settles."raise"raisesOptimizationBudgetExceeded(snapshot)."return_partial"returns only the best result accepted before termination. The caller-owned context snapshot remains the authoritative marker withrun_status="budget_exceeded"; the existingOptimizerResultschema is unchanged.For
SimplePromptOptimizer, a partial result hasoverall_score=Noneunless a validation score had already completed; budget termination never schedules a final validation solely to populate the score. For GEPA, a partial result contains only the previously committed state/frontier, never a half-proposed candidate. An over-budget run can never carryrun_status="completed".Provider errors
With no context, existing optimizer behavior is unchanged. With a context, both raised provider exceptions and in-band
LlmResponse.error_coderesponses commit the call asprovider_error, preserve any reported usage, finalize the snapshot asfailed, and terminate with a typedOptimizationProviderError(snapshot, error_code). A governed run cannot silently return success after an optimizer-owned provider error.Cancellation and worker lifecycle
Cancellation is explicit and cooperative: thread-safe idempotent
request_cancel(reason), observed before and after each optimizer-owned logical model invocation, before a GEPA sampler call is scheduled, immediately after an in-flight sampler future settles, and through a context-backed GEPA stopper.In-flight provider/sampler operations are allowed to settle. Context-requested cancellation ends with
OptimizationCancelledError(snapshot). Native task cancellation requests the same cooperative stop, shields and drains the GEPA executor future to its next boundary, then re-raisesasyncio.CancelledError. A normal GEPA stopper return is post-checked so it cannot masquerade as success.The no-orphan guarantee is conditional on the active provider/sampler operation eventually reaching a cancellation boundary; cooperative cancellation is not a time-bounded kill mechanism. A caller watchdog may request cooperative cancellation at a soft deadline and wait for a bounded grace period. If the grace period expires, the caller may abandon the wait, but then ADK cannot also guarantee that the still-blocked executor worker was drained. An absolute wall-clock bound requires provider/sampler timeouts or process isolation; native task cancellation alone cannot forcibly terminate an arbitrary blocked provider call or Python executor thread.
GEPA 0.1.x adaptation
GEPA checks stop callbacks between iterations, which is too late by itself to prevent candidate evaluation after a reflection call crosses the reported-token limit. The ADK adapter therefore uses this sequence:
gepa.optimize()returns, mapping it toraiseor the previously committed partial frontier.Optional-dependency types and the sentinel stay private to the adapter. A regression test must prove that no sampler call is scheduled after the triggering reflection invocation commits.
OptimizerCapabilities— conservative and granular by defaultNon-abstract instance property on
AgentOptimizer, with all support flags defaulting tofalse:accepts_run_context;optimizer_calls_observable;logical_call_limit_enforced;reported_token_limit_enforced;cooperative_cancellation; andsampler_usage_included(alwaysfalsein this RFC — sampler/Runner accounting belongs to the supplied sampler).Callers must omit the
run_contextkeyword entirely whenaccepts_run_contextis false; this preserves runtime compatibility with existing third-party optimizer implementations whose override still has the old two-argument signature.SimplePromptOptimizerandGEPARootAgentPromptOptimizeropt into the supported capabilities.GEPARootAgentOptimizeraccepts the keyword only to issue a typed unsupported error before attachment or work; its capabilities remain false until the 2.4 root/skill surface is instrumented as a fast-follow. Third-party optimizers inherit unsupported defaults untouched.Delivery
The in-memory ledger and final immutable snapshot are the only delivery mechanisms in the first implementation. The snapshot is readable from the caller-owned context on success and failure, so a wrapper can persist the complete attempt in
finally.Explicit non-goals for ADK
Price catalogs, currency estimates, storage/BigQuery writes, promotion policy, holdout governance, incumbent registries, sampler/Runner usage accounting, and a hard wall-clock/thread-kill mechanism are downstream or separate concerns. This seam observes and stops optimizer-owned calls; nothing more.
Implementation plan — one RFC, two independently reviewable stacked PRs
OptimizationRunContext, immutable snapshots, typed terminal exceptions, granularOptimizerCapabilities, and optional keyword-onlyoptimize(...)plumbing (newoptimization/run_context.py+ minimal touch toagent_optimizer.pyand built-in signatures). Additive only; no optimizer enforcement behavior yet. Stack PR 2 immediately so the contract is reviewed with a concrete consumer rather than as dead API.SimplePromptOptimizerandGEPARootAgentPromptOptimizer; implement atomic logical-call admission, normalized usage coverage, provider-error handling, the GEPA sentinel/stopper/post-check bridge, sampler-boundary cancellation checks, and executor shielding/drain; keep root/skill optimizer capabilities conservative.The two PRs must collectively prove:
run_contextafter capability preflight;raiseandreturn_partialmodes;indeterminatecompliance when coverage is incomplete;completedwhile the run status becomesbudget_exceededfor the triggering successful call;gepa==0.1.0and the latest version admitted by ADK's unboundedgepa>=0.1dependency. If that matrix is not sustainable, define and test an upper bound.Questions for maintainers
optimize(...)(recommended for per-run state), or constructor injection for built-in optimizers?OptimizerCapabilitiesas an instance property (recommended — support can depend on model/config), or class-level?input/output/reasoning/cached/tool_use/total+ coverage) the right experimental v1, leaving raw provider metadata out until a consumer proves need?return_partial, preserving the existingOptimizerResultschema, or would maintainers prefer a context-aware outcome wrapper?Related follow-ups, not acceptance dependencies
Samplerpostcondition or replace the raw GEPAKeyErrorwith a typed incomplete-result failure. No answer is required to accept or implement this RFC.adk eval;cli_optimizeApp/plugin parity remains a separate follow-up.GEPARootAgentOptimizeris a fast-follow after the prompt-surface contract proves stable.Definition of done
A caller can run either instrumented optimizer with an optional one-shot context; receive honest, granular capabilities for supported and unsupported optimizers; reconcile every ADK-owned logical model invocation with provider-reported usage when available; distinguish verified token-limit compliance from indeterminate coverage; terminate deterministically on logical-call or reported-token overshoot in either configured mode; stop cooperatively and drain GEPA's worker once the active provider/sampler operation reaches a boundary; and read the final immutable ledger snapshot on success or failure — with observationally equivalent behavior for existing no-context callers and no new required dependencies.