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.
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.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:
python3.12 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements-lock.txt1. 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.
"""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:
python tune.pyCompare 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:
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.
# 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:
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.
python -m unittest -v test_tune.pytest_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
OKAll 6 tests pass.
Read the test and recording scripts
"""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 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()- Resume an interrupted agent run: finish an invocation after the process dies inside a tool.
Check your understanding
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.