aiml
SearchMy learning
Menu

Lesson 14 of 20Module 4

Inject tool failures with environment simulation

Inject docs-service failures with ADK environment simulation, replay the recorded responses, and test the limits of retry guidance and error rules.

Prerequisites

App plugins and the tool callback order from Guard model and tool calls with callbacks and plugins.

By the end, you can

  • Inject HTTP-style errors into one tool with EnvironmentSimulationConfig, ToolSimulationConfig and InjectionConfig, without editing the tool.
  • Measure how an agent behaves during an outage across repeated runs, and fix what it does with an instruction rule for error results.
  • Bound retries with a ReflectAndRetryToolPlugin subclass that recognizes injected errors, and choose between raising and answering when retries run out.
Runtime
Python 3.12+, a Gemini API key (or Ollama with gemma4:e2b)
Dependencies
google-adk 2.12.0; litellm for the Ollama alternative

Lab code, tests and fixtures (ZIP, 82 KB)Quiz check your understanding

Progress is saved in this browser.

The docs assistant reads pages from a docs service, and services go down. The question is what the agent does then: retry forever, give up politely, or answer from memory and cite a page it never read. You can’t wait for a real outage to find out, and you shouldn’t change the tool to fake one. ADK’s environment simulation intercepts tool calls in a plugin and returns an injected error or response instead of running the tool. We make read_page fail with a 503 for every page, then with a 404 for one page, and record what Gemini gemini-3.8-flash does. Without any rule for errors, one recorded outage run looked like this:

When you need this. Before a docs assistant goes live, you want to know what it says when the docs backend times out, a page was deleted or a dependency is slow. The same applies to any agent whose tools call a network service: you need the failure behavior tested as deliberately as the success path.

Recorded outage run on GeminiRecorded output (excerpt) · textFull output
# Run outage
> How do I make the assistant use a local model instead of Gemini?
call   list_pages({})
result list_pages -> {'pages': ['cli.md', 'install.md', 'troubleshooting.md']}
call   read_page({'path': 'cli.md'})
result read_page -> {'error_code': 503, 'error_message': 'Docs service unavailable.'}
call   read_page({'path': 'cli.md'})
result read_page -> {'error_code': 503, 'error_message': 'Docs service unavailable.'}
call   read_page({'path': 'cli.md'})
result read_page -> {'error_code': 503, 'error_message': 'Docs service unavailable.'}
call   read_page({'path': 'install.md'})
result read_page -> {'error_code': 503, 'error_message': 'Docs service unavailable.'}
call   read_page({'path': 'troubleshooting.md'})
result read_page -> {'error_code': 503, 'error_message': 'Docs service unavailable.'}
call   read_page({'path': 'cli.md'})
result read_page -> {'error_code': 503, 'error_message': 'Docs service unavailable.'}
answer I am currently unable to answer your question because the documentation service is unavailable (cli.md).
simulated.py · EnvironmentSimulationFactory.create_plugin · gemini-3.8-flash · recorded in recorded-runs.json
data flowreturnfailuredocs_assistantcalls read_pagegemini-3.8-flashnext callApp plugins, in list orderinjection fires?match_args, probabilitycallinjected errorerror_code 503yesread_pagenodocs/*.mdreadsRetryOnErrorCodeoptionalafter_tool_callbackpageresult or retry guidanceRecorded runsfailed readsmodel responsescites a pageraisedbaseline04yesnooutage68yesnooutage-with-error-rule13nonooutage-with-retry-plugin24noyesoutage-with-retry-limit-answer24yesnomissing-page-with-error-rule15yesnodata flowreturnfailuredocs_assistanttool callApp pluginsinjection fires?injectederror 503yesread_pagenoRetryOnErrorCodeoptionalafter_tool_callbackgemini-3.8-flashresultRecorded runsbaseline0 failed · cites yesoutage6 failed · cites yesoutage-with-error-rule1 failed · cites nooutage-with-retry-plugin2 failed · cites no · raisedoutage-with-retry-limit-answer2 failed · cites yesmissing-page-with-error-rule1 failed · cites yes
This drawing includes the optional retry plugin; only the two retry rows enable it, and baseline also omits simulation. The simulation plugin runs first in the tool-call path when configured. When an injection fires, its error becomes the tool's result and read_page never runs; otherwise it returns nothing and the real tool reads the page. The plugins' after-tool callbacks see both kinds of result, which is how the retry plugin can count injected errors. The table counts emitted error results; the call that raises has no result event. In the recorded runs, without a rule for error results, the agent kept calling read_page and still cited a page it had not read.

Lab setup. Download the complete lab ZIP, extract it and open a terminal in adk-environment-simulation/. Keep the bundled code, tests and fixtures together. Create the pinned environment before running the commands below:

Prepare the lab environmentTerminal · bash
python3.12 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements-lock.txt

1. Define the faults next to an unchanged agent

simulated.pySource · pythonDownload
"""Run the docs assistant against simulated docs-service failures with ADK environment simulation.

The tools stay unchanged. EnvironmentSimulationFactory.create_plugin() intercepts their calls
and returns an injected error or response instead of running them.
"""
import os
from pathlib import Path

from google.adk import Agent
from google.adk.apps import App
from google.adk.models.lite_llm import LiteLlm
from google.adk.plugins.reflect_retry_tool_plugin import ReflectAndRetryToolPlugin
from google.adk.runners import InMemoryRunner
from google.adk.tools.environment_simulation import EnvironmentSimulationFactory
from google.adk.tools.environment_simulation.environment_simulation_config import (
    EnvironmentSimulationConfig, InjectedError, InjectionConfig, ToolSimulationConfig)
from google.genai import types

MODEL = os.environ.get("DOCS_MODEL", "gemini-3.8-flash")  # or ollama_chat/gemma4:e2b
DOCS = Path(__file__).resolve().parent / "docs"
INSTRUCTION = ("Answer questions about the docs-assistant project from its docs only. Call list_pages, "
               "then read_page on the one page that answers. Reply in one sentence and end it with the "
               "page path in parentheses.")
# Tell the agent what each error result means; without it the model guesses.
ON_ERROR = (" A read_page result with error_code 404 means that page does not exist: try another page from "
            "list_pages. Any other error_code means the docs service failed: say so and do not answer from "
            "memory. Never cite a page you could not read.")


def list_pages() -> dict:
    """List the docs pages by path."""
    return {"pages": sorted(page.name for page in DOCS.glob("*.md"))}


def read_page(path: str) -> dict:
    """Return the Markdown of one docs page from the docs service, by the path list_pages returned."""
    page = DOCS / path
    if page.parent != DOCS or not page.is_file():
        return {"error": f"No page {path!r}. Call list_pages for valid paths."}
    return {"path": path, "content": page.read_text(encoding="utf-8")}


def fail(code: int, message: str, **options) -> InjectionConfig:
    return InjectionConfig(injected_error=InjectedError(injected_http_error_code=code, error_message=message), **options)


FAULTS = {
    "outage": [fail(503, "Docs service unavailable.")],
    "missing-page": [fail(404, "Page not found.", match_args={"path": "install.md"})],
    "flaky": [fail(503, "Docs service unavailable.", injection_probability=0.5, random_seed=1)],
}


class RetryOnErrorCode(ReflectAndRetryToolPlugin):
    """ReflectAndRetryToolPlugin reacts to exceptions; injected faults are results with error_code."""

    async def extract_error_from_result(self, *, tool, tool_args, tool_context, result):
        return result if isinstance(result, dict) and "error_code" in result else None


def build_app(fault: str | None = None, instruction: str = INSTRUCTION, retry: str | None = None) -> App:
    """fault: a FAULTS key. retry: None, "raise" (the plugin's default) or "answer"."""
    agent = Agent(
        name="docs_assistant",
        model=LiteLlm(model=MODEL) if MODEL.startswith("ollama_chat/") else MODEL,
        instruction=instruction,
        tools=[list_pages, read_page],
        generate_content_config=types.GenerateContentConfig(
            temperature=0.2, thinking_config=types.ThinkingConfig(thinking_level="low")),
    )
    plugins = []
    if fault:
        config = EnvironmentSimulationConfig(tool_simulation_configs=[
            ToolSimulationConfig(tool_name="read_page", injection_configs=FAULTS[fault])])
        plugins.append(EnvironmentSimulationFactory.create_plugin(config))
    if retry:
        plugins.append(RetryOnErrorCode(max_retries=2, throw_exception_if_retry_exceeded=retry == "raise"))
    return App(name="docs_assistant", root_agent=agent, plugins=plugins)


async def ask(app: App, question: str, events: list | None = None) -> list:
    """Run one question; append its events (tool calls, results, answer) to `events` as they arrive."""
    events = [] if events is None else events
    runner = InMemoryRunner(app=app)
    session = await runner.session_service.create_session(app_name=app.name, user_id="dev")
    message = types.Content(role="user", parts=[types.Part(text=question)])
    async for event in runner.run_async(user_id="dev", session_id=session.id, new_message=message):
        events.append(event)
    return events


def show(events: list) -> list[str]:
    lines = []
    for event in events:
        for part in (event.content.parts if event.content else []):
            if part.function_call:
                lines.append(f"call   {part.function_call.name}({part.function_call.args})")
            elif part.function_response:
                body = part.function_response.response
                lines.append(f"result {part.function_response.name} -> "
                             + (str(body) if "content" not in body else f"{body['path']} ({len(body['content'])} chars)"))
            elif part.text and not part.thought:
                lines.append(f"answer {part.text.strip()}")
    return lines

The tools list_pages and read_page are the same as in the other lessons. The faults live in FAULTS:

  • An InjectionConfig with injected_error returns {"error_code": ..., "error_message": ...} as the tool’s result. injected_response would return a fixed success payload instead.
  • match_args={"path": "install.md"} makes the injection fire only when the call’s arguments contain that pair. Every other page passes through to the real tool.
  • injection_probability and random_seed are for flaky behavior. The seed has a catch, covered below.

build_app wraps the faults in an EnvironmentSimulationConfig with one ToolSimulationConfig for read_page, and registers EnvironmentSimulationFactory.create_plugin(config) on the App. The factory also offers create_callback(config) for a single agent’s before_tool_callback. The plugin is the better fit for tests, because it applies to every agent in the App and is easy to leave out in production: the production App is the same build_app call with fault=None.

A seed that fixes every call. random_seed is reapplied on each call, so a seeded probability is all or nothing. Leave the seed out and repeat runs, or fail a fixed number of calls in your own callback.

A fault that hits the wrong calls. Without match_args, an injection applies to every call of the tool. Match on the arguments that identify the dependency you are failing.

2. Record the agent under each fault

Try one selected scenario first. With GOOGLE_API_KEY set, run this from the extracted lab directory:

Ask one question with an injected outageTerminal · bash
python run.py "How do I use a local model instead of Gemini?" --fault outage --retry answer

run.py builds the actual simulation App, includes ON_ERROR, and prints tool calls, results and the answer. --fault selects none, outage, missing-page or flaky; --retry selects none, raise or answer. The default is an outage with no retry plugin. The failure is injected locally, but the agent still makes fresh model requests. Loading a bare agent in adk run or adk web would omit the simulation and retry plugins. This launcher uses the programmatic Runner to keep them attached.

For the full comparison below, record.py makes many fresh model requests and overwrites the supplied JSON and text recordings. Keep a separate copy when reproducing it.

Terminal commandsTerminal · bash
python record.py

record.py asks the same question, how to use a local model instead of Gemini, under each configuration. The answer is in install.md. It runs the plain outage five more times, because one run can miss a failure that appears on another attempt.

recorded-runs.json: one row per configurationRecorded output · jsonDownload
{
  "baseline": {
    "read_page_failures": 0,
    "model_responses": 4,
    "cites_a_page": true,
    "raised": null
  },
  "outage": {
    "read_page_failures": 6,
    "model_responses": 8,
    "cites_a_page": true,
    "raised": null
  },
  "outage-with-error-rule": {
    "read_page_failures": 1,
    "model_responses": 3,
    "cites_a_page": false,
    "raised": null
  },
  "outage-with-retry-plugin": {
    "read_page_failures": 2,
    "model_responses": 4,
    "cites_a_page": false,
    "raised": "RuntimeError"
  },
  "outage-with-retry-limit-answer": {
    "read_page_failures": 2,
    "model_responses": 4,
    "cites_a_page": true,
    "raised": null
  },
  "missing-page-with-error-rule": {
    "read_page_failures": 1,
    "model_responses": 5,
    "cites_a_page": true,
    "raised": null
  }
}

Across the 6 plain outage runs, the agent called read_page between 4 and 18 times before it stopped, and 3 of the answers ended with a page citation such as (cli.md) although no page had been read. The answer text said the service was down, but anything that parses the citation, such as a UI that links to the source page, would show a source that was never read.

3. Tell the agent what an error result means

The instruction said nothing about errors, so the model had to guess. ON_ERROR gives it a rule: a 404 means try another page, any other error_code means the service failed, and never cite a page you couldn’t read.

Recorded run: outage with the error ruleRecorded output · textDownload
# Run outage-with-error-rule
> How do I make the assistant use a local model instead of Gemini?
call   list_pages({})
result list_pages -> {'pages': ['cli.md', 'install.md', 'troubleshooting.md']}
call   read_page({'path': 'cli.md'})
result read_page -> {'error_code': 503, 'error_message': 'Docs service unavailable.'}
answer The docs service failed, so I cannot provide an answer.

With the rule, the agent stopped after one failed read and cited nothing. The 404 run checks that the rule doesn’t make things worse when only one page is missing: the agent read another page and cited the page it actually read.

Recorded run: install.md returns 404Recorded output · textDownload
# Run missing-page-with-error-rule
> How do I make the assistant use a local model instead of Gemini?
call   list_pages({})
result list_pages -> {'pages': ['cli.md', 'install.md', 'troubleshooting.md']}
call   read_page({'path': 'cli.md'})
result read_page -> cli.md (231 chars)
call   read_page({'path': 'install.md'})
result read_page -> {'error_code': 404, 'error_message': 'Page not found.'}
call   read_page({'path': 'troubleshooting.md'})
result read_page -> troubleshooting.md (256 chars)
answer Set `OLLAMA_API_BASE` to point to a running Ollama server (troubleshooting.md).

No rule for error results. During the outage the agent called read_page again and again, and some answers cited a page it never read. Add an instruction rule for each error class, and test it under the same fault.

4. Bound retries with ReflectAndRetryToolPlugin

An instruction cannot enforce a call limit. In its exception mode, ReflectAndRetryToolPlugin stops on an exhausted failure: it counts consecutive failures per tool in an invocation, replaces each failed result with guidance that asks the model to reflect and try something different, and raises when the next failure exceeds max_retries. Out of the box it reacts to exceptions raised by tools. An injected error is a normal result, so RetryOnErrorCode overrides extract_error_from_result to treat any result with error_code as a failure, the hook the plugin’s docstring describes.

Recorded run: retry plugin with its default settingsRecorded output (excerpt) · textFull output
# Run outage-with-retry-plugin
> How do I make the assistant use a local model instead of Gemini?
call   list_pages({})
result list_pages -> {'pages': ['cli.md', 'install.md', 'troubleshooting.md']}
call   read_page({'path': 'cli.md'})
result read_page -> {'response_type': 'ERROR_HANDLED_BY_REFLECT_AND_RETRY_PLUGIN', 'error_type': 'ToolError', 'error_details': "{'error_code': 503, 'e ...
call   read_page({'path': 'install.md'})
result read_page -> {'response_type': 'ERROR_HANDLED_BY_REFLECT_AND_RETRY_PLUGIN', 'error_type': 'ToolError', 'error_details': "{'error_code': 503, 'e ...
call   read_page({'path': 'troubleshooting.md'})
raised RuntimeError: Error in plugin 'reflect_retry_tool_plugin' during 'after_tool_callback' callback: {'error_code': 503, 'error_message': 'Docs ser ...

The summary counts emitted error results, not attempts: the raising call produces no tool-result event. With the default throw_exception_if_retry_exceeded=True, the third failure raised a RuntimeError out of run_async, and the user got no answer at all. That default suits a batch job that should fail loudly. For a chat agent, retry="answer" sets it to False: the plugin returns a “retries exceeded” result. The model can answer or attempt another call.

Recorded run: retry plugin that answers when retries run outRecorded output (excerpt) · textFull output
# Run outage-with-retry-limit-answer
> How do I make the assistant use a local model instead of Gemini?
call   list_pages({})
result list_pages -> {'pages': ['cli.md', 'install.md', 'troubleshooting.md']}
call   read_page({'path': 'cli.md'})
result read_page -> {'response_type': 'ERROR_HANDLED_BY_REFLECT_AND_RETRY_PLUGIN', 'error_type': 'ToolError', 'error_details': "{'error_code': 503, 'e ...
call   read_page({'path': 'install.md'})
result read_page -> {'response_type': 'ERROR_HANDLED_BY_REFLECT_AND_RETRY_PLUGIN', 'error_type': 'ToolError', 'error_details': "{'error_code': 503, 'e ...
answer I am unable to retrieve the documentation at the moment because the docs service is currently unavailable (cli.md).

This recorded answer stopped after the retry-exceeded message, but still ended with (cli.md). With exception mode off, the plugin returns guidance after exhaustion on every further failure; it does not prevent another tool call. The recorded model chose to stop. Use exception mode or a runner limit when execution must stop, and keep the error rule for answer behavior.

Retry plugin that raises. With default settings, ReflectAndRetryToolPlugin raised RuntimeError after its retries, ending the run without an answer. Set throw_exception_if_retry_exceeded=False for interactive agents, or catch the error in the caller.

Retry guidance treated as a call limit. With exception mode off, exhaustion returns a message and another call can still run. Use exception mode to stop on failure; inspect model behavior separately.

Retry plugin that never fires. The plugin looks for exceptions; an injected or real error that comes back as a result is not one. Override extract_error_from_result.

These runs show this small sample: 3 of the 6 outage runs cited an unread page and the rest didn’t, so any single run could have hidden the problem.

How injection and retry plugins handle a result

For each tool call, ADK runs the plugins’ before_tool_callback methods in list order, and the first one that returns a value becomes the tool’s result: the real tool and the agent’s own callbacks are skipped. The simulation plugin returns None for any tool it has no config for, and for any call no injection matches, so the real tool runs. When an injection matches, match_args is checked first, then the probability, then injected_latency_seconds is awaited, and the error dict is returned.

The after-tool step does not care where the result came from. ADK runs the plugins’ after_tool_callback methods on injected results exactly as on real ones, which is why RetryOnErrorCode sees the 503s. A test replays the recorded turns to check it.

The randomness is simpler than it looks. The engine keeps one random.Random and, when an injection has a random_seed, seeds it again before every call. Every call then draws the same number, so a seeded 50% injection either fires on every call or on none. In the test, seed 1 fired 20 times out of 20 and seed 2 never fired. To test recovery from a flaky tool, run an unseeded injection many times and look at the distribution, or write a callback that fails a fixed number of calls.

Test the faults without a model

test_simulated.py replays the recorded model turns through the same App, with the real simulation plugin, retry plugin and tools, and calls the simulation engine directly for the seed and match_args checks.

Terminal commandsTerminal · bash
python -m unittest -v test_simulated.py
Recorded test outputRecorded output · textDownload
test_a_random_seed_is_reapplied_on_every_call (test_simulated.Faults.test_a_random_seed_is_reapplied_on_every_call) ... ok
test_match_args_fails_only_the_matching_page (test_simulated.Faults.test_match_args_fails_only_the_matching_page) ... ok
test_non_throwing_exhaustion_returns_guidance_without_blocking_more_calls (test_simulated.Faults.test_non_throwing_exhaustion_returns_guidance_without_blocking_more_calls) ... ok
test_the_error_rule_stops_after_one_failure_and_cites_nothing (test_simulated.Faults.test_the_error_rule_stops_after_one_failure_and_cites_nothing) ... ok
test_the_outage_replays_the_recorded_tool_results (test_simulated.Faults.test_the_outage_replays_the_recorded_tool_results) ... ok
test_the_retry_plugin_raises_once_retries_are_used_up (test_simulated.Faults.test_the_retry_plugin_raises_once_retries_are_used_up) ... ok
test_the_retry_plugin_sees_results_that_the_simulator_injected (test_simulated.Faults.test_the_retry_plugin_sees_results_that_the_simulator_injected) ... ok
test_without_an_error_rule_the_agent_sometimes_cites_a_page_it_never_read (test_simulated.Faults.test_without_an_error_rule_the_agent_sometimes_cites_a_page_it_never_read) ... ok

----------------------------------------------------------------------
Ran 8 tests in 0.350s

OK

All 8 tests pass.

Read the test and recording scripts
test_simulated.pySource · pythonDownload
"""Replay the recorded Gemini turns against the same injected faults, offline.

Run: python -m unittest -v test_simulated.py
The model is replaced by its recorded turns; the environment simulation plugin, the retry
plugin and the tools are real.
"""
import asyncio
import json
import logging
import unittest
import warnings
from pathlib import Path
from types import SimpleNamespace

warnings.filterwarnings("ignore")
logging.disable(logging.CRITICAL)

from google.adk.apps import App  # noqa: E402
from google.adk.models.base_llm import BaseLlm  # noqa: E402
from google.adk.models.llm_response import LlmResponse  # noqa: E402
from google.adk.tools import FunctionTool  # noqa: E402
from google.adk.tools.environment_simulation.environment_simulation_config import (  # noqa: E402
    EnvironmentSimulationConfig, ToolSimulationConfig)
from google.adk.tools.environment_simulation.environment_simulation_engine import EnvironmentSimulationEngine  # noqa: E402
from google.genai import types  # noqa: E402

import record  # noqa: E402
import simulated  # noqa: E402

RECORD = json.loads(Path(__file__).with_name("recorded-runs.json").read_text(encoding="utf-8"))
READ_PAGE = FunctionTool(simulated.read_page)


class ReplayLlm(BaseLlm):
    turns: list

    async def generate_content_async(self, llm_request, stream=False):
        yield LlmResponse(content=types.Content.model_validate(self.turns.pop(0)))


def replay(run: str):
    """Run the recorded model turns of one run through the same app; return its trail and error."""
    options = dict(record.RUNS[run])
    app = simulated.build_app(**options)
    model = ReplayLlm(model="replay", turns=list(RECORD["runs"][run]["model_turns"]))
    app = App(name=app.name, root_agent=app.root_agent.clone(update={"model": model}), plugins=app.plugins)
    events, error = [], None
    try:
        asyncio.run(simulated.ask(app, RECORD["question"], events))
    except RuntimeError as exc:
        error = exc
    return simulated.show(events), error


def engine(*injections) -> EnvironmentSimulationEngine:
    return EnvironmentSimulationEngine(EnvironmentSimulationConfig(tool_simulation_configs=[
        ToolSimulationConfig(tool_name="read_page", injection_configs=list(injections))]))


def simulate(sim, path):
    return asyncio.run(sim.simulate(READ_PAGE, {"path": path}, None))


def setUpModule():
    warnings.simplefilter("ignore")  # unittest resets the filter; ADK warns about experimental features


class Faults(unittest.TestCase):
    def test_the_outage_replays_the_recorded_tool_results(self):
        trail, _ = replay("outage")
        self.assertEqual(trail, RECORD["runs"]["outage"]["trail"])
        self.assertEqual(sum("error_code" in line for line in trail), RECORD["runs"]["outage"]["read_page_failures"])

    def test_without_an_error_rule_the_agent_sometimes_cites_a_page_it_never_read(self):
        runs = [RECORD["runs"]["outage"], *RECORD["outage_repeats"]]
        self.assertTrue(any(r["cites_a_page"] for r in runs))
        self.assertGreater(min(r["read_page_failures"] for r in runs), 1)

    def test_the_error_rule_stops_after_one_failure_and_cites_nothing(self):
        run = RECORD["runs"]["outage-with-error-rule"]
        self.assertEqual((run["read_page_failures"], run["cites_a_page"]), (1, False))
        trail, _ = replay("outage-with-error-rule")
        self.assertEqual(trail, run["trail"])

    def test_the_retry_plugin_raises_once_retries_are_used_up(self):
        trail, error = replay("outage-with-retry-plugin")
        self.assertIn("reflect_retry_tool_plugin", str(error))
        self.assertEqual(sum("ERROR_HANDLED_BY_REFLECT_AND_RETRY_PLUGIN" in line for line in trail), 2)

    def test_the_retry_plugin_sees_results_that_the_simulator_injected(self):
        trail, error = replay("outage-with-retry-limit-answer")
        self.assertIsNone(error)
        self.assertTrue(any("ERROR_HANDLED_BY_REFLECT_AND_RETRY_PLUGIN" in line for line in trail))

    def test_non_throwing_exhaustion_returns_guidance_without_blocking_more_calls(self):
        plugin = simulated.RetryOnErrorCode(max_retries=2, throw_exception_if_retry_exceeded=False)
        async def failures():
            return [await plugin.after_tool_callback(
                tool=READ_PAGE, tool_args={"path": "install.md"},
                tool_context=SimpleNamespace(invocation_id="retry-demo"),
                result={"error_code": 503, "error_message": "Unavailable"}) for _ in range(4)]
        results = asyncio.run(failures())
        self.assertEqual(len(results), 4)
        self.assertEqual(results[2], results[3])
        self.assertIn("exceeded", str(results[3]).lower())

    def test_match_args_fails_only_the_matching_page(self):
        sim = engine(*simulated.FAULTS["missing-page"])
        self.assertEqual(simulate(sim, "install.md"), {"error_code": 404, "error_message": "Page not found."})
        self.assertIsNone(simulate(sim, "cli.md"))  # None: the real tool runs

    def test_a_random_seed_is_reapplied_on_every_call(self):
        always = engine(*simulated.FAULTS["flaky"])  # probability 0.5, random_seed=1
        self.assertEqual(sum(simulate(always, "cli.md") is not None for _ in range(20)), 20)
        never = engine(simulated.fail(503, "Docs service unavailable.", injection_probability=0.5, random_seed=2))
        self.assertEqual(sum(simulate(never, "cli.md") is not None for _ in range(20)), 0)


class SelectedScenario(unittest.TestCase):
    def test_launcher_attaches_error_rule_and_selected_plugins(self):
        from unittest.mock import AsyncMock, patch
        import run
        marker = object()
        args = SimpleNamespace(question="chosen", fault="missing-page", retry="answer")
        with patch.object(simulated, "build_app", return_value=marker) as build, patch.object(simulated, "ask", new_callable=AsyncMock) as ask:
            asyncio.run(run.main(args))
        build.assert_called_once_with(fault="missing-page", instruction=simulated.INSTRUCTION + simulated.ON_ERROR, retry="answer")
        self.assertIs(ask.await_args.args[0], marker)
        self.assertEqual(ask.await_args.args[1], "chosen")


if __name__ == "__main__":
    unittest.main()
record.pySource · pythonDownload
"""Record the docs assistant on Gemini under each injected fault.

Run from this folder with GOOGLE_API_KEY set: python record.py
Writes recorded-runs.json (questions, model turns, tool results, answers) and run-output.txt.
"""
import asyncio
import json
import warnings
from pathlib import Path

warnings.filterwarnings("ignore")

from google.adk.apps import App  # noqa: E402

import simulated  # noqa: E402

HERE = Path(__file__).resolve().parent
QUESTION = "How do I make the assistant use a local model instead of Gemini?"
FIXED = simulated.INSTRUCTION + simulated.ON_ERROR
RUNS = {
    "baseline": dict(fault=None),
    "outage": dict(fault="outage"),
    "outage-with-error-rule": dict(fault="outage", instruction=FIXED),
    "outage-with-retry-plugin": dict(fault="outage", retry="raise"),
    "outage-with-retry-limit-answer": dict(fault="outage", retry="answer"),
    "missing-page-with-error-rule": dict(fault="missing-page", instruction=FIXED),
}

async def run(options: dict) -> dict:
    turns = []

    def keep_turn(callback_context, llm_response):
        if llm_response.content:
            content = llm_response.content.model_dump(mode="json", exclude_none=True)
            for part in content.get("parts", []):
                part.pop("thought_signature", None)
            turns.append(content)

    app = simulated.build_app(**options)
    app = App(name=app.name, root_agent=app.root_agent.clone(update={"after_model_callback": keep_turn}),
              plugins=app.plugins)
    events, error = [], None
    try:
        await simulated.ask(app, QUESTION, events)
    except Exception as exc:  # a plugin may end the run by raising
        error = f"{type(exc).__name__}: {exc}"
    lines = simulated.show(events)
    answer = next((l[len("answer "):] for l in reversed(lines) if l.startswith("answer ")), None)
    return {"options": {k: (v == FIXED and "INSTRUCTION + ON_ERROR" or v) for k, v in options.items()},
            "model_turns": turns, "trail": lines, "error": error, "answer": answer,
            "read_page_failures": sum("error_code" in l for l in lines if l.startswith("result read_page")),
            "cites_a_page": bool(answer and answer.rstrip(".").endswith(".md)"))}


async def main() -> None:
    record = {"question": QUESTION, "model": simulated.MODEL, "runs": {}}
    output = []
    for name, options in RUNS.items():
        r = record["runs"][name] = await run(options)
        output.append("\n".join([f"# Run {name}", f"> {QUESTION}", *r["trail"]]
                                + ([f"raised {r['error']}"] if r["error"] else [])) + "\n")
    # The same outage five more times: how often the agent cites a page it never read.
    repeats = [await run(RUNS["outage"]) for _ in range(5)]
    record["outage_repeats"] = [{"read_page_failures": r["read_page_failures"], "cites_a_page": r["cites_a_page"],
                                 "answer": r["answer"]} for r in repeats]
    output.append("# Outage repeated 5 times: read_page failures, cites a page\n" + "\n".join(
        f"{r['read_page_failures']} failures, cites a page: {r['cites_a_page']}  | {r['answer']}" for r in repeats) + "\n")
    (HERE / "recorded-runs.json").write_text(json.dumps(record, indent=2) + "\n")
    (HERE / "run-output.txt").write_text("\n".join(output))
    print("\n".join(output))


if __name__ == "__main__":
    asyncio.run(main())

You want 30% of read_page calls to fail, so you set injection_probability=0.3 and random_seed=42 to make the test repeatable.

With injection_probability=0.3 and random_seed=42 on read_page, what does a run of the docs assistant see?

Choose one answer, then check your reasoning.

Read the explanation

To test a flaky dependency, repeat unseeded runs and look at the rates, or fail a fixed number of calls in your own callback.

References

Official sources checked October 21, 2026, against google-adk 2.12.0.

Written by Arjun Prabhulal.

Module 4

Evaluate, trace and apply guardrails