Skip to content

RFC: run-scoped usage, budget, and cancellation controls for AgentOptimizer (OptimizationRunContext + OptimizerCapabilities) #6357

Description

@caohy1988

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 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.

Target reviewed: main at 9d306f5 (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

  • 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.
  • Related but separate: GEPA optimizer crashes with KeyError on failed eval cases #6004 (score coverage / GEPA KeyError) and fix(eval): wire App.plugins / context-cache / resumability through adk eval #5534 (App plugin plumbing for adk eval, which leaves cli_optimize as 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:

await optimizer.optimize(initial_agent, sampler)

Governed callers may supply a one-shot context:

await optimizer.optimize(
    initial_agent,
    sampler,
    run_context=run_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 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);
  • requested model and returned model version;
  • start/end time;
  • call status: completed | provider_error | cancelled;
  • sanitized provider error code / exception type when present;
  • provider-reported prompt / output / reasoning / cached / tool-use / total tokens when present; and
  • usage coverage: verified (provider supplied total_token_count) | partial (some counters, no authoritative total) | unreported (none). Missing values stay null — never coerced to zero.

The final immutable snapshot separately records run-level state:

  • run status: completed | budget_exceeded | cancelled | failed;
  • configured limits and usage-coverage summary;
  • reported-token budget status: within_limit | exceeded | indeterminate;
  • 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.

  • "raise" raises OptimizationBudgetExceeded(snapshot).
  • "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:

  1. commit the completed reflection call and set the context terminal request;
  2. raise a private ADK iteration-abort sentinel from the reflection callable;
  3. allow GEPA 0.1.x to convert that reflection failure into no proposal;
  4. have the context-backed stopper end the run at the next loop boundary; and
  5. 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

  1. 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.
  2. PR 2 — enforcement: instrument SimplePromptOptimizer and GEPARootAgentPromptOptimizer; 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:

  • 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

  1. Optional keyword-only context on optimize(...) (recommended for per-run state), or constructor injection for built-in optimizers?
  2. OptimizerCapabilities as an instance property (recommended — support can depend on model/config), or class-level?
  3. 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?
  4. 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

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions