Production-ready agents, Part 11.5: Fallbacks and budgets
Models time out, hit rate limits, return broken JSON and quote numbers nobody gave them. This part sorts the ways a turn can fail, builds a fallback ladder that is decided in code before anything breaks, and adds a budget for tokens, cost, model calls and time that is checked while the turn runs, not in next month's report.
It is 9 o’clock on a Monday morning at Acme Consumer Brands. Maria in marketing asks the assistant “What did the Acme Pure stockouts in Western Europe cost us?”, and so do forty of her colleagues, each with their own question, in the same minute. The writer model answers the first few. Then the model endpoint starts returning HTTP 429, the code for “too many requests”. What does Maria see?
Until now, the honest answer was “it depends on luck”. Part 11’s usage ledger and cost per resolved question arrive after the fact, in a report. Nothing in the running turn decides what to do when a call fails, or when one question has already spent more than it should.
This part fills that gap before Part 12 gives the system to real users. It sorts the ways one turn can fail, builds a ladder of deterministic fallbacks decided in code before anything breaks, and adds a budget for tokens, cost, model calls and time that is checked while the turn runs.
At Acme, every model call already retries, and the number check, the kill switch and the usage ledger are in place. The budget plugin, the circuit breaker and deadline propagation are shown as sketches of how they would work at Acme; they are not in the series’ repository.
If you prefer to watch first, here is the heart of this part as a short animated explainer.
Video: how an agent fails safely and stays inside a budget, in three minutes. The narration is a synthetic voice; the script and animation are mine.
Where are we in the architecture?
This part adds fallbacks and budgets around the router’s model calls, so a failing model or a runaway loop still ends in an honest answer. Part 1.25 walks through the whole picture, layer by layer.
where Part 11.5 sits in the Acme architecture: highlighted boxes are the subject of this part, solid boxes come from earlier parts, faded boxes arrive later
What do I need to know first?
These are the terms this part uses, each with a plain explanation:
- Failure mode: one specific way a step can go wrong, such as a timeout or a rate limit.
- Transient error: a failure that may go away if you try the same thing again a little later, such as an overloaded server. A permanent error will fail the same way every time, such as a request the model’s safety filter blocks.
- Retry: sending the same request again after a failure. A bounded retry has a maximum number of attempts.
- Exponential backoff: waiting longer before each retry, for example 1, 2, then 4 seconds, so a struggling service gets time to recover.
- Jitter: a random amount added to (or taken from) each wait, so that many clients do not all retry at the same instant.
- Idempotent: a request that has the same effect however many times it runs. Reading a table is idempotent; creating an invoice is not.
- Circuit breaker: code that stops calling a dependency after repeated failures, for a while, so the system fails fast instead of waiting on something that is down.
- Budget: the most a single request may spend, measured in tokens, money, model calls and time.
- Deadline: the point in time by which the whole turn must finish. Deadline propagation means passing the time that is left down to every step the turn calls.
What fails, and how?
Failures are not all the same failure, so they do not all get the same fix. The split that matters is transient (try again a little later and it may work) versus permanent (it will fail the same way every time, however often you try).
Start with something simpler than an agent: phoning a bakery to order a birthday cake.
1. The line is engaged. Ring back in a few minutes: it will probably work. (transient)
2. Nobody picks up for 2 minutes. Ring back later, or try another branch. (transient)
3. "This number does not exist." Ringing back changes nothing. Find another way. (permanent)
4. They read the order back Ringing back does not help either: correct it once. (bad output)
as "one steak".
Where people get tripped up is treating all four the same, usually by ringing back until something works. The engaged line and the silent phone deserve another try; the dead number and the wrong order need a different response. In agent terms, the engaged line is a 429 (rate limit), the silent phone is a timeout, the dead number is a permanent error such as a safety block, and the misheard order is malformed output.
How does this apply to Acme? The same split holds, with more links in the chain.
A turn in our system is a chain: the router’s extract step, one or two specialists over A2A, their tools, and the writer. Any link can fail. These are the eight failure modes I plan for:
eight ways one turn can fail: timeouts, rate limits and server errors are transient and may succeed if retried later; bad output, safety blocks, tool errors, ungrounded answers and remote agent failures need a different response, such as one repair, a decline, a replacement or a partial answer
- Timeout. The call does not finish in time. The Gemini
API errors reference lists 504
(
deadline_exceeded) for this, and a client may give up first with 408 or a dropped connection. At Acme, the writer reasons for a long time on a hard question. - Rate limit. The project has used more than its quota. The Gemini API returns 429 RESOURCE_EXHAUSTED; its rate limits page says limits apply per project, to requests per minute, tokens per minute and requests per day, and exceeding any one triggers the error. The Agent Platform error code 429 page says the same. Agent Runtime has its own quotas too, such as 90 queries per minute per project and region by default. This is Maria’s Monday morning.
- Server error. 500 (internal error) or 503 UNAVAILABLE (temporarily overloaded), usually short.
- Malformed or truncated output. The reply is not usable. Its finish reason (why the model
stopped) can be
MAX_TOKENS(cut off at the output limit) orMALFORMED_FUNCTION_CALL(an invalid tool call), both listed in the Gemini API generate content reference. At Acme, the extract step could return half a JSON object. - Safety block. The finish reason is
SAFETY, or the prompt is blocked with ablockReason. The troubleshooting guide says to review the prompt against the safety settings. The same prompt will be blocked again. - Tool error. A tool returns
{"status": "error", ...}(Part 1.5’s errors returned as data), or a guard refuses the request, such as BigQuery refusing a query that would bill more than 2 GB (Part 3). - Ungrounded answer. The text reads well but quotes a figure no tool returned. Part 5’s number check exists for exactly this.
- Remote agent failure. A specialist is down, slow or returns a failed task. Our router waits up to 300 seconds for a specialist (Part 6), so one slow specialist can hold the whole turn.
The tags are the useful part. Timeouts, rate limits and server errors are transient, so a later retry may succeed. Google’s retry strategy for Agent Platform lists them as the retryable cases: HTTP 408, 429 and 5xx, plus socket timeouts and dropped connections. It also says not to retry other client errors, such as 400 (a bad request) or 401 (not authorised), because they will fail again. The other five failure modes need a different response: one repair, a replacement, a decline or a partial answer.
What is a deterministic fallback?
A deterministic fallback is a response to a failure that you choose in advance and write in code. When failure mode X happens at step Y, the system does Z, every time.
A simple example first. A shop’s till crashes at the busiest hour. A well-run shop does not ask the cashier to improvise. There is a card by the till, written months ago: if the till is down, use the printed price list and the receipt pad; if the card reader is down too, take cash only and put a sign on the door. Every cashier does the same thing on every shift, and the manager can test it in a drill. The crashed till is the failure mode (X), checkout is the step (Y), and the card by the till is the fallback (Z): chosen in advance, written down, the same every time.
How does this apply to Acme? The card by the till becomes a code path, and the cashier who improvises becomes the model that is asked to try again.
The tempting alternative is to ask the model again and hope. If the writer quotes a number that is not in the findings, why not ask it to “please only use the numbers in the findings”? Because that is the instruction it already ignored. A second attempt costs another call, it may fail the same way, and you cannot test it, because the result changes from run to run. Part 5 made the same choice for the number check: it replaces the answer and does not ask for a new one.
So a good fallback has three properties:
- It is chosen per failure mode. A 429 and a safety block need different responses, so they get different code paths.
- It is bounded. It has a fixed maximum number of attempts, and it always ends.
- It is honest. If the system cannot give the full answer, it says so and shows what it does know. It never fills the gap with a guess.
My view: a good fallback is the most useful thing the system can still say truthfully.
What is the fallback ladder?
I arrange the fallbacks as a ladder. Each rung down is cheaper, plainer and more certain than the one above it. A failure starts at the first rung that applies to it and moves down only if that rung fails or does not apply.
It is the same ladder you climb down on a bad commute:
1. The 08:10 train is cancelled. Wait for the 08:25 on the same line. (retry)
2. That one is cancelled too. Take the bus instead. (a different model)
3. The bus driver misses the stop. Point it out once, then carry on. (one repair)
4. No buses either. Walk, with the map you already have. (tool results only)
5. Too far to walk. Ring the office: "I can't get in today." (honest decline)
6. The line is closed all week. Stop checking every hour; look tomorrow. (circuit breaker)
Each step down is slower or plainer, but you always end with a true answer to “where are you?”. You never tell the office you are at your desk.
How does this apply to Acme? Each rung has an exact match in our build. The guide below says where each of the eight failure modes above starts, cheapest response first:
Failure mode First rung that applies If that fails too
1-3 Timeout, rate limit, 5xx Rung 1: bounded retry Rung 2, then 4 or 5
4 Malformed or truncated output Rung 3: validate, one repair Rung 4 or 5
5 Safety block Rung 5: decline, never retry nothing further
6 Tool error, bad arguments Reflect and Retry for tools Rung 4 or 5
6 Tool error, a guard refused Rung 4 or 5, never coach around it nothing further
7 Ungrounded answer Rung 4: findings only Rung 5
8 Remote agent failure Partial answer from what exists Rung 5
Any mode, failing again and again Rung 6: circuit breaker kill switch (a person)
the fallback ladder: when the normal model call fails, the turn steps down through bounded retry, a fallback model, schema validation with one repair, an answer built from tool results only, a templated honest decline, and finally a circuit breaker or kill switch; each rung is decided in code and shows the user something plainer but still true
The animation follows one bad turn: the writer hits rate limits on every retry, so the turn drops to the fallback model. That answer quotes a figure that is not in the findings, so the turn skips rung 3 (which handles broken structure) and Maria gets the findings from rung 4. Each rung below gets the same treatment: what it is, when to use it, Acme’s case and how to build it in ADK.
Rung 1: a bounded retry with backoff and jitter
What it is. Send the same request again, after a wait that grows with each attempt and has a random part, up to a fixed number of attempts.
When to use it. Only for transient errors (408, 429, 5xx and timeouts), and only for idempotent
calls. A Gemini call changes nothing on the server, and the Agent Platform retry page says
generateContent is generally safe to retry for transient errors. A tool that writes data is a
different matter: retrying it can create a duplicate. Google’s
Cloud Storage retry strategy makes the
same point for its own API: before retrying, consider whether the request is idempotent.
A simple example first. You ring a takeaway twice because the first call dropped:
Asking (idempotent): "What time do you close?" Ask twice, hear "10 pm" twice. No harm done.
Ordering (not): "One large margherita." The first call got through before it dropped.
Order again and two pizzas arrive.
The question is safe to repeat; the order is not, unless you first ask “did my order go through?”. The common mistake is to think a retry is free. It costs time the user sits through, and a blind retry of a write can do the work twice.
How does this apply to Acme? Here is the difference with Acme’s own requests. Acme’s one write is the S&OP replan from Part 10’s
replan_no_confirm mutant:
Read (idempotent): Maria asks "What did the Acme Pure stockouts in Western Europe cost us?"
1. The supply agent queries service_cost. The call times out.
2. Retry: the same query runs again and returns the same rows. Nothing changed, so no harm done.
Write (not idempotent): Sam asks "Start an S&OP replan for Acme Pure"
1. The replan request reaches the planning system and the replan starts.
2. The reply is lost and the call times out. From the agent's side it looks like a failure.
3. A blind retry sends it again: now two replans are running.
So the read gets rung 1, and the write never does. The right response to a timed-out write is to check whether it already happened, or to tell Sam the outcome is unknown, before anything sends it again.
The everyday version of jitter: a shop shuts for ten minutes and tells its whole queue to come back at 10:00 sharp. At 10:00 everyone arrives at once and the door jams again. If each person comes back at a random minute between 10:00 and 10:10, the shop copes. That random spread is the jitter.
Why the jitter? Picture three copies of the router hitting a 429 at the same moment:
exponential backoff with and without jitter: three router copies hit a 429 at the same moment; without jitter they all retry after 1, 3 and 7 seconds and overload the endpoint again together; with full jitter each waits a random time up to the backoff limit, so the retries spread out
Without jitter, all three wait exactly the same time and retry together, so the overload returns at 1, 3 and 7 seconds. The Google SRE book’s chapter on addressing cascading failures puts it plainly: “Always use randomized exponential backoff when scheduling retries.” The same chapter warns about retries at several layers: if three layers each try 4 times, one user action can turn into 64 attempts on the struggling service. Its companion chapter on handling overload describes two limits Google uses: at most three attempts per request, and a per-client retry budget that allows retries only while they stay below 10 per cent of requests.
At Acme. Every model in our build already retries. This line is from the router’s writer.py:
writer = Agent(
name="writer",
model=Gemini(model=WRITER_MODEL, retry_options=types.HttpRetryOptions(attempts=3)),
...
)
(From the series’ project; the extract step and both specialists use the same setting.)
In ADK. The ADK Gemini page shows two places to put
retry_options: on the Gemini model object, as above, or in the agent’s generate_content_config.
HttpRetryOptions comes from the
Google Gen AI SDK and has
six settings: attempts (including the
first try), initial_delay, max_delay, exp_base (the multiplier), jitter and
http_status_codes. The Agent Platform retry page lists the defaults as 5 attempts, a 1 second initial
delay, a base of 2, a 60 second maximum and a jitter factor of 1. Spelled out for a chat agent, where
the user is waiting, the settings look like this:
# Illustrative: tighter limits for a person who is waiting for an answer.
retry = types.HttpRetryOptions(
attempts=3, # the first try plus two retries
initial_delay=1.0, # seconds
max_delay=8.0, # never wait longer than this between tries
http_status_codes=[408, 429, 500, 502, 503, 504],
)
model = Gemini(model=WRITER_MODEL, retry_options=retry)
The Agent Platform page gives the reason for keeping the numbers small: for real-time use such as chat,
“Fail fast. Limit the number of retry attempts so users are not left waiting indefinitely for a
response.” One honest gap: I have
not found documentation that says whether the SDK’s request timeout applies to each attempt or to all
of them, so I plan for the worst case (attempts × timeout, plus the waits) and keep that inside the
turn’s deadline.
For tools, ADK ships a prebuilt
Reflect and Retry plugin,
ReflectAndRetryToolPlugin, which gives the model guidance after a failed tool call and lets it try
again, up to max_retries (3 by default). That suits bad arguments. It does not suit a refused query:
the model must never be coached to work around a guard.
Rung 2: a fallback model for the same step
What it is. If the primary model still fails after its retries, send the same request to a different model.
When to use it. When the failure belongs to one model or one capacity pool: repeated 429s, a 503 that does not clear, or a model that is being retired. Do not use it for a safety block, because a different model is no reason for the prompt to become acceptable.
A simple example: your usual barista is off sick, so a colleague makes the same order from the same recipe. The coffee may be a little plainer, but it is still your order, and the person at the counter still checks it before it goes out. The order is the same request, the colleague is the fallback model, and the check at the counter is the number check that runs either way.
How does this apply to Acme?
At Acme. The writer runs on Gemini 3.1 Pro, which is in Preview. Its fallback is Gemini 3.5 Flash, which is cheaper and already handles the extract step. The answer is a little plainer, but it is grounded in the same findings and goes through the same number check.
In ADK. In Python, the hook is on_model_error_callback. The
ADK callbacks guide says it runs when the model call
raises an exception, and that returning an LlmResponse suppresses the exception and uses that
response instead. The plugins page adds one detail that matters here: after
the replacement response, after_model_callback still runs, so the writer’s number check still checks
the fallback model’s answer.
# Illustrative: one fallback call per turn, then let the error go to the next rung.
FALLBACK = Gemini(model="gemini-3.5-flash")
async def writer_on_error(callback_context, llm_request, error):
if callback_context.state.get("temp:writer_fallback_used"):
return None # already tried: do not loop
callback_context.state["temp:writer_fallback_used"] = True
llm_request.model = FALLBACK.model
async for response in FALLBACK.generate_content_async(llm_request):
return response # first full response
The temp: prefix keeps the flag for this turn only. ADK’s
state documentation says temp: keys are discarded when the
invocation completes. ADK TypeScript has a built-in for this pattern, RoutedLlm, described on the
model routing page. As of October 2026 it is
experimental and TypeScript only, so in Python we write the callback ourselves.
Rung 3: validate the output, then one repair
What it is. Check the reply against a schema (a description of the exact fields and types you expect). If it fails, ask for one corrected version, and check again.
When to use it. When the output feeds code, such as JSON that drives the next step, and the failure is about structure, not facts.
At Acme. The router’s extract step turns Maria’s question into a short structure that the routing function reads. If the JSON is truncated, routing cannot run.
In ADK. An LlmAgent can take an output_schema, such as a Pydantic model. The
LLM agents page notes that if the response fails validation, ADK
logs the error and stores the raw string under output_key instead of the parsed object. So the next
step must check what it received. The repair is plain Python:
# Illustrative: validate, repair once, then hand over to the next rung.
from pydantic import BaseModel, ValidationError
class Extracted(BaseModel):
question: str
metrics: list[str]
async def parse_or_repair(raw: str, repair) -> Extracted | None:
for attempt in (raw, None):
try:
return Extracted.model_validate_json(attempt or await repair(raw))
except ValidationError:
continue
return None # rung 4 or 5 decides what Maria sees
Here is parse_or_repair on one bad reply from the extract step (the reply text is illustrative):
1. raw = '{"question": "Acme Pure stockout cost, Western Europe", "metrics": ["service_pen'
finish_reason MAX_TOKENS: the JSON stops mid-string, so model_validate_json raises ValidationError.
2. repair(raw) asks once for the same structure, and returns
'{"question": "Acme Pure stockout cost, Western Europe", "metrics": ["service_penalty_eur"]}'
3. It validates, so the routing function gets a proper Extracted object and the turn carries on.
4. Had the repair also come back broken, the loop ends and the function returns None.
No third attempt: rung 4 or rung 5 takes over.
The model wrote both replies; code decided that the first was broken, that one repair was allowed and that the turn moves on after it, whatever the second reply looks like.
The limit is one repair. A second repair is the start of “ask again and hope”. For the specific case of a broken tool call, ADK lists a prebuilt Reflect and Retry Model plugin that asks the model to try again when a response ends with an error such as a malformed function call.
Rung 4: an answer built from tool results only
What it is. Skip the model entirely and format the facts the tools returned, in code.
When to use it. When the tools succeeded but the model’s words cannot be trusted or cannot be had: an ungrounded answer, or a writer that failed on every model.
At Acme. This is Part 5’s number check. If a figure in the writer’s answer is not in the findings, the whole answer is replaced with “I couldn’t verify every figure”, followed by the raw findings. It costs no model call, and it cannot invent a number.
# Illustrative: the same idea as the number check's replacement text.
def findings_only(findings: list[dict]) -> str:
lines = ["I couldn't verify every figure, so here are the findings as the tools returned them:"]
for f in findings:
lines.append(f"- {f['metric']}: {f['value']} ({f['source']})")
return "\n".join(lines)
In ADK. Returning an LlmResponse from after_model_callback replaces the model’s output, which is
how Part 5 does it. From on_model_error_callback, the same text becomes the response when every model
has failed.
Rung 5: a templated honest decline
What it is. A fixed message for each failure type, written and reviewed in advance.
When to use it. When there are no trustworthy facts to show: a safety block, a specialist that is down with nothing else to report, or a budget that ran out before any tool returned.
At Acme. Part 9’s request policy already declines some questions politely and deterministically. The fallback templates follow the same style. Each one says what happened, in plain words, and what the user can do next:
# Illustrative: one reviewed message per failure type.
DECLINES = {
"safety": "I can't help with that request.",
"unavailable": "The supply data service isn't responding right now. Please try again in a few minutes.",
"budget": "This question needs more work than I can do in one go. Could you narrow it down?",
}
In ADK. The same LlmResponse return from a model callback or plugin, or a Content returned from
before_run_callback to end the run before any model is called, as Part 9 does for the rate limit.
Rung 6: the circuit breaker and the kill switch
What it is. A circuit breaker, as Martin Fowler describes it in CircuitBreaker, wraps calls to a dependency and counts failures. While things work, the breaker is closed and calls go through. After too many failures it opens, and calls fail straight away without waiting. After a cool-off period it lets a trial call through (half-open), and closes again if that succeeds. The kill switch from Part 9 is the manual version: one setting that pauses the whole assistant.
The software pattern borrows the idea of the breaker in a fuse box. When a faulty heater keeps overloading a circuit, the breaker trips and cuts that circuit, so the wiring does not overheat while someone keeps switching the heater on. The difference, as Fowler notes, is the reset: in a building a person resets the breaker by hand, while a software breaker tries one call after a cool-off and resets itself if the call works. The heater is the failing dependency, the tripped switch is open, the trial call after the cool-off is half-open, and the main switch that cuts the whole house is the kill switch.
How does this apply to Acme? The heater is the writer model.
When to use it. When a dependency is clearly down, and every new turn would otherwise wait for its timeout and retries before failing anyway.
At Acme. If the writer model fails five times in a minute, new turns skip it for two minutes and go
straight to rung 2 or rung 4. If the whole system must stop, an operator turns on ACB_KILL_SWITCH.
# Illustrative: a minimal breaker. Like Part 9's rate limit, it lives in one instance's memory.
class CircuitBreaker:
def __init__(self, threshold=5, window_s=60, cool_off_s=120):
self.threshold, self.window_s, self.cool_off_s = threshold, window_s, cool_off_s
self.failures, self.open_until = [], 0.0
def allow(self) -> bool:
return time.time() >= self.open_until # closed, or cool-off over: let one through
def record(self, ok: bool) -> None:
now = time.time()
self.failures = [] if ok else [t for t in self.failures if now - t < self.window_s] + [now]
if len(self.failures) >= self.threshold:
self.open_until = now + self.cool_off_s
At Acme, this is how the breaker would work around the writer with the defaults above (threshold 5, window 60 s, cool-off 120 s), on one illustrative Monday morning:
1. 09:00:05 to 09:00:40 Five writer calls fail, each one calls record(False).
The fifth failure makes 5 in the window: open_until = 09:02:40. OPEN.
2. 09:01:10 Maria's turn. allow() is False, so the writer on Pro is not called at all.
The turn goes straight to rung 2 (Flash) and she still gets an answer, with no wait
on Pro's timeout and retries.
3. 09:02:41 Next turn. allow() is True again: HALF-OPEN, one trial call goes through to Pro.
4. 09:02:47 The trial call succeeds, record(True) empties the failure list. CLOSED.
5. If the trial had failed, the old failures are long outside the 60 s window, so this minimal
breaker would count only 1 and stay closed until four more fail. A fuller breaker reopens on a
failed trial call; that is one more line of state.
Kill switch: an operator sets ACB_KILL_SWITCH=on. Every user gets the paused message, and no model
is called, until it is switched off.
The breaker’s decisions are all code: it counts, it opens and it lets one trial call through. The kill switch is the one place a person decides.
In ADK. Call allow() in a plugin’s before_model_callback and pick the next rung when it says no.
Call record(False) in on_model_error_callback and record(True) in after_model_callback. ADK has
no built-in circuit breaker, so this is generic Python. The kill switch already exists in our build as
KillSwitchPlugin, the first plugin in the list.
What is a budget during execution?
A budget is the most one request may spend. In simple terms: before the turn starts, it gets an allowance, and every step draws from it.
Picture a teenager going out for the evening with 20 pounds, a limit of three taxi rides and a promise to be home by 11 pm. They check as the night goes on: down to 4 pounds, so walk instead of a taxi; 10:30, so leave now. The bank statement at the end of the month shows what the evening cost, but it arrives far too late to stop a fourth taxi. The 20 pounds is the cost limit, the three rides are the model call limit, 11 pm is the deadline, and checking before each spend is what makes it a budget rather than a report.
Where teams go wrong is treating a monthly cost dashboard as a budget. Part 11’s cost report is the bank statement: it tells you a turn was expensive, but it cannot stop one.
How does this apply to Acme?
More formally, an execution budget is a per-request allowance of tokens, cost, model calls and wall-clock time, checked while the turn runs, before each expensive step, so the turn can change course or stop. It has four parts:
- Tokens: input, output and thinking tokens across every model call in the turn.
- Cost: those tokens priced with Part 11’s
pricing.json, in US dollars. - Model calls: the number of model calls. This catches loops, even when each call is cheap.
- Wall-clock time: real time since the turn started, against a deadline.
Part 11’s ledger and cost report measure the same things afterwards, across many questions. A budget acts during one question: it stops tonight’s runaway question at its sixth model call.
a budget meter across one Acme turn: the router sets a budget of 0.08 dollars, 6 model calls, 40,000 tokens and 60 seconds, checks it before the extract, marketing and writer calls, and ends at 0.0537 dollars, 4 calls, 30,570 tokens and 38 seconds, inside every limit
With Part 11’s illustrative token counts, a normal marketing question uses 0.0537 dollars, 4 model calls and 30,570 tokens, well inside a budget of 0.08 dollars, 6 calls and 40,000 tokens. The budget is for the abnormal turn: a specialist looping on a failing tool, a retry storm, or a huge writer prompt. The times are made up to show the shape.
How do you choose the numbers? My view: start from the ledger. Take the 95th percentile of cost and calls for resolved questions, add some headroom, and review it monthly.
For example, with illustrative ledger numbers:
1. Take last month's resolved questions from the usage ledger: say 200 of them.
2. 95th percentile cost per question: 0.061 USD. 95th percentile model calls: 5.
3. Add about 30% headroom: 0.061 x 1.3 = 0.079, round to 0.08 USD; 5 x 1.3 = 6.5, round down to 6.
4. Budget: 0.08 USD and 6 calls, the defaults in the plugin below. The normal turn above
(0.0537 USD, 4 calls) fits; a turn that needs a seventh call is already unusual.
How do I enforce budgets in ADK?
There are six pieces, from the simplest to the most involved.
A hard cap on model calls: RunConfig.max_llm_calls
RunConfig has max_llm_calls, which “caps the total number of
LLM calls per run (default: 500)”. Part 1.5 already recommended setting it, because 500 calls is a lot
of tokens.
from google.adk.agents.run_config import RunConfig
run_config = RunConfig(max_llm_calls=8)
async for event in runner.run_async(user_id=user, session_id=sid,
new_message=msg, run_config=run_config):
...
Two limits apply. First, when the cap is reached, ADK Python raises LlmCallsLimitExceededError
(defined in the ADK source),
so the run ends with an error, not an answer. Plugins can observe this through on_run_error_callback,
but the plugins page says its return value is ignored. Second, RunConfig is chosen by whoever calls the
runner. On Agent Runtime, the
ADK app template
takes run_config as a parameter of each query, so the caller decides. When Gemini Enterprise is the
caller, you do not control it. So treat max_llm_calls as a safety net, and enforce your real limit in
a plugin that can answer politely.
A budget kept in session state
The budget for one turn lives in temp: state: temp:deadline, temp:usd, temp:calls and
temp:tokens. temp: is the right scope because the budget belongs to one invocation and should
vanish with it. The state docs also say that sub-agents called by a parent share the same invocation,
and so the same temp: state, which means the router’s extract step and writer draw from one budget.
A plugin that checks before each model call and can stop the call
The plugin is a checkpoint in code before every model call, and it can let the call through, change it or stop it. Think of a parent at a shop checkout with the teenager’s spending card. For each item they can nod it through, swap it for the cheaper own brand, or say “that’s enough for today” and close the basket. Nodding through is observe, the cheaper brand is amend, and closing the basket is intervene. The teenager (the model) never decides when the spending stops.
How does this apply to Acme? This is the heart of it. A plugin’s before_model_callback runs before every model call in every
agent of the app. The plugins page describes three ways a hook can act: observe (return nothing),
intervene (return a value, which skips the model call and uses that value instead) and amend
(change the request and let it continue). A budget plugin uses all three:
a budget plugin: before each model call, before_model_callback reads the budget and deadline from state; with plenty left it returns None and the planned model runs, when running low it sets a cheaper model on the request, and when the budget is used up it returns an LlmResponse with an honest partial answer so no model is called; after_model_callback adds the call's usage to the budget
# Illustrative: a per-turn budget. Plugin hooks are the documented ADK ones.
class BudgetPlugin(BasePlugin):
def __init__(self, usd=0.08, calls=6, seconds=60, cheap="gemini-3.1-flash-lite"):
super().__init__(name="budget")
self.usd, self.calls, self.seconds, self.cheap = usd, calls, seconds, cheap
async def before_model_callback(self, *, callback_context, llm_request):
st = callback_context.state
st.setdefault("temp:deadline", time.time() + self.seconds)
spent, used = st.get("temp:usd", 0.0), st.get("temp:calls", 0)
if used >= self.calls or spent >= self.usd or time.time() >= st["temp:deadline"]:
return LlmResponse(content=types.Content(role="model",
parts=[types.Part(text=partial_answer(st))]))
if spent >= 0.75 * self.usd:
llm_request.model = self.cheap # amend: a cheaper model for this call
return None
async def after_model_callback(self, *, callback_context, llm_response):
st, u = callback_context.state, llm_response.usage_metadata
st["temp:calls"] = st.get("temp:calls", 0) + 1
if u:
st["temp:usd"] = st.get("temp:usd", 0.0) + usage_cost(u, llm_response.model_version)
return None
usage_cost is Part 11’s call_cost, fed from the same usage_metadata fields the usage ledger
records: prompt_token_count, cached_content_token_count, candidates_token_count and
thoughts_token_count. partial_answer is rung 4’s formatting applied to whatever findings are already
in state, or rung 5’s budget template if there are none.
At Acme, this is how the plugin would handle a runaway turn: the supply agent keeps calling a tool
that keeps failing, and its own BudgetPlugin has the defaults of 0.08 USD and 6 calls. The costs are illustrative; 0.06
USD is the 75% line.
1. Calls 1 to 4 planned model, each check returns None. After call 4: temp:usd 0.062, temp:calls 4.
2. Call 5 check: 0.062 >= 0.06, so AMEND. llm_request.model = "gemini-3.1-flash-lite".
The call costs about 0.0033. After it: temp:usd 0.0653, temp:calls 5.
3. Call 6 check: still over 0.06 and only 5 calls used, so AMEND again (Flash-Lite).
After it: temp:usd about 0.069, temp:calls 6.
4. Call 7 check: temp:calls 6 >= 6, so INTERVENE. before_model_callback returns an
LlmResponse with partial_answer(st). No model is called.
5. The trace shows six generate_content spans, not seven, and the user gets whatever findings exist.
The model chose to keep calling the tool; the plugin, in code, chose the cheaper model at call 5 and the stop at call 7.
Where does it go in the plugin list? Part 9’s order is KillSwitch, RequestPolicy, RateLimit,
ModelArmor, Audit, UsageLedger (leaving out UiActionPlugin, which is not a guardrail). The kill switch, request policy and rate limit work in
before_run_callback, so they have already run before any model call. Among the model hooks, the
plugins page says ADK stops at the first plugin that returns a value. I would put BudgetPlugin just
before UsageLedgerPlugin, so a refused call is never logged as a model call.
Deadline propagation to tools and remote agents
A deadline is only useful if every step can see it. The SRE chapter describes deadline propagation: the first server sets one deadline, and every call it makes carries the time that is left. In its example, a 30 second deadline becomes 23 seconds after 7 seconds of work, and 19 seconds after 4 more.
A pizza shop promises delivery in 30 minutes. The kitchen takes 20, so the driver has 10, not a fresh 30 of his own. If nobody tells him, he takes the long way and arrives after the customer has given up and made toast. The promise to the customer is the deadline; telling the driver “you have 10 minutes” is propagation.
How does this apply to Acme? Maria is the customer, the router is the kitchen, and each specialist or query is a driver who today sets his own timer.
deadline propagation: the router sets one deadline of 60 seconds for the turn, spends 7 seconds, and passes 53 seconds left to each specialist; each specialist spends its share and passes what remains to its query, minus 5 seconds to write a reply; without propagation each hop invents its own limit and work continues after the user has given up
Our build has two fixed limits that ignore the turn: a 60 second timeout on each BigQuery query (Part 3) and a 300 second timeout on each A2A call (Part 6). The fix is to cap each of them by the time left:
# Illustrative: a tool helper and an A2A metadata provider.
def time_left(tool_context, cap_s: float, reserve_s: float = 5.0) -> float:
left = tool_context.state.get("temp:deadline", time.time() + cap_s) - time.time()
return max(0.0, min(cap_s, left - reserve_s)) # keep time to write a reply
def request_meta(ctx, a2a_request) -> dict:
meta = identity_meta(ctx) # Part 6: who is asking
deadline = ctx.session.state.get("temp:deadline")
if deadline:
meta["time_left_s"] = round(deadline - time.time(), 1)
return meta
A tool passes time_left(tool_context, cap_s=60) to its query timeout, and returns
{"status": "error", "message": "no time left"} instead of running when it gets 0. request_meta is
the a2a_request_meta_provider that RemoteA2aAgent already accepts; the
ADK source
shows it receives the invocation context and the outgoing message and returns metadata to attach. The
specialist reads time_left_s as it reads the user’s identity in Part 6 and sets its own
temp:deadline. Sending time left, not a clock time, means the two machines’ clocks need not agree.
ADK has no built-in deadline propagation, so this is our own convention, and ADK’s A2A support in
Python is still experimental as of October 2026 (Part 6).
Here is the zero case, with illustrative times. A 60 second turn has reached 56 seconds when the supply agent’s tool is about to query Databricks:
1. temp:deadline is 4 s away.
2. time_left(tool_context, cap_s=60) = max(0, min(60, 4 - 5)) = 0, because 5 s is kept in reserve.
3. The tool does not start the query. It returns {"status": "error", "message": "no time left"}.
4. The supply agent reports that it has no answer, and the router falls back to a partial answer
(see "Stop early" below) inside the 60 s, instead of a query that finishes after Maria gave up.
The same channel carries the money. Each specialist runs in its own engine with its own plugins, so the
router’s budget plugin never sees their model calls. The router sends each specialist its share of the
remaining budget, and the specialist’s own BudgetPlugin enforces it.
A cheaper model when the budget runs low
The plugin above switches to a cheaper model when three quarters of the money is gone. Reading ADK
Python’s source, the Gemini adapter sends whatever llm_request.model names, so changing it in
before_model_callback changes the model for that one call. That is an implementation detail, not a
documented promise, so test it after each ADK upgrade. Keep the switch within one model family; the
request was built for Gemini.
Part 11 priced the levers. At the same token counts, Gemini 3.1 Flash-Lite would cost about 0.0033 dollars for the writer’s call instead of 0.0262 on 3.1 Pro. That saving means nothing if the answer gets worse, so the cheaper path goes through the eval gate like any other change.
Stop early, with a partial but honest answer
A partial answer is honest when it names what is missing; it becomes dangerous the moment something fills the gap with a guess. A garage that only had time to check your brakes should say “the brakes are fine, I didn’t get to the tyres”, not tell you the car is fine.
How does this apply to Acme?
When the budget runs out, the turn should still end with something true. The order I use:
- If findings exist, show them formatted by code (rung 4), with one line saying the answer is incomplete and why.
- If a specialist answered and the other did not, show the part that exists and name the part that is missing, and who owns it.
- If nothing exists yet, use the budget template (rung 5) and suggest a narrower question.
What the turn must never do is let the writer “summarise what we have” with no check. That is how a partial answer turns into an ungrounded one.
Here is case 2 on Part 5’s mixed question, “Compare the Acme Pure Refill Launch spend with what the stockouts cost us”, on a morning when the supply agent is mid-deploy:
1. Routing (code): spend is local, penalties are delegated, so the target is both.
2. Marketing answers: Acme Pure spend, EUR 283,590.
3. Supply does not answer in time: failure mode 8.
4. The router does not ask the writer to compare one number with nothing. Code builds the reply:
Here is the part I have:
- Acme Pure spend: EUR 283,590 (marketing, campaign_performance)
Missing: the supply side (service penalties, owned by supply and finance).
The supply data service isn't responding right now. Please try again in a few minutes.
The last sentence is the DECLINES["unavailable"] text from rung 5. Every figure Maria sees came from
a tool, and the gap is named, with its owner, so she knows what to ask for later.
How do I test fallbacks?
A fallback that has never run is only a hope. Fault injection makes it run: break one thing on purpose and check that the expected rung handled it, using Part 10’s eval cases and Part 11’s traces and usage ledger.
A fire drill is the everyday version. You do not find out whether the exits work during a real fire; you set off the alarm on a quiet Tuesday, time how long the building takes to empty, and check that nobody used the lift. The alarm is the injected fault, the planned exit route is the expected rung, and the timing sheet is the evidence.
How does this apply to Acme? Each drill becomes an eval case that runs on every change.
fault injection: each eval case breaks one thing on purpose, such as a 429 on the writer, broken JSON from the extract step, an unknown figure, a supply agent timeout, a used-up budget, a safety block or repeated writer errors, and checks that the expected rung ran, using the eval result, the trace and the usage ledger as evidence
- Each fault is an eval case. Next to the question and expected route, the case says which fault to
inject and which rung should handle it, for example
"fault": "writer_429"and"expected_rung": "fallback_model". - The fault lives in test-only code. A small stand-in model (a subclass of ADK’s
BaseLlm, the base class for models) raises the error or returns a canned bad reply for the named agent. Part 10’s mutation tests switch off a safety control; this switches on a failure. Neither goes near production. - The evidence is the trace and the ledger. Code metrics check the outcome, not the wording. A
budget stop at call 3 must show no fourth
generate_contentspan in the trace and no fourthmodel_usageentry. A safety block must show exactly one model call, because a retry would mean the code treated a permanent error as transient. - Log the rung. A
fallbackevent in the structured log, with the failure mode and the rung, lets a dashboard show how often each rung fires. A rising rung 2 count is an early warning about quota.
Here is Part 10’s q3_delegation golden case with a fault added. The two new fields are our own,
like the other expected_* fields:
{
"eval_case_id": "q3_delegation_writer_429",
"prompt": {"role": "user", "parts": [{"text": "What did the stockouts behind the Acme Pure Refill Launch cost us?"}]},
"expected_route": "delegate",
"expected_target": "supply",
"expected_numbers": [51331],
"fault": "writer_429",
"expected_rung": "fallback_model"
}
The case passes only if all of this is true (illustrative evidence):
1. Trace: three failed generate_content spans on gemini-3.1-pro-preview (the first try plus two
retries), then one successful span on gemini-3.5-flash.
2. The answer contains 51331 (as 51,331), and grounded is 1.0: the number check passed.
3. The structured log has one fallback event with mode=rate_limit and rung=2.
The stand-in model raised the 429s; everything the case checks was decided by code, so the same case gives the same result on every run.
Try it: a budget-aware retry and fallback loop
This simulation needs only Python 3. It stands in for the writer step: a Pro model with retries, a
Flash fallback, the number check and a budget. The prices are Part 11’s list prices and the token
counts match the writer’s illustrative counts, so the costs line up with Part 11. The findings and the
model replies are made up. Time is simulated, so it runs instantly. Save it as fallbacks_demo.py:
"""fallbacks_demo.py: a budget-aware retry and fallback loop, no cloud and no libraries."""
import random
import re
PRICES = {"pro": (2.00, 12.00), "flash": (1.50, 9.00)} # USD per million tokens (in, out)
FINDINGS = {"penalties_eur": "36.0k", "stockout_weeks": "7"}
class Transient(Exception): # 429, 503, timeouts: worth a retry
pass
class Budget:
def __init__(self, usd, calls, seconds):
self.usd, self.calls, self.deadline = usd, calls, seconds
self.spent, self.used, self.clock = 0.0, 0, 0.0
def can_afford(self, est_usd, est_s):
return (self.used < self.calls and self.spent + est_usd <= self.usd
and self.clock + est_s <= self.deadline)
def charge(self, model, tok_in, tok_out, secs):
p_in, p_out = PRICES[model]
self.spent += (tok_in * p_in + tok_out * p_out) / 1_000_000
self.used += 1
self.clock += secs
def fake_model(model, scenario):
"""Stands in for Gemini. Each scenario breaks something on purpose."""
if scenario == "429 storm" and model == "pro":
raise Transient("429 RESOURCE_EXHAUSTED")
if scenario == "ungrounded":
return "Stockouts cost EUR 52,000 over 7 weeks."
return "Stockouts cost EUR 36.0k in penalties over 7 weeks."
def grounded(text):
numbers = re.findall(r"\d[\d,.]*k?", text)
return all(n.rstrip(".") in FINDINGS.values() for n in numbers)
def findings_only(header="I couldn't verify every figure, so here are the findings:"):
rows = [f"- {k}: {v}" for k, v in FINDINGS.items()]
return header + "\n" + "\n".join(rows)
def call_with_retry(model, scenario, budget, attempts=3, base=1.0):
for attempt in range(1, attempts + 1):
if not budget.can_afford(0.03, 8):
raise TimeoutError("budget or deadline")
try:
text = fake_model(model, scenario)
budget.charge(model, 5000, 1350, 6)
return text
except Transient as err:
budget.charge(model, 0, 0, 1) # a failed call still costs time
if attempt == attempts:
print(f" {model} attempt {attempt}: {err}, next rung")
break
wait = random.uniform(0, base * 2 ** (attempt - 1)) # full jitter
budget.clock += wait
print(f" {model} attempt {attempt}: {err}, waiting {wait:.1f}s")
raise Transient(f"{model}: {attempts} attempts failed")
def answer(scenario, budget):
for rung, model in (("normal", "pro"), ("fallback model", "flash")):
try:
text = call_with_retry(model, scenario, budget)
except Transient:
continue # next rung
except TimeoutError:
return "budget stop", findings_only("I reached this question's time or cost limit. The findings so far:")
if grounded(text):
return rung, text
return "findings only", findings_only() # never ask the model to try again
return "decline", "The writer is unavailable right now. Please try again in a few minutes."
random.seed(7)
for scenario, budget in [("normal", Budget(0.08, 6, 60)), ("429 storm", Budget(0.08, 6, 60)),
("ungrounded", Budget(0.08, 6, 60)), ("429 storm", Budget(0.08, 3, 60))]:
print(f"== {scenario} (max {budget.calls} calls)")
rung, text = answer(scenario, budget)
print(f" rung: {rung} | calls {budget.used} | USD {budget.spent:.4f} | {budget.clock:.1f}s")
print(" " + text.replace("\n", "\n "))
Run python3 fallbacks_demo.py. With the fixed random seed, you should see:
== normal (max 6 calls)
rung: normal | calls 1 | USD 0.0262 | 6.0s
Stockouts cost EUR 36.0k in penalties over 7 weeks.
== 429 storm (max 6 calls)
pro attempt 1: 429 RESOURCE_EXHAUSTED, waiting 0.3s
pro attempt 2: 429 RESOURCE_EXHAUSTED, waiting 0.3s
pro attempt 3: 429 RESOURCE_EXHAUSTED, next rung
rung: fallback model | calls 4 | USD 0.0197 | 9.6s
Stockouts cost EUR 36.0k in penalties over 7 weeks.
== ungrounded (max 6 calls)
rung: findings only | calls 1 | USD 0.0262 | 6.0s
I couldn't verify every figure, so here are the findings:
- penalties_eur: 36.0k
- stockout_weeks: 7
== 429 storm (max 3 calls)
pro attempt 1: 429 RESOURCE_EXHAUSTED, waiting 0.7s
pro attempt 2: 429 RESOURCE_EXHAUSTED, waiting 0.1s
pro attempt 3: 429 RESOURCE_EXHAUSTED, next rung
rung: budget stop | calls 3 | USD 0.0000 | 3.8s
I reached this question's time or cost limit. The findings so far:
- penalties_eur: 36.0k
- stockout_weeks: 7
Four scenarios, four rungs. The normal turn costs one Pro call. The 429 storm costs three failed Pro
attempts and one Flash call, and Maria still gets the right answer. The ungrounded reply is replaced by
the findings without a second call. In the last run the budget allows only three calls, so the turn
stops after the third failed attempt and shows what it has. Change the jitter to a fixed wait, or raise
attempts to 10, and watch the time grow.
What can go wrong
- Retrying permanent errors. A 400 or a safety block fails the same way every time. Retrying it burns quota and time. Retry only the codes on your list.
- Retries at every layer. SDK retries inside a tool retry inside an A2A retry multiply. The SRE book’s example turns one action into 64 attempts. Pick one layer for each kind of retry.
- A fallback with no limit. A fallback or repair that can repeat is a loop. Use one
temp:flag per rung. - A budget that only exists in the report. If nothing checks the budget during the turn, it only describes the past. Check it before each model call.
- Fixed timeouts that outlive the turn. A 300 second A2A timeout inside a 60 second turn keeps a specialist working on an answer nobody will read. Propagate the deadline.
- Counters in one instance’s memory. Like Part 9’s rate limit, a breaker kept in memory resets on restart and counts per instance. Use a shared store if it must be exact.
What code did we write in this part?
Every part ends with the same map of Acme’s build: which files exist so far, and the code this part added, explained line by line. The map grows with each part.
This part adds no code to Acme’s build. The one line it quotes from the build, the writer’s three retry attempts in router/app/writer.py, was already there, as were the number check, the kill switch and the usage ledger. The fallback model, the repair step, the circuit breaker, the budget plugin, deadline propagation and the fault-injection eval case are shown as illustrative code for the design, and the budget simulation is a standalone exercise.
Where does it live?
router/ the router agent: extract, route, write
app/agent.py P5,8 workflow; extract reads memory; App with compaction and resumability
app/app_utils/services.py P8 picks the session, artifact and memory services
app/fast_api_app.py P8 Runner with session, artifact and memory services
app/remote.py P6,7 A2A client over mTLS; metadata carries Sam's token
app/writer.py P5,8 writer reads memories and saves the conversation to Memory Bank
scripts/create_memory_bank.py P8 creates the Memory Bank and its topics, once
tests/eval/acb_metrics.py P10 code metrics: route_correct, grounded, golden_numbers
tests/eval/build_datasets.py P10 splits source cases into golden and held-out, seed 42
tests/eval/calibrate.py P10 compares judge scores with human labels (kappa)
tests/eval/datasets/golden_marketing.json P10 golden cases with expected route, target and numbers
tests/eval/eval_config.yaml P10 eval metrics, including the faithfulness judge
tests/eval/gate.py P10 thresholds per metric; exits 1 when one fails
tests/unit/test_router.py P10 unit tests; routing checked with one row per question type
marketing/ the marketing specialist, on BigQuery
app/agent.py P1,2,3,4 marketing agent with its skill in a SkillToolset
app/app_utils/a2a.py P6 A2A endpoint and agent card (1.0 and 0.3) for marketing
app/skills/marketing-kpis/SKILL.md P4 marketing know-how: which tool, definitions, story checks
app/skills/marketing-kpis/references/tables.md P4 tables, grain, measures and the join rule, read on demand
app/tools.py P1,2 three narrow tools: measures, unserved demand, SQL fallback
tests/unit/test_marketing.py P2 unit tests for the tools, with a fake context and database
supply/ the supply specialist, on Databricks
app/agent.py P3,7,8,9 supply agent and App with the confirmed replan tool
app/app_utils/a2a.py P6 A2A endpoint and agent card (1.0 and 0.3) for supply
app/skills/supply-catalog/SKILL.md P4 supply know-how, given only to the supply agent
app/tools.py P3,7,9 supply tools; the replan tool asks a human first
shared/ code all three services share
adk_common.py P3,6,9,11 plugins and redacted JSON logs, now with the usage ledger
auth_manager.py P7 auth manager callback: token or consent (off by default)
engines.py P3,7 run_databricks connects as the signed-in user
factcheck.py P5 ungrounded_numbers: finds figures not in the findings
registry.py P2 reads semantic.yaml: measure names, computable measures
routing.py P1 classify: picks the specialist in code, no model
safety.py P9 per-user token bucket and injection pattern scan
semantic.yaml P1,2 registry of measures, owners and published formulas
skill_source.py P4 loads a skill from disk or a pinned registry revision
sqlguard.py P3 guard_sql read-only allow-list; redact for log lines
tests/test_cost_report.py P11 unit tests for the cost maths
ops/ build, deploy and evaluation tooling
.cloudbuild/eval_gate.yaml P10 after staging: eval run, then gate.py
.cloudbuild/pr_checks.yaml P10,11 offline PR checks, cheapest first, ending with the mutants
env.sh.example P11 settings for every engine: ids, models, plugin switches, OTEL; no secrets
tools/cost_report.py P11 prices the ledger and reports cost per resolved question
tools/mutants/acb_mutate.py P10 pytest plugin that injects each mutant's fault
tools/pricing.json P11 list prices per model, kept out of the code
tools/run_mutants.py P10 runs the offline suites once per mutant
+ added in this part ~ changed in this part Pn = the parts that touched the file
The post names the gap plainly: the build does not yet have the budget plugin, the circuit breaker or deadline propagation.
What did we learn?
- Timeouts, rate limits and server errors are transient and may be retried; bad output, safety blocks, tool errors, ungrounded answers and remote agent failures each need their own response.
- A deterministic fallback is a response chosen in advance and written in code for each failure mode, never “ask the model again and hope”.
- The fallback ladder runs from a bounded retry with backoff and jitter, through a fallback model, one repair, an answer from tool results and a templated decline, down to a circuit breaker and the kill switch.
- An execution budget limits one request’s tokens, cost, model calls and time, and is checked before each model call while the turn runs.
- In ADK,
max_llm_callsis only a safety net; a plugin’sbefore_model_callbackenforces the real budget, and one deadline is passed down to every tool and specialist. - Fallbacks are tested by injecting faults in eval cases and checking the trace and usage ledger for the expected rung.
What comes next?
The system now fails in ways we chose: every failure mode has a rung, every turn has a budget, and every fallback has an eval case that proves it runs. That is what makes it safe to hand to people who did not build it. First, Part 11.75 explains what Gemini Enterprise is and untangles its vocabulary: connectors, skills, the two meanings of plugin, and sandbox-based agents. Then in Part 12, giving it to users, we publish the router to Gemini Enterprise, handle user consent, control which endpoints the agents may reach with Agent Gateway, and roll out in stages: shadow mode, a small group, then everyone.
Sources
- ADK: Runtime configuration (RunConfig)
- ADK: Types of callbacks
- ADK: Plugins
- ADK: Reflect and Retry plugin
- ADK: Gemini models
- ADK: Route between models (experimental, TypeScript)
- ADK: LLM agents (output_schema)
- ADK: Session state
- ADK Python source: invocation context (LlmCallsLimitExceededError)
- ADK Python source: RemoteA2aAgent
- Agent Runtime ADK app template (run_config)
- Google Gen AI SDK for Python: HttpRetryOptions
- Agent Platform: Retry strategy
- Agent Platform: Error code 429
- Agent Platform: Quotas and system limits
- Gemini API: Troubleshooting guide
- Gemini API: API errors
- Gemini API: Rate limits
- Gemini API: Generate content reference (finish reasons)
- Cloud Storage: Retry strategy
- Google SRE book: Addressing cascading failures
- Google SRE book: Handling overload
- Martin Fowler: CircuitBreaker
Opinions are my own. Every fact in this post comes from the public documentation linked above. Acme Consumer Brands and its data are fictional, and the costs, token counts and times are illustrative, not measurements.