aiml
SearchMy learning
Menu

Lesson 3 of 20Module 1

Tune a run with RunConfig and read the event loop

Set Google ADK 2.0 RunConfig per run to tag events, cap model calls and history, and time out the model, then trace when the Runner commits state.

Prerequisites

Python async functions, and the Runner and event stream from the Building Agents with Google ADK 2.0 course.

By the end, you can

  • Pass a RunConfig per run to tag events with custom_metadata and bound model calls with max_llm_calls and http_options.
  • Cap the history a run loads with get_session_config, and choose a cap that keeps tool calls paired.
  • Explain when a state write becomes visible to the tool, to the caller and to other readers of the session.
Runtime
Python 3.12+, a Gemini API key (or Ollama with gemma4:e2b)
Dependencies
google-adk 2.12.0; litellm 1.102.4 for the Ollama alternative

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

Progress is saved in this browser.

Runner.run_async accepts an optional RunConfig: settings for that one run, separate from the agent’s definition. A server can give each request its own limits, tags and timeouts without rebuilding the agent. Here you give the docs assistant a RunConfig for each of three questions in one session: a request ID carried by the agent’s events, a cap on model calls, an HTTP timeout and retry policy for the model client, and a cap on how much session history a run loads. The tool also writes session state, and a log line after each event shows the moment the Runner commits that write. These runs were recorded on Gemini gemini-3.8-flash.

When you need this. A docs portal serves hundreds of developers, and some keep one chat open all week. Each request needs a request ID you can find in the logs, a hard stop if a tool loop runs away, a timeout shorter than the portal’s own, and a bound on the history sent to the model so a week-old session does not cost more on every turn. All four are per-run settings, and getting the history cap wrong breaks the run in a way that is easy to miss.

Original recording. The saved output below uses the earlier fictional shipctl scenario. Current lab instructions and inputs use docuctl; this preserved output is not a recording of those renamed inputs.

Recorded first requestRecorded output · textDownload
req-1: How do I roll back production?
  model  call with 1 contents
  event call   state_delta={} stored[last_page]=None metadata={'request_id': 'req-1'}
  tool   request_id=req-1 state[last_page]='rollback' stored[last_page]=None
  event result state_delta={'last_page': 'rollback'} stored[last_page]='rollback' metadata={'request_id': 'req-1'}
  model  call with 3 contents
  event answer state_delta={} stored[last_page]='rollback' metadata={'request_id': 'req-1'}
  answer: Run `shipctl rollback --env production` to roll back to the previous release. To roll back to a specific release, specify `--to` (for example, `shipctl rollback --env production --to r41`). Note that database migrations are not rolled back.
tune.py · Runner.run_async(run_config=...) · gemini-3.8-flash · recorded in recorded-runs.json
data flowreturnartifact hand-offfailuretune.pyask(), run_config()Runnermax_llm_calls=4custom_metadataget_session_confighttp_optionsmessageeventsgemini-3.8-flashcontentsreplyread_pagewrites state[last_page]callresult, state_deltahistory cut400 INVALID_ARGUMENT1 ms timeoutTimeoutErrorInMemorySessionServicecommitted events and stateappend eventrecent eventsRecorded req-1 · when last_page reached the storeevent callno state changestore holds Nonetooltool_context.state shows 'rollback'store holds Noneevent resultstate_delta {'last_page': 'rollback'}store holds 'rollback'event answerno state changestore holds 'rollback'Recorded contents per model call, to scalereq-11req-13req-25req-27req-3 capped5data flowreturnartifact hand-offfailuretune.pymessageeventsRunnermax_llm_callscustom_metadataget_session_confighttp_optionsGeminicontentsreplyread_pagewrites statecalldeltahistory cut400timeoutTimeoutErrorsession servicecommitted stateappendrecent eventsRecorded req-1 commitevent callstore holds Nonetool sees 'rollback'store holds Noneevent resultstore holds 'rollback'event answerstore holds 'rollback'Contents per model callreq-11req-13req-25req-27req-35
Three requests in one session with a RunConfig on each run. The tool's write to last_page is visible to the tool at once and reaches the session store only when the Runner appends the function response event. The bars count the contents each model call received: the history grows with every turn until req-3 caps it with num_recent_events=4 (dashed bar), and the model then misses the first question. The two failures are recorded from the same RunConfig fields.

Lab setup. Download the complete lab ZIP, extract it and open a terminal in adk-run-config/. 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. Tag agent events with a request ID

tune.py defines the docs assistant inline, with two additions for this lesson: read_page writes last_page to session state, and a before_model_callback logs how many contents each model call receives.

tune.pySource · pythonDownload
"""Run the docs assistant with a tuned RunConfig and show when the runner commits state.

Run from this folder with GOOGLE_API_KEY set: python tune.py
Set DOCS_MODEL=ollama_chat/gemma4:e2b to run Gemma locally through LiteLlm.
"""
import asyncio
import os
from pathlib import Path

from google.adk import Agent, Runner
from google.adk.agents import RunConfig
from google.adk.models.lite_llm import LiteLlm
from google.adk.sessions import InMemorySessionService
from google.adk.sessions.base_session_service import GetSessionConfig
from google.adk.tools.tool_context import ToolContext
from google.genai import types

MODEL = os.environ.get("DOCS_MODEL", "gemini-3.8-flash")
DOCS = Path(__file__).resolve().parent / "docs"
APP, USER = "docs_assistant", "dev-1"
LOG: list[str] = []  # what the tool and the model callback saw, in order


async def read_page(page: str, tool_context: ToolContext) -> dict:
    """Read one page of the docuctl documentation.

    Args:
        page: The page name: config, deploy, rollback or tokens.
    """
    if page not in {"config", "deploy", "rollback", "tokens"}:
        return {"error": f"No page {page!r}."}
    tool_context.state["last_page"] = page  # recorded in this event's state_delta, not yet in the store
    service = tool_context.get_invocation_context().session_service
    stored = await service.get_session(app_name=APP, user_id=USER, session_id=tool_context.session.id)
    LOG.append(f"  tool   request_id={tool_context.custom_metadata.get('request_id')} "
               f"state[last_page]={tool_context.state.get('last_page')!r} "
               f"stored[last_page]={stored.state.get('last_page')!r}")
    path = DOCS / f"{page}.md"
    return {"page": page, "text": path.read_text(encoding="utf-8")} if path.is_file() else {"error": f"No page {page!r}."}


def count_history(callback_context, llm_request) -> None:
    """Log how many contents each model call receives; returning None lets the call run."""
    LOG.append(f"  model  call with {len(llm_request.contents)} contents")


root_agent = Agent(
    name=APP,
    model=LiteLlm(model=MODEL) if MODEL.startswith("ollama_chat/") else MODEL,
    instruction="""You answer questions from developers about docuctl, the team's deploy CLI.
Read the relevant page with read_page before you answer, and answer only from it.
Keep the answer under 40 words.""",
    tools=[read_page],
    before_model_callback=count_history,
    generate_content_config=types.GenerateContentConfig(
        temperature=0.2, thinking_config=types.ThinkingConfig(thinking_level="low")),
)


def run_config(request_id: str, recent_events: int | None = None, timeout_ms: int = 60_000) -> RunConfig:
    return RunConfig(
        max_llm_calls=4,  # one question needs two calls: the tool call and the answer
        custom_metadata={"request_id": request_id},  # copied onto every event of this invocation
        get_session_config=GetSessionConfig(num_recent_events=recent_events) if recent_events else None,
        http_options=types.HttpOptions(timeout=timeout_ms, retry_options=types.HttpRetryOptions(attempts=3)),
    )


async def ask(runner: Runner, session_id: str, text: str, config: RunConfig) -> list:
    """Run one turn; after each event, log its state delta and what the session service holds."""
    message = types.Content(role="user", parts=[types.Part(text=text)])
    events = []
    async for event in runner.run_async(user_id=USER, session_id=session_id, new_message=message, run_config=config):
        events.append(event)
        stored = await runner.session_service.get_session(app_name=APP, user_id=USER, session_id=session_id)
        kind = ("error" if event.error_code else "call" if event.get_function_calls()
                else "result" if event.get_function_responses() else "answer")
        LOG.append(f"  event {kind:<6} state_delta={dict(event.actions.state_delta)} "
                   f"stored[last_page]={stored.state.get('last_page')!r} metadata={event.custom_metadata}"
                   + (f" error_code={event.error_code}" if event.error_code else ""))
    return events


async def main() -> None:
    runner = Runner(app_name=APP, agent=root_agent, session_service=InMemorySessionService())
    session = await runner.session_service.create_session(app_name=APP, user_id=USER)
    turns = [("req-1", "How do I roll back production?", None),
             ("req-2", "Which page covers CI tokens?", None),
             ("req-3", "What was my first question?", 4)]
    try:
        for request_id, text, recent in turns:
            LOG.append(f"{request_id}: {text}" + (f"  (num_recent_events={recent})" if recent else ""))
            events = await ask(runner, session.id, text, run_config(request_id, recent))
            LOG.append(f"  answer: {events[-1].content.parts[-1].text}")
    finally:
        await runner.close()
    print("\n".join(LOG))


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

custom_metadata={"request_id": ...} is copied onto the agent’s events, and the session stores it with the event, so a request ID from your HTTP layer follows the run into the session history. The tool reads the same dict as tool_context.custom_metadata, which lets a tool tag its own logs or downstream calls with the request ID.

From the extracted lab directory, with GOOGLE_API_KEY available in your shell, run the three-turn experiment:

Run the configured event loopTerminal · bash
python tune.py

Compare each req-1, req-2 and req-3 section with its model, tool and event lines. Because the Python caller creates the session service and passes a distinct RunConfig to each runner.run_async call, run python tune.py directly rather than adk run.

2. Bound model calls and model-call time

max_llm_calls=4 stops a run that keeps calling the model; a question with one tool call needs two calls. The default is 500, the ADK_MAX_LLM_CALLS environment variable overrides it, and adk api_server --max_llm_calls sets it for every run the server starts.

http_options goes to the model client on each call. timeout is in milliseconds, and HttpRetryOptions(attempts=3) allows the original request plus two retries for retryable HTTP errors. Pick the timeout from your caller’s deadline: each attempt can take up to the timeout, and a question with one tool call makes two model calls in sequence.

A model-call timeout that is too short. http_options.timeout=1 made the model call raise TimeoutError; ADK yielded an error event with error_code=TimeoutError before the run raised. Set the timeout from the caller’s deadline, and handle the error event or exception as a failed run.

3. Cap the history a run loads

Without a cap, the runner loads the whole session and sends all of it to the model. The recorded calls received 1, 3, 5, 7 contents over the first two requests: each turn added a question, a call, a result and an answer. For req-3, get_session_config=GetSessionConfig(num_recent_events=4) loads only the last four events:

Recorded capped requestRecorded output · textDownload
req-3: What was my first question?  (num_recent_events=4)
  model  call with 5 contents
  event answer state_delta={} stored[last_page]='tokens' metadata={'request_id': 'req-3'}
  answer: Your first question was: "Which page covers CI tokens?"

The model call received 5 contents instead of the 9 a full history would have had, and the answer was wrong: the first question was about rollback, and the model never saw it. The cap filters what is loaded; the events stay in the store. after_timestamp filters by time instead.

A history cap that splits a tool call. With num_recent_events=3, the loaded history began with the model’s function call, without the user turn before it. Gemini answered 400 INVALID_ARGUMENT: a function call turn must come right after a user turn or a function response. Do not assume a fixed number of events per turn: tool use changes it. Keep complete turns, or use event compaction from the context module instead of a blind event-count cap.

A cap that hides what the model needs. With num_recent_events=4 the run succeeded, but the answer named the second question as the first. Cap only where older turns are not needed, and keep facts the agent must remember in state, not in history.

Recorded failuresRecorded output · textDownload
# the same session, capped at 3 events
  model  call with 4 contents
  event error  state_delta={} stored[last_page]='tokens' metadata={'request_id': 'req-3'} error_code=INVALID_ARGUMENT
  raised google.genai.errors.ClientError: 400 INVALID_ARGUMENT. {'error': {'code': 400, 'message': 'Please ensure that function call turn comes immediately after a user turn or after a function response turn.', 'status': 'INVALID_ARGUMENT'}}

# a 1 ms model-call timeout
req-1: How do I roll back production?  (timeout=1 ms)
  model  call with 1 contents
  event error  state_delta={} stored[last_page]=None metadata={'request_id': 'req-1'} error_code=TimeoutError
  raised builtins.TimeoutError: 

The 400 error in full, as the recording kept it:

recorded-runs.json: the history-cap errorRecorded output · textDownload
google.genai.errors.ClientError: 400 INVALID_ARGUMENT. {'error': {'code': 400, 'message': 'Please ensure that function call turn comes immediately after a user turn or after a function response turn.', 'status': 'INVALID_ARGUMENT'}}

4. Watch when state commits

Read the first request’s log again, line by line. When the call event arrived, the store had no last_page. Inside the tool, tool_context.state["last_page"] already returned 'rollback' while the store, fetched through the same session service, still held None. When the function response event reached tune.py, its state_delta carried the change and the store already held 'rollback'.

How the Runner commits each event

The Runner drives the agent as a loop of yield, commit and resume. The agent code, here the LLM flow running read_page, yields an event and pauses. The Runner appends the event to the session service, which applies its state_delta to the stored state, and only then passes the event to the caller and lets the agent continue. So a caller that reacts to an event can trust that the store already reflects it.

Inside one step the rule differs. A write to tool_context.state lands in the current context and in the pending state_delta at once, so code in the same invocation reads its own write before it is committed: the ADK docs call this a dirty read. Anything that reads the store, such as another request, a dashboard or a second process, sees the value only after the event is appended. If a tool’s write must survive a crash, it is not committed until that event is, which is the subject of the next lesson.

get_session_config is applied when the runner loads the session at the start of the run. It counts events, not turns, which is why the history cap in step 3 can split a tool call.

Test the run config offline

test_tune.py replays the recorded model replies through the real Runner, session service and tool, and keeps every LlmRequest the replay model receives. That is how the tests check the request side without a model: the number of contents, the first content after a cap, and the http_options that reached the request.

Terminal commandTerminal · bash
python -m unittest -v test_tune.py
Recorded test outputRecorded output · textDownload
test_a_cap_of_3_starts_the_history_at_a_function_call (test_tune.RunConfigAndEventLoop.test_a_cap_of_3_starts_the_history_at_a_function_call) ... ok
test_a_page_path_is_rejected_before_state_or_file_access (test_tune.RunConfigAndEventLoop.test_a_page_path_is_rejected_before_state_or_file_access) ... ok
test_custom_metadata_is_stamped_on_every_stored_event (test_tune.RunConfigAndEventLoop.test_custom_metadata_is_stamped_on_every_stored_event) ... ok
test_num_recent_events_limits_the_history_the_model_receives (test_tune.RunConfigAndEventLoop.test_num_recent_events_limits_the_history_the_model_receives) ... ok
test_state_commits_when_the_runner_appends_the_event (test_tune.RunConfigAndEventLoop.test_state_commits_when_the_runner_appends_the_event) ... ok
test_the_http_timeout_reaches_the_model_request (test_tune.RunConfigAndEventLoop.test_the_http_timeout_reaches_the_model_request) ... ok

----------------------------------------------------------------------
Ran 6 tests in 0.280s

OK

All 6 tests pass.

Read the test and recording scripts
test_tune.pySource · pythonDownload
"""Replay the recorded model output through tune.py's runner, offline.

Run: python -m unittest -v test_tune.py
The model is replaced by its recorded responses; the Runner, RunConfig handling, session service and tool are real.
"""
import asyncio
import json
import logging
import unittest
import warnings
from pathlib import Path
from unittest import mock

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

from google.adk import Runner  # 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.sessions import InMemorySessionService  # noqa: E402
from google.genai import types  # noqa: E402

import tune  # noqa: E402

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


class ReplayLlm(BaseLlm):
    """Yields one recorded response per call and keeps each request; raises when the recording ends."""
    turns: list
    requests: list = []

    async def generate_content_async(self, llm_request, stream=False):
        self.requests.append(llm_request)
        if not self.turns:
            raise RuntimeError("no recorded response for this call")
        yield LlmResponse(content=types.Content.model_validate(self.turns.pop(0)["content"]))


def replay(name: str):
    rec = RECORD[name]
    model = ReplayLlm(model="replay", turns=list(rec["model_turns"]), requests=[])
    runner = Runner(app_name=tune.APP, agent=tune.root_agent.clone(update={"model": model}), session_service=InMemorySessionService())

    async def go():
        tune.LOG.clear()
        session = await runner.session_service.create_session(app_name=tune.APP, user_id=tune.USER)
        error = None
        try:
            for request_id, text, recent, timeout_ms in rec["turns"]:
                await tune.ask(runner, session.id, text, tune.run_config(request_id, recent, timeout_ms))
        except RuntimeError as exc:
            error = exc
        finally:
            await runner.close()
        stored = await runner.session_service.get_session(app_name=tune.APP, user_id=tune.USER, session_id=session.id)
        return model.requests, stored, error

    return asyncio.run(go())


def recorded(name: str, prefix: str) -> list:
    return [line for line in RECORD[name]["log"] if line.startswith(prefix)]


class RunConfigAndEventLoop(unittest.TestCase):
    def setUp(self):
        warnings.simplefilter("ignore")

    def test_a_page_path_is_rejected_before_state_or_file_access(self):
        with mock.patch.object(Path, "is_file", return_value=True), mock.patch.object(Path, "read_text", return_value="private"):
            self.assertIn("error", asyncio.run(tune.read_page("../secret", None)))

    def test_state_commits_when_the_runner_appends_the_event(self):
        replay("tuned")
        self.assertEqual([l for l in tune.LOG if l.startswith(("  tool", "  event"))], recorded("tuned", ("  tool", "  event")))
        tool_line = next(l for l in tune.LOG if l.startswith("  tool"))
        self.assertIn("state[last_page]='rollback' stored[last_page]=None", tool_line)

    def test_custom_metadata_is_stamped_on_every_stored_event(self):
        _, stored, _ = replay("tuned")
        tagged = {e.invocation_id: e.custom_metadata["request_id"] for e in stored.events if e.author != "user"}
        self.assertEqual(sorted(tagged.values()), ["req-1", "req-2", "req-3"])

    def test_num_recent_events_limits_the_history_the_model_receives(self):
        requests, _, _ = replay("tuned")
        self.assertEqual([f"  model  call with {len(r.contents)} contents" for r in requests], recorded("tuned", "  model"))
        self.assertEqual(requests[-1].contents[0].parts[0].text, "Which page covers CI tokens?")

    def test_a_cap_of_3_starts_the_history_at_a_function_call(self):
        requests, _, error = replay("cap_at_function_call")
        self.assertIsNotNone(error)  # the recording has no response: Gemini rejected this request with 400
        first = requests[-1].contents[0]
        self.assertEqual(first.role, "model")
        self.assertIsNotNone(first.parts[0].function_call)
        self.assertIn("INVALID_ARGUMENT", RECORD["cap_at_function_call"]["error"])

    def test_the_http_timeout_reaches_the_model_request(self):
        requests, _, _ = replay("timeout")
        self.assertEqual(requests[0].config.http_options.timeout, 1)
        self.assertEqual(requests[0].config.http_options.retry_options.attempts, 3)
        self.assertTrue(RECORD["timeout"]["error"].startswith("builtins.TimeoutError"))


if __name__ == "__main__":
    unittest.main()
record.pySource · pythonDownload
"""Record tune.py on Gemini, plus two RunConfig failures: a history cap that starts at a
function call, and a model-call timeout that is too short.

Run from this folder with GOOGLE_API_KEY set: python record.py
Writes run-output.txt and recorded-runs.json. The tests replay it offline.
"""
import asyncio
import json
import logging
import warnings
from pathlib import Path

warnings.filterwarnings("ignore")
logging.disable(logging.CRITICAL)  # ADK logs each expected failure with a traceback

from google.adk import Runner  # noqa: E402
from google.adk.sessions import InMemorySessionService  # noqa: E402

import tune  # noqa: E402

HERE = Path(__file__).resolve().parent
MODEL_TURNS: list = []


def keep_turn(callback_context, llm_response):
    """after_model_callback: keep each model response (thought signatures dropped) for the replay tests."""
    if llm_response.content:
        parts = [{k: v for k, v in p.model_dump(mode="json", exclude_none=True).items() if k != "thought_signature"}
                 for p in llm_response.content.parts]
        MODEL_TURNS.append({"content": {"role": "model", "parts": parts}})


async def scenario(turns) -> dict:
    tune.LOG.clear()
    MODEL_TURNS.clear()
    agent = tune.root_agent.clone(update={"after_model_callback": keep_turn})
    runner = Runner(app_name=tune.APP, agent=agent, session_service=InMemorySessionService())
    session = await runner.session_service.create_session(app_name=tune.APP, user_id=tune.USER)
    error = None
    try:
        for request_id, text, recent, timeout_ms in turns:
            tune.LOG.append(f"{request_id}: {text}" + (f"  (num_recent_events={recent})" if recent else "")
                            + (f"  (timeout={timeout_ms} ms)" if timeout_ms != 60_000 else ""))
            events = await tune.ask(runner, session.id, text, tune.run_config(request_id, recent, timeout_ms))
            tune.LOG.append(f"  answer: {events[-1].content.parts[-1].text}")
    except Exception as exc:  # the failures are what this scenario records
        error = f"{type(exc).__module__}.{type(exc).__name__}: {exc}"
        tune.LOG.append(f"  raised {error}")
    finally:
        await runner.close()
    return {"turns": [list(t) for t in turns], "log": list(tune.LOG), "model_turns": list(MODEL_TURNS), "error": error}


BASE = [("req-1", "How do I roll back production?", None, 60_000), ("req-2", "Which page covers CI tokens?", None, 60_000)]
SCENARIOS = {
    "tuned": BASE + [("req-3", "What was my first question?", 4, 60_000)],
    "cap_at_function_call": BASE + [("req-3", "What was my first question?", 3, 60_000)],
    "timeout": [("req-1", "How do I roll back production?", None, 1)],
}


def main() -> None:
    rec = {"model": tune.MODEL, **{name: asyncio.run(scenario(turns)) for name, turns in SCENARIOS.items()}}
    out = [f"$ python tune.py   # {tune.MODEL}", *rec["tuned"]["log"], "",
           "# the same session, capped at 3 events", *rec["cap_at_function_call"]["log"][-3:], "",
           "# a 1 ms model-call timeout", *rec["timeout"]["log"]]
    (HERE / "run-output.txt").write_text("\n".join(out) + "\n", encoding="utf-8")
    (HERE / "recorded-runs.json").write_text(json.dumps(rec, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
    print("\n".join(out))


if __name__ == "__main__":
    main()

Check your understanding

A recent-event cap removes the first question but leaves valid tool-call ordering. The next run finishes. What can you conclude?

Choose one answer, then check your reasoning.

Read the explanation

The saved four-event case completed but answered the first-question query incorrectly. The cap changes loaded history, not stored events.

References

Official sources checked October 21, 2026, against google-adk 2.12.0 and the google-genai client it installs.

Previously on the blog: Google ADK Events: Agent Event System & Streaming Guide covers events in ADK 1.x.

Written by Arjun Prabhulal.

Module 1

Run agents and manage sessions